diff --git a/CHANGELOG.md b/CHANGELOG.md index d2270f4..be8702c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` + 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 diff --git a/README.md b/README.md index 6679c49..194955e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/migrating-from-mypy.md b/docs/migrating-from-mypy.md index 827a49a..01c3f09 100644 --- a/docs/migrating-from-mypy.md +++ b/docs/migrating-from-mypy.md @@ -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). diff --git a/pants-plugins/pants_pyrefly/goals.py b/pants-plugins/pants_pyrefly/goals.py index 0bf256a..68e11b0 100644 --- a/pants-plugins/pants_pyrefly/goals.py +++ b/pants-plugins/pants_pyrefly/goals.py @@ -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) @@ -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__) @@ -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(),) diff --git a/pants-plugins/pants_pyrefly/rules_integration_test.py b/pants-plugins/pants_pyrefly/rules_integration_test.py index 45a9895..551247b 100644 --- a/pants-plugins/pants_pyrefly/rules_integration_test.py +++ b/pants-plugins/pants_pyrefly/rules_integration_test.py @@ -10,6 +10,7 @@ from pants_pyrefly.goals import ( PyreflyCoverage, + PyreflyInit, PyreflyLspConfig, PyreflySuppress, PyreflyUpdateBaseline, @@ -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( {