pants-pyrefly
A Pants plugin that runs Pyrefly —
Meta's fast, Rust-based Python type checker — as part of the Pants check goal.
Pants downloads the official prebuilt Pyrefly binary (pinned by SHA256) and runs it hermetically in a sandbox, wiring up your first-party source roots and the resolved third-party dependencies so that imports resolve correctly.
Requirements
- Pants 2.27–2.33. A single codebase uses the call-by-name rules API on every supported version and detects the two API differences between those versions at runtime; verified on every minor from 2.27 to 2.33.
- The published wheel is pure-Python —
Requires-Python: >=3.11, with nopantsbuild.pantsdependency (Pants provides itself at runtime) — so a single release installs into any supported Pants, from 2.27 (CPython 3.11) through 2.33 (CPython 3.14).
Installation
Add the plugin and enable its backend in pants.toml:
[GLOBAL]
plugins = ["pants-pyrefly==1.1.1"]
backend_packages.add = [
"pants.backend.python",
"pants_pyrefly",
]
From source (in-repo)
Prefer to vendor the plugin — for rapid iteration, or to pin to an exact source state? Consume it
the way in-repo plugins are normally loaded: copy pants-plugins/pants_pyrefly/ into your repo and:
[GLOBAL]
pythonpath = ["%(buildroot)s/pants-plugins"]
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:
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) so your editor resolves first-party imports the way Pants does.
Usage
pants check :: # type-check everything
pants check path/to/dir:: # type-check a subtree
Configuration
[pyrefly] subsystem options:
| Option | Env / flag | Description |
|---|---|---|
skip |
--pyrefly-skip / PANTS_PYREFLY_SKIP |
Don't run Pyrefly during check. |
args |
--pyrefly-args |
Extra args passed to Pyrefly, e.g. --pyrefly-args='--python-version 3.12'. |
extra_type_stubs |
--pyrefly-extra-type-stubs |
Stub-only packages to add to the type-check environment without making them runtime deps, e.g. types-requests, sqlalchemy2-stubs==0.0.2a38. Resolved directly, so pin versions for reproducibility. |
output_format |
--pyrefly-output-format |
Override Pyrefly's output format: min-text, full-text, json, github, junit-xml, omit-errors. |
min_severity |
--pyrefly-min-severity |
Only show errors at/above this severity (ignore/info/warn/error). |
only |
--pyrefly-only |
Only report these error kinds (e.g. bad-assignment); handy for triage. |
config |
--pyrefly-config |
Path to a pyrefly.toml / pyproject.toml (disables discovery). |
config_discovery |
--[no-]pyrefly-config-discovery |
Auto-discover pyrefly.toml / [tool.pyrefly]. |
baseline |
--pyrefly-baseline |
Path to a Pyrefly baseline JSON; check then reports only errors new since the baseline. Generate it with pants pyrefly-update-baseline ::. |
exclude_source_roots |
--pyrefly-exclude-source-roots (advanced) |
Source roots to omit from --search-path. Rarely needed — nested roots are deduped automatically (see below); use this only to force-drop a root the automatic logic keeps. |
version / known_versions / url_template |
(advanced) | Pin or override the downloaded Pyrefly binary. Any supported Pyrefly version needs only version. |
Opt a target out of Pyrefly:
python_sources(skip_pyrefly=True)
Incremental adoption (baseline)
Adopting Pyrefly on a codebase that already has type errors? Record them in a baseline so check
only fails on new errors:
pants pyrefly-update-baseline :: # writes the file named by [pyrefly].baseline
pants check :: # now reports only errors introduced since the baseline
Configure the path (and commit the baseline file):
[pyrefly]
baseline = "build-support/pyrefly-baseline.json"
Re-run pants pyrefly-update-baseline :: after fixing errors, or to refresh it. Baseline matching is
Pyrefly's own (lenient by design, so it survives code churn).
Pants runs Pyrefly once per partition (resolve and interpreter constraints), and each run gets only the baseline entries for the files it checks. Entries for files outside the run's targets therefore never suppress anything there. They don't count as stale either, except for entries whose files were deleted (see below).
pants pyrefly-update-baseline rewrites the whole baseline file from the targets you give it, and
only those. A subset run such as pants pyrefly-update-baseline src/b:: drops every entry for
files outside src/b, so always pass :: to refresh the baseline.
Catching stale entries (Pyrefly 1.3+). To fail check when the baseline holds entries for
errors that are gone, add Pyrefly's --error-stale-baseline:
[pyrefly]
baseline = "build-support/pyrefly-baseline.json"
args = ["--error-stale-baseline"]
An entry is stale when its file is checked and no longer has that error, or when its file no longer
exists. A stale entry fails the partition that checks its file. Entries for deleted files belong to
no partition, so the first partition of every run carries them, even a subset run: pants check src/b:: fails on a stale entry for a deleted file anywhere in the repo, as plain Pyrefly does when
given a list of files. Pyrefly prints only a count, not the entries. To clean them up, run
pants pyrefly-update-baseline ::. The goal ignores --error-stale-baseline, so it can stay in
[pyrefly].args.
Limitation: files checked by more than one partition. Baseline entries are keyed by file path
only. When one file is checked in several partitions (for example, parametrize over interpreter
constraints or resolves) and an error occurs in only some of them, the partitions without the error
see its entry as stale. --error-stale-baseline then fails on every run, and
pants pyrefly-update-baseline :: writes the entry straight back. For an error that depends on the
Python version or resolve, use an inline suppression on that line instead of a baseline entry:
from typing import override # pyrefly: ignore[missing-module-attribute]
Partitions where the error does not occur ignore the unused comment (by default).
Pyrefly's --prune-baseline (which rewrites the baseline in place) is not supported in
[pyrefly].args: check and pyrefly-update-baseline fail with a PyreflyArgsError that says so.
Under Pants it would only prune a temporary copy, and check never writes to your repo. Run
pants pyrefly-update-baseline :: instead. It rewrites the whole baseline from the current errors
in the targets given (all of them, with ::), so stale entries are dropped. Unlike
--prune-baseline, it also records any new errors.
Prefer inline suppressions? pants pyrefly-suppress :: instead rewrites the targeted files in
place, adding # pyrefly: ignore on each current error (Pyrefly's suppress); delete them as you
fix, or run pants pyrefly-suppress --pyrefly-suppress-remove-unused :: to strip stale ones. An
external baseline (JSON) and inline suppressions are two strategies for the same goal — pick one.
Migrating from MyPy
Moving a Pants repo off MyPy? See docs/migrating-from-mypy.md —
config conversion (pyrefly init --migrate-from mypy), running both checkers during the transition,
baseline-based incremental adoption, and the MyPy-plugin gap (SQLAlchemy et al.).
Editor / IDE (LSP)
Pyrefly ships an LSP server, but in a Pants repo your editor doesn't know the source roots. Generate
a pyrefly.toml with them:
pants pyrefly-lsp-config # writes search-path (= your source roots) + python-version
For third-party imports, point your editor's interpreter at a venv (e.g.
pants export --resolve=python-default). If your Pyrefly config lives in pyproject.toml
[tool.pyrefly], the goal prints the keys to add instead of writing a shadowing pyrefly.toml.
Type coverage
Track typing progress — useful as a migration ratchet:
pants pyrefly-coverage :: # prints overall % typed
pants pyrefly-coverage --pyrefly-coverage-fail-under=80 :: # also fails if below 80%
How import resolution works
-
First-party code: your source roots are passed to Pyrefly via
--search-path(the analogue ofMYPYPATH/sys.path). Pants gives every file exactly one source root, but Pyrefly makes a file importable under every search path that physically contains it — so when source roots nest (the common case: the build root.abovesrc/python), a file gets two module identities (pkg.modandsrc.python.pkg.mod) and Pyrefly reports spurious errors where one flows into the other.For
checkandpyrefly-suppress, the plugin removes the nesting structurally: each source root's files are re-staged in the sandbox under its own sibling directory (__pyrefly_root_<n>) with the root prefix stripped, and each of those is passed as a single--search-pathalongside--disable-search-path-heuristics. Sibling directories can't nest, so every file is reachable under exactly one module identity no matter howroot_patternsoverlap. Pyrefly's synthetic paths are mapped back to real repo paths in diagnostics (including the search-path list in missing-import hints, which names the real source roots), baseline files, andsuppressedits, so this is invisible in output.The diagnostic goals (
pyrefly-coverage,pyrefly-dump-config,pyrefly-lsp-config) don't re-stage — they pass your real source roots, deduplicated to each file's nearest root. If first-party code genuinely roots at both an ancestor and a nested root, both are kept and the plugin warns;[pyrefly].exclude_source_rootsforce-drops one. -
Third-party deps: Pants materializes the target's resolved requirements into a venv and points Pyrefly's
--python-interpreter-pathat it, so Pyrefly discoverssite-packagesand the target Python version exactly asimportwould at runtime.
Diagnostics
When Pyrefly resolves imports or the interpreter differently than you expect, dump the effective
configuration Pants assembles — the first-party search-paths, the interpreter used for third-party
resolution, and the config file in effect:
pants pyrefly-dump-config :: # whole repo
pants pyrefly-dump-config src/project:: # a subtree
This runs Pyrefly's dump-config subcommand with exactly the arguments Pants passes to check, so
what you see is what pants check sees. It does not type-check. When targets span multiple resolves
or interpreter constraints, each partition's config is printed under its own heading.
Pants compatibility
| Plugin version | Pants | Pyrefly (default) |
|---|---|---|
1.1.1 |
2.27–2.33 |
1.4.0 |
1.1.0 |
2.27–2.33 |
1.3.2 |
1.0.0 |
2.27–2.33 |
1.2.0 |
0.5.0 |
2.27–2.32 |
1.1.1 |
0.4.0 |
2.27–2.32 |
1.1.1 |
0.3.0 |
2.27–2.32 |
1.1.1 |
0.2.0 |
2.27–2.32 |
1.1.1 |
0.1.0 |
2.27–2.32 |
1.1.1 |
The plugin uses the call-by-name rules API, which every supported version provides; it never uses
Get/MultiGet (removed by Pants 2.32). Two things differ across the supported range, and the
plugin detects which side it is on at runtime: the coarsened-targets rule was renamed
(coarsened_targets on 2.27–2.28, resolve_coarsened_targets on 2.29+), and the check goal's
default_process_cache_scope, which honors --force, only exists on 2.32+ (older versions cache
successful runs, so pants check --force does not rerun Pyrefly there). CI smoke-tests
consumption on 2.27, 2.31, 2.32, and 2.33 on every pull request, and the pre-release
compatibility suite runs on every minor from 2.27 to 2.33.
Supported Pyrefly versions
The plugin ships checksums for a set of stable Pyrefly releases, from 1.1.1 (the plugin's
first default) upward. Every version listed in its default_known_versions is pinned and tested,
and any of them can be selected with version alone:
[pyrefly]
version = "1.2.0"
Pre-releases (X.Y.Z-dev.N) are never pinned; to run one, set known_versions yourself. The policy:
- New Pyrefly releases are considered as they come out, including backports to older lines.
- A pinned version is removed only deliberately, with the reason recorded in the plugin's
DENYLISTED_VERSIONS(insubsystems.py) and a CHANGELOG note. - Versions older than 1.1.1 are added on request: open an issue.
Selecting a denylisted version fails with the recorded reason rather than a generic
UnknownVersion, and suggests the plugin's default:
DenylistedPyreflyVersion: Pyrefly 1.2.1 is not supported by pants-pyrefly: <reason>. Set [pyrefly].version to a supported release (1.4.0).
To use a denylisted version anyway, supply your own [pyrefly].known_versions entry for it on
your platform. That is a deliberate choice, and the plugin does not block it.
Every supported version is exercised by the compatibility suite before each release.
Stability
From 1.0.0 on, this project follows Semantic Versioning. Covered by the compatibility promise — a breaking change to any of these requires a major bump:
- The goal names (
pyrefly-init,pyrefly-lsp-config,pyrefly-coverage,pyrefly-suppress,pyrefly-update-baseline,pyrefly-dump-config) and Pyrefly's participation incheck. - The
[pyrefly]option names documented under Configuration, and theskip_pyreflyfield. - The backend name
pants_pyrefly, and the published wheel carrying nopantsbuild.pantsdependency.
Not covered: the plugin's Python API (every module is an implementation detail — import nothing
from pants_pyrefly directly), the default pinned Pyrefly version, the exact wording and layout of
Pyrefly's own diagnostic output, the contents and format of the baseline file (Pyrefly writes it,
and its format can change between Pyrefly releases), and the sandbox staging mechanics described under
How import resolution works. Dropping a Pants version that has
reached end of life is a minor bump, not a major one.
Development
This repo dogfoods its own tooling: ruff (lint + format) and Pyrefly itself (check) run on the
plugin's sources.
pants generate-lockfiles # pants-plugins + python-default resolves
pants fmt lint :: # ruff format + check
pants check :: # Pyrefly type-checks the plugin (dogfood) + testprojects/
pants test :: # run the integration tests
pants package pants-plugins/pants_pyrefly:dist # build the wheel + sdist into dist/
Bumping the pinned Pyrefly version
The default_known_versions pins in subsystems.py (<version>|<platform>|<sha256>|<size>) are
managed by build-support/bin/generate_known_versions.py, not hand-edited. They are ordered newest
version first, platforms in a fixed order. The script reads everything it needs from
subsystems.py: MINIMUM_PINNED_VERSION and DENYLISTED_VERSIONS (module-level constants, the
single source of truth for each), plus the default, URL template, and platform mapping.
GEN=build-support/bin/generate_known_versions.py
python3 $GEN --check-upstream # stable releases not yet pinned (or denylisted)?
python3 $GEN --version <new> --write # move the default and add the missing pins
python3 $GEN --write # add missing pins (e.g. a backport) only
python3 $GEN --check # verify the shipped pins (what CI runs)
python3 $GEN --remove <ver> --reason "<why>" # drop a version's pins and denylist it
python3 $GEN --list-versions # supported versions as JSON (the compat matrix)
python3 $GEN --default-version # the default version
--writeonly adds. It inserts pins for every stable release in[minimum, default]that isn't already pinned or denylisted, and editsdefault_version. It never removes or rewrites an existing pin: lowering the default keeps the higher pins, and a pin that disagrees with its release is reported (exit 1), not overwritten.--checkverifies only what ships. Each pin's sha256 and size must match its release, the default must be pinned, the order must be canonical, nothing may be older than the minimum, and no denylisted version may be pinned or be the default. An unpinned upstream release, whether newer than the default or an in-range backport, never fails--check, so a new Pyrefly release never turns unrelated CI red.--check-upstreamlists every stable release at or above the minimum that is neither pinned nor denylisted, and exits 1 if there are any. It also prints the commands to run:--version <newest> --writewhen there are newer releases (it pins the backports too),--writefor backports alone, and--remove X.Y.Z --reason "..."to skip one.--removeis the only removal path: it deletes the version's pins (if any) and records the reason inDENYLISTED_VERSIONS, so--writenever re-adds it and--check-upstreamstops reporting it. It also works for a never-pinned release you want to skip. It refuses the default, and it refuses the minimum: raiseMINIMUM_PINNED_VERSIONdeliberately instead. The reason must be a single line; it is stored exactly as typed, in the quote styleruff formatprefers (long reasons are split across adjacent string literals), so nopants fmtis needed.--writeand--removeeditsubsystems.pyline by line: they change only thedefault_versionliteral, insert or delete pin lines, and add a denylist entry. Every other line, including comments and quoting, is left byte-identical. They refuse layouts they can't edit safely (for example two pins on one line) and leave the file untouched.
Failures are reported as a single error: ... line (naming the file and line where relevant) with
exit status 1, never a traceback. Set GENERATE_KNOWN_VERSIONS_DEBUG=1 to see the traceback of an
unexpected error.
If Pyrefly withdraws a pinned release (deletes it, or re-labels it as a pre-release), --check
fails, and the fix is --remove <version> --reason "...".
The script fetches each asset's published .sha256 sidecar (rejecting anything that isn't a
64-character hex digest) and size from the GitHub release, retrying transient failures up to twice.
Set GITHUB_TOKEN to avoid API rate limits; the token is only sent to api.github.com.
The upstream workflow runs --check-upstream every Monday
(06:17 UTC), and on demand. It only reports: it never writes pins or opens a PR, and it does not
run the compatibility suite. When it fails, the job summary shows the script's output. An error:
line saying "neither pinned nor denylisted" is a real finding: run one of the printed commands
and open a PR. Any other failure, such as GET ... failed after 3 attempts, is usually a GitHub
API or network problem, so re-run the workflow. GitHub notifies the user who last changed the
workflow's cron line of a failed scheduled run, subject to their Actions notification settings.
GitHub disables scheduled workflows in a public repository after 60 days without repository activity. If the weekly run stops, re-enable it from the repository's Actions tab.
Pyrefly compatibility suite
build-support/ci/compat_test.sh <version> builds a throwaway project that loads the plugin from
source and drives real Pants runs with only --pyrefly-version=<version> set, so the shipped pins
resolve the download. It checks that a clean file passes, that a missing import fails with
missing-import, that pyrefly-update-baseline followed by a gated check passes, and that
pyrefly-suppress followed by check passes. For every Pyrefly process it asserts, from the
binary in that process's preserved sandbox, that <version> is what actually ran, and that the
process got --python-version set to the project's minimum Python. Each Pants run uses an
execution root inside the script's own work directory, so the script leaves nothing behind in
$TMPDIR.
The script runs under this repo's Pants with a CPython>=3.11,<3.15 project by default. Set
PANTS_VERSION to use another Pants, and COMPAT_INTERPRETER_CONSTRAINTS plus
COMPAT_PYTHON_VERSION (the constraints' minimum) to check another Python. The project needs a
real interpreter matching its constraints, since the plugin builds the third-party venv Pyrefly
inspects with one. When the minimum is older than 3.10, the script also checks that a match
statement fails with invalid-syntax.
The compatibility workflow runs the script in separate jobs:
- one per supported Pyrefly version (
--list-versions), on this repo's Pants; - one per supported Pants minor, at its latest stable patch (2.27.1, 2.28.1, 2.29.1, 2.30.2,
2.31.0, 2.32.1, and 2.33.1), with the default Pyrefly version (
--default-version); - one with the default Pyrefly on this repo's Pants, against a
CPython==3.9.*project.
It runs on demand and as a required step of the release workflow, so if any job fails, nothing is published. It does not run on pull requests.
Releasing
Push a vX.Y.Z tag. The release workflow first runs the
Pyrefly compatibility suite (every supported Pyrefly version, and
every supported Pants minor), then builds the wheel and publishes it to PyPI using Trusted Publishing (OIDC,
no API tokens). Configure a PyPI trusted publisher for this repo + the release.yml workflow first.
License
Metadata
Release files for pants-pyrefly 1.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| pants_pyrefly-1.1.1.tar.gz | 49.8 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pants_pyrefly-1.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 85.7 kB
Release files / pants_pyrefly-1.1.1.tar.gz
| Download URL | pants_pyrefly-1.1.1.tar.gz |
|---|---|
| Size | 49.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b0c1d55428a984767fe60f8f4638b45d58f0b0c41fe3ba5e8cb3ccb8eccc6f9c
|
|
BLAKE2b-256 checksum How to use checksums |
27232b19d9850e8da14bda6fb3088bec4acaddc0e0901084d5a0a5a7e6651f3a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logRelease files / pants_pyrefly-1.1.1-py3-none-any.whl
| Download URL | pants_pyrefly-1.1.1-py3-none-any.whl |
|---|---|
| Size | 35.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7417e1a318d24628df6017a2a5013ca8186d381e17ea525c81bdadf7a84b8e0d
|
|
BLAKE2b-256 checksum How to use checksums |
44fbfd12d8d2c101b0abe7d10e5b4a308eb6b5b838f749f9bbcde96db1b1445c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logSecurity for pants-pyrefly 1.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for pants_pyrefly-1.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.