Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to `pants-pyrefly` are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## 0.3.0 (unreleased)

- `pants pyrefly-init` bootstraps a Pyrefly config for the repo (wraps `pyrefly init`), migrating an
existing MyPy or Pyright configuration when one is found. `--pyrefly-init-migrate-from=<auto|mypy|pyright>`
selects the source. It refuses to overwrite an existing config.

## 0.2.0 (2026-07-10)

- `pants pyrefly-suppress` inserts inline `# pyrefly: ignore` comments for the current errors in the
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ backend_packages.add = ["pants.backend.python", "pants_pyrefly"]
If you keep plugin code in a dedicated `pants-plugins` resolve, add it there and run
`pants generate-lockfiles`.

## Getting started

Bootstrap a Pyrefly config for the repo (wraps `pyrefly init`). If you already have a MyPy or
Pyright configuration, it is migrated into the new `pyrefly.toml`:

```bash
pants pyrefly-init # create pyrefly.toml (auto-migrates mypy/pyright)
pants pyrefly-init --pyrefly-init-migrate-from=mypy # force migrating from a MyPy config
```

It refuses to overwrite an existing `pyrefly.toml` (or a `[tool.pyrefly]` table in
`pyproject.toml`) — remove it first to regenerate. Then run `pants pyrefly-lsp-config` (see
[Editor / IDE](#editor--ide-lsp)) so your editor resolves first-party imports the way Pants does.

## Usage

```bash
Expand Down
9 changes: 5 additions & 4 deletions docs/migrating-from-mypy.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ pants --only=mypy check :: # just MyPy

## 2. Convert your MyPy config

Pyrefly can import an existing MyPy (or Pyright) configuration:
Pyrefly can import an existing MyPy (or Pyright) configuration. Run it through Pants so the plugin
manages the Pyrefly binary for you:

```bash
pyrefly init --migrate-from mypy
pants pyrefly-init --pyrefly-init-migrate-from=mypy
```

This generates a `pyrefly.toml` (or a `pyproject.toml [tool.pyrefly]` table) from your `[mypy]` /
`mypy.ini` settings. Review the result — not everything maps 1:1:
This generates a `pyrefly.toml` from your `[mypy]` / `mypy.ini` settings. Review the result — not
everything maps 1:1:

- `ignore_missing_imports`, per-module overrides, and strictness flags migrate.
- **MyPy *plugins* do not** — Pyrefly has no plugin system (see the gap note below).
Expand Down
132 changes: 130 additions & 2 deletions pants-plugins/pants_pyrefly/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import json
import logging
import os
from collections.abc import Iterable

import toml # pants: no-infer-dep (provided by the Pants runtime)
Expand Down Expand Up @@ -42,12 +43,14 @@
merge_digests,
path_globs_to_digest,
)
from pants.core.util_rules.external_tool import download_external_tool
from pants.engine.platform import Platform
from pants.engine.process import ProcessCacheScope
from pants.engine.process import Process, ProcessCacheScope
from pants.engine.rules import Rule, collect_rules, concurrently, goal_rule, implicitly
from pants.engine.target import AllTargets, Targets
from pants.engine.unions import UnionRule
from pants.option.option_types import BoolOption, FloatOption
from pants.option.option_types import BoolOption, FloatOption, StrOption
from pants.util.logging import LogLevel
from pants.util.strutil import softwrap

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -406,5 +409,130 @@ async def pyrefly_suppress(
return PyreflySuppress(exit_code=0)


# ---
# `pyrefly-init`
# ---

_MIGRATE_FROM_CHOICES = ("auto", "mypy", "pyright")


class PyreflyInitSubsystem(GoalSubsystem):
name = "pyrefly-init"
help = softwrap(
"""
Bootstrap a Pyrefly config for this repo by running `pyrefly init`. Writes a `pyrefly.toml`
in the build root, migrating an existing MyPy or Pyright configuration when one is found.
Run once when adopting Pyrefly. After it writes the config, run `pants pyrefly-lsp-config`
to add Pants's source roots so the editor/LSP resolves first-party imports.
"""
)

migrate_from = StrOption(
default=None,
help=softwrap(
"""
Which existing type-checker config to migrate from: `auto` (try MyPy, then Pyright),
`mypy`, or `pyright`. When unset, Pyrefly's own default (`auto`) is used.
"""
),
)


class PyreflyInit(Goal):
subsystem_cls = PyreflyInitSubsystem
environment_behavior = Goal.EnvironmentBehavior.LOCAL_ONLY


@goal_rule
async def pyrefly_init(
init_subsystem: PyreflyInitSubsystem,
pyrefly: Pyrefly,
workspace: Workspace,
platform: Platform,
) -> PyreflyInit:
if (
init_subsystem.migrate_from is not None
and init_subsystem.migrate_from not in _MIGRATE_FROM_CHOICES
):
logger.error(
f"`--pyrefly-init-migrate-from` must be one of {', '.join(_MIGRATE_FROM_CHOICES)}; "
f"got `{init_subsystem.migrate_from}`."
)
return PyreflyInit(exit_code=1)

# `pyrefly init` reads any existing type-checker config from the working directory; materialize
# the candidates into the sandbox (no source files are needed — init does not type-check).
downloaded_pyrefly, config_digest = await concurrently(
download_external_tool(pyrefly.get_request(platform)),
path_globs_to_digest(
PathGlobs(
["pyproject.toml", "mypy.ini", "setup.cfg", "pyrefly.toml"],
glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
)
),
)
input_files = {fc.path: fc.content for fc in await get_digest_contents(config_digest)}

# `pyrefly init` refuses to overwrite an existing config; surface that as a clear message
# rather than a bare nonzero exit. The `b"[tool.pyrefly"` sentinel matches `config_request()`.
already_configured = "pyrefly.toml" in input_files or (
b"[tool.pyrefly" in input_files.get("pyproject.toml", b"")
)
if already_configured:
logger.error(
softwrap(
"""
A Pyrefly config already exists (`pyrefly.toml`, or `[tool.pyrefly]` in
`pyproject.toml`). Remove it and re-run `pants pyrefly-init` to regenerate.
"""
)
)
return PyreflyInit(exit_code=1)

tool_key = "__pyrefly_tool"
exe_path = os.path.normpath(os.path.join(tool_key, downloaded_pyrefly.exe))
argv = [exe_path, "init", "--non-interactive"]
if init_subsystem.migrate_from is not None:
argv += ["--migrate-from", init_subsystem.migrate_from]
argv.append(".")

result = await execute_process(
Process(
argv=tuple(argv),
input_digest=config_digest,
immutable_input_digests={tool_key: downloaded_pyrefly.digest},
# Capture both possible targets; a nonexistent output file is simply omitted, so this
# grabs whichever file `init` writes without us needing to predict it.
output_files=("pyrefly.toml", "pyproject.toml"),
description="Bootstrap a Pyrefly config (`pyrefly init`).",
level=LogLevel.DEBUG,
cache_scope=ProcessCacheScope.PER_SESSION,
),
**implicitly(),
)
if result.exit_code != 0:
logger.error(
f"`pyrefly init` failed (exit {result.exit_code}):\n"
f"{result.stderr.decode(errors='replace')}"
)
return PyreflyInit(exit_code=result.exit_code)

# Write back only the files `init` actually created or changed (it also captures an untouched
# pyproject.toml as a maybe-target — don't rewrite it needlessly).
changed = [
fc
for fc in await get_digest_contents(result.output_digest)
if input_files.get(fc.path) != fc.content
]
if not changed:
logger.warning("`pyrefly init` produced no config changes.")
return PyreflyInit(exit_code=0)

output_digest = await create_digest(CreateDigest(changed))
workspace.write_digest(output_digest)
logger.info(f"Wrote Pyrefly config: {', '.join(sorted(fc.path for fc in changed))}.")
return PyreflyInit(exit_code=0)


def rules() -> Iterable[Rule | UnionRule]:
return (*collect_rules(),)
24 changes: 24 additions & 0 deletions pants-plugins/pants_pyrefly/rules_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from pants_pyrefly.goals import (
PyreflyCoverage,
PyreflyInit,
PyreflyLspConfig,
PyreflySuppress,
PyreflyUpdateBaseline,
Expand Down Expand Up @@ -282,6 +283,29 @@ def test_update_baseline_roundtrip(rule_runner: PythonRuleRunner) -> None:
assert gated[0].exit_code == 0


def test_init_creates_config(rule_runner: PythonRuleRunner) -> None:
# A repo with a MyPy config and no Pyrefly config: `pyrefly-init` migrates it into pyrefly.toml.
rule_runner.write_files({"mypy.ini": "[mypy]\nstrict = True\npython_version = 3.11\n"})
result = rule_runner.run_goal_rule(
PyreflyInit, args=["--pyrefly-init-migrate-from=mypy"], env_inherit=_ENV_INHERIT
)
assert result.exit_code == 0
config_path = os.path.join(rule_runner.build_root, "pyrefly.toml")
assert os.path.exists(config_path)
with open(config_path) as fh:
assert fh.read().strip() # non-empty config was written


def test_init_refuses_existing_config(rule_runner: PythonRuleRunner) -> None:
original = 'preset = "legacy"\n# hand-tuned\n'
rule_runner.write_files({"pyrefly.toml": original})
result = rule_runner.run_goal_rule(PyreflyInit, env_inherit=_ENV_INHERIT)
assert result.exit_code == 1
# The existing config must be left untouched.
with open(os.path.join(rule_runner.build_root, "pyrefly.toml")) as fh:
assert fh.read() == original


def test_lsp_config_writes_search_path(rule_runner: PythonRuleRunner) -> None:
rule_runner.write_files(
{
Expand Down