pants-pyrefly 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pants_pyrefly-0.1.0/MANIFEST.in +1 -0
- pants_pyrefly-0.1.0/PKG-INFO +181 -0
- pants_pyrefly-0.1.0/backend_shim.py +31 -0
- pants_pyrefly-0.1.0/pants_pyrefly/__init__.py +0 -0
- pants_pyrefly-0.1.0/pants_pyrefly/goals.py +320 -0
- pants_pyrefly-0.1.0/pants_pyrefly/register.py +23 -0
- pants_pyrefly-0.1.0/pants_pyrefly/rules.py +368 -0
- pants_pyrefly-0.1.0/pants_pyrefly/skip_field.py +33 -0
- pants_pyrefly-0.1.0/pants_pyrefly/subsystems.py +174 -0
- pants_pyrefly-0.1.0/pants_pyrefly.egg-info/PKG-INFO +181 -0
- pants_pyrefly-0.1.0/pants_pyrefly.egg-info/SOURCES.txt +15 -0
- pants_pyrefly-0.1.0/pants_pyrefly.egg-info/dependency_links.txt +1 -0
- pants_pyrefly-0.1.0/pants_pyrefly.egg-info/entry_points.txt +2 -0
- pants_pyrefly-0.1.0/pants_pyrefly.egg-info/namespace_packages.txt +1 -0
- pants_pyrefly-0.1.0/pants_pyrefly.egg-info/top_level.txt +1 -0
- pants_pyrefly-0.1.0/setup.cfg +4 -0
- pants_pyrefly-0.1.0/setup.py +203 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include *.py
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pants-pyrefly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Pants plugin that runs the Pyrefly type checker in the `check` goal.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: pants,pantsbuild,pants-plugin,pyrefly,typecheck
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
9
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Dynamic: classifier
|
|
17
|
+
Dynamic: description
|
|
18
|
+
Dynamic: description-content-type
|
|
19
|
+
Dynamic: keywords
|
|
20
|
+
Dynamic: license
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
# pants-pyrefly
|
|
25
|
+
|
|
26
|
+
A [Pants](https://www.pantsbuild.org/) plugin that runs [Pyrefly](https://pyrefly.org/) —
|
|
27
|
+
Meta's fast, Rust-based Python type checker — as part of the Pants `check` goal.
|
|
28
|
+
|
|
29
|
+
Pants downloads the official prebuilt Pyrefly binary (pinned by SHA256) and runs it hermetically
|
|
30
|
+
in a sandbox, wiring up your first-party source roots and the resolved third-party dependencies so
|
|
31
|
+
that imports resolve correctly.
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- **Pants 2.27–2.32.** A single codebase supports both the legacy (`Get`/`MultiGet`-era) and modern
|
|
36
|
+
(call-by-name) rules APIs via a small version-conditional import; verified on 2.27 and 2.32.
|
|
37
|
+
- The **published wheel** is pure-Python — `Requires-Python: >=3.12`, with **no `pantsbuild.pants`
|
|
38
|
+
dependency** (Pants provides itself at runtime) — so it installs into any Pants whose runtime is
|
|
39
|
+
CPython 3.12+ (e.g. 2.32 → CPython 3.14). **Pants 2.27** runs on CPython 3.11, below the floor —
|
|
40
|
+
install **from source** there (see [Installation](#installation)); the plugin code still supports it.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
Add the plugin and enable its backend in `pants.toml`:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
[GLOBAL]
|
|
48
|
+
plugins = ["pants-pyrefly==0.1.0"]
|
|
49
|
+
backend_packages.add = [
|
|
50
|
+
"pants.backend.python",
|
|
51
|
+
"pants_pyrefly",
|
|
52
|
+
]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### From source (in-repo)
|
|
56
|
+
|
|
57
|
+
Prefer to vendor the plugin — for rapid iteration, to pin to an exact source state, or to run on
|
|
58
|
+
**Pants 2.27** (its CPython 3.11 runtime is below the published wheel's floor)? Consume it the way
|
|
59
|
+
in-repo plugins are normally loaded: copy `pants-plugins/pants_pyrefly/` into your repo and:
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[GLOBAL]
|
|
63
|
+
pythonpath = ["%(buildroot)s/pants-plugins"]
|
|
64
|
+
backend_packages.add = ["pants.backend.python", "pants_pyrefly"]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
If you keep plugin code in a dedicated `pants-plugins` resolve, add it there and run
|
|
68
|
+
`pants generate-lockfiles`.
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pants check :: # type-check everything
|
|
74
|
+
pants check path/to/dir:: # type-check a subtree
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
`[pyrefly]` subsystem options:
|
|
80
|
+
|
|
81
|
+
| Option | Env / flag | Description |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `skip` | `--pyrefly-skip` / `PANTS_PYREFLY_SKIP` | Don't run Pyrefly during `check`. |
|
|
84
|
+
| `args` | `--pyrefly-args` | Extra args passed to Pyrefly, e.g. `--pyrefly-args='--python-version 3.12'`. |
|
|
85
|
+
| `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. |
|
|
86
|
+
| `output_format` | `--pyrefly-output-format` | Override Pyrefly's output format: `min-text`, `full-text`, `json`, `github`, `junit-xml`, `omit-errors`. |
|
|
87
|
+
| `min_severity` | `--pyrefly-min-severity` | Only show errors at/above this severity (`ignore`/`info`/`warn`/`error`). |
|
|
88
|
+
| `only` | `--pyrefly-only` | Only report these error kinds (e.g. `bad-assignment`); handy for triage. |
|
|
89
|
+
| `config` | `--pyrefly-config` | Path to a `pyrefly.toml` / `pyproject.toml` (disables discovery). |
|
|
90
|
+
| `config_discovery` | `--[no-]pyrefly-config-discovery` | Auto-discover `pyrefly.toml` / `[tool.pyrefly]`. |
|
|
91
|
+
| `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`. |
|
|
92
|
+
| `version` / `known_versions` / `url_template` | (advanced) | Pin or override the downloaded Pyrefly binary. |
|
|
93
|
+
|
|
94
|
+
Opt a target out of Pyrefly:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
python_sources(skip_pyrefly=True)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Incremental adoption (baseline)
|
|
101
|
+
|
|
102
|
+
Adopting Pyrefly on a codebase that already has type errors? Record them in a baseline so `check`
|
|
103
|
+
only fails on *new* errors:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pants pyrefly-update-baseline :: # writes the file named by [pyrefly].baseline
|
|
107
|
+
pants check :: # now reports only errors introduced since the baseline
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Configure the path (and commit the baseline file):
|
|
111
|
+
|
|
112
|
+
```toml
|
|
113
|
+
[pyrefly]
|
|
114
|
+
baseline = "build-support/pyrefly-baseline.json"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Re-run `pants pyrefly-update-baseline` after fixing errors, or to refresh it. Baseline matching is
|
|
118
|
+
Pyrefly's own (lenient by design, so it survives code churn).
|
|
119
|
+
|
|
120
|
+
## Editor / IDE (LSP)
|
|
121
|
+
|
|
122
|
+
Pyrefly ships an LSP server, but in a Pants repo your editor doesn't know the source roots. Generate
|
|
123
|
+
a `pyrefly.toml` with them:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pants pyrefly-lsp-config # writes search-path (= your source roots) + python-version
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For third-party imports, point your editor's interpreter at a venv (e.g.
|
|
130
|
+
`pants export --resolve=python-default`). If your Pyrefly config lives in `pyproject.toml`
|
|
131
|
+
`[tool.pyrefly]`, the goal prints the keys to add instead of writing a shadowing `pyrefly.toml`.
|
|
132
|
+
|
|
133
|
+
## Type coverage
|
|
134
|
+
|
|
135
|
+
Track typing progress — useful as a migration ratchet:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pants pyrefly-coverage :: # prints overall % typed
|
|
139
|
+
pants pyrefly-coverage --pyrefly-coverage-fail-under=80 :: # also fails if below 80%
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## How import resolution works
|
|
143
|
+
|
|
144
|
+
- **First-party code:** every source root is passed to Pyrefly via `--search-path` (the analogue of
|
|
145
|
+
`MYPYPATH` / `sys.path`).
|
|
146
|
+
- **Third-party deps:** Pants materializes the target's resolved requirements into a venv and points
|
|
147
|
+
Pyrefly's `--python-interpreter-path` at it, so Pyrefly discovers `site-packages` and the target
|
|
148
|
+
Python version exactly as `import` would at runtime.
|
|
149
|
+
|
|
150
|
+
## Pants compatibility
|
|
151
|
+
|
|
152
|
+
| Plugin version | Pants | Pyrefly (default) |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| `0.1.0` | `2.27`–`2.32` | `1.1.1` |
|
|
155
|
+
|
|
156
|
+
The plugin supports both the legacy (`Get`/`MultiGet`) and modern (call-by-name) rules APIs through
|
|
157
|
+
a small version-conditional import (the rules API changed at Pants 2.30, and again removed `Get`
|
|
158
|
+
by 2.32). Verified on 2.27 and 2.32; in-between versions use the same modern API.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
This repo dogfoods its own tooling: `ruff` (lint + format) and Pyrefly itself (`check`) run on the
|
|
163
|
+
plugin's sources.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pants generate-lockfiles # pants-plugins + python-default resolves
|
|
167
|
+
pants fmt lint :: # ruff format + check
|
|
168
|
+
pants check :: # Pyrefly type-checks the plugin (dogfood) + testprojects/
|
|
169
|
+
pants test :: # run the integration tests
|
|
170
|
+
pants package pants-plugins/pants_pyrefly:dist # build the wheel + sdist into dist/
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Releasing
|
|
174
|
+
|
|
175
|
+
Push a `vX.Y.Z` tag. The [release workflow](.github/workflows/release.yml) builds the wheel and
|
|
176
|
+
publishes it to PyPI using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC,
|
|
177
|
+
no API tokens). Configure a PyPI trusted publisher for this repo + the `release.yml` workflow first.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
[Apache-2.0](LICENSE).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
|
|
2
|
+
# DO NOT EDIT THIS FILE -- AUTOGENERATED BY PANTS
|
|
3
|
+
|
|
4
|
+
import errno
|
|
5
|
+
import os
|
|
6
|
+
import setuptools.build_meta
|
|
7
|
+
|
|
8
|
+
backend = setuptools.build_meta.__legacy__
|
|
9
|
+
|
|
10
|
+
dist_dir = "dist/"
|
|
11
|
+
build_wheel = True
|
|
12
|
+
build_sdist = True
|
|
13
|
+
wheel_config_settings = {
|
|
14
|
+
}
|
|
15
|
+
sdist_config_settings = {
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Python 2.7 doesn't have the exist_ok arg on os.makedirs().
|
|
19
|
+
try:
|
|
20
|
+
os.makedirs(dist_dir)
|
|
21
|
+
except OSError as e:
|
|
22
|
+
if e.errno != errno.EEXIST:
|
|
23
|
+
raise
|
|
24
|
+
|
|
25
|
+
wheel_path = backend.build_wheel(dist_dir, wheel_config_settings) if build_wheel else None
|
|
26
|
+
sdist_path = backend.build_sdist(dist_dir, sdist_config_settings) if build_sdist else None
|
|
27
|
+
|
|
28
|
+
if wheel_path:
|
|
29
|
+
print("wheel: {wheel_path}".format(wheel_path=wheel_path))
|
|
30
|
+
if sdist_path:
|
|
31
|
+
print("sdist: {sdist_path}".format(sdist_path=sdist_path))
|
|
File without changes
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# Copyright 2026 Tague Griffith
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
import toml # pants: no-infer-dep (provided by the Pants runtime)
|
|
11
|
+
|
|
12
|
+
from pants_pyrefly.rules import (
|
|
13
|
+
_BASELINE_OUTPUT,
|
|
14
|
+
PyreflyFieldSet,
|
|
15
|
+
PyreflyRequest,
|
|
16
|
+
_setup_pyrefly_process,
|
|
17
|
+
pyrefly_determine_partitions,
|
|
18
|
+
)
|
|
19
|
+
from pants_pyrefly.subsystems import Pyrefly
|
|
20
|
+
|
|
21
|
+
from pants.backend.python.subsystems.setup import PythonSetup
|
|
22
|
+
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
|
|
23
|
+
from pants.backend.python.util_rules.python_sources import (
|
|
24
|
+
PythonSourceFilesRequest,
|
|
25
|
+
prepare_python_sources,
|
|
26
|
+
)
|
|
27
|
+
from pants.engine.fs import (
|
|
28
|
+
EMPTY_DIGEST,
|
|
29
|
+
CreateDigest,
|
|
30
|
+
FileContent,
|
|
31
|
+
GlobMatchErrorBehavior,
|
|
32
|
+
PathGlobs,
|
|
33
|
+
Workspace,
|
|
34
|
+
)
|
|
35
|
+
from pants.engine.console import Console
|
|
36
|
+
from pants.engine.goal import Goal, GoalSubsystem
|
|
37
|
+
from pants.engine.intrinsics import (
|
|
38
|
+
create_digest,
|
|
39
|
+
execute_process,
|
|
40
|
+
get_digest_contents,
|
|
41
|
+
path_globs_to_digest,
|
|
42
|
+
)
|
|
43
|
+
from pants.engine.platform import Platform
|
|
44
|
+
from pants.engine.process import ProcessCacheScope
|
|
45
|
+
from pants.engine.rules import Rule, collect_rules, concurrently, goal_rule, implicitly
|
|
46
|
+
from pants.engine.target import AllTargets, Targets
|
|
47
|
+
from pants.engine.unions import UnionRule
|
|
48
|
+
from pants.option.option_types import FloatOption
|
|
49
|
+
from pants.util.strutil import softwrap
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---
|
|
55
|
+
# `pyrefly-update-baseline`
|
|
56
|
+
# ---
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PyreflyUpdateBaselineSubsystem(GoalSubsystem):
|
|
60
|
+
name = "pyrefly-update-baseline"
|
|
61
|
+
help = softwrap(
|
|
62
|
+
"""
|
|
63
|
+
Run Pyrefly and (re)write the baseline file configured by `[pyrefly].baseline`, recording
|
|
64
|
+
the current type errors so that a subsequent `pants check` reports only NEW ones.
|
|
65
|
+
"""
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PyreflyUpdateBaseline(Goal):
|
|
70
|
+
subsystem_cls = PyreflyUpdateBaselineSubsystem
|
|
71
|
+
environment_behavior = Goal.EnvironmentBehavior.LOCAL_ONLY
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@goal_rule
|
|
75
|
+
async def pyrefly_update_baseline(
|
|
76
|
+
targets: Targets,
|
|
77
|
+
pyrefly: Pyrefly,
|
|
78
|
+
workspace: Workspace,
|
|
79
|
+
platform: Platform,
|
|
80
|
+
python_setup: PythonSetup,
|
|
81
|
+
) -> PyreflyUpdateBaseline:
|
|
82
|
+
if not pyrefly.baseline:
|
|
83
|
+
logger.error(
|
|
84
|
+
softwrap(
|
|
85
|
+
"""
|
|
86
|
+
Set `[pyrefly].baseline` to the path where the baseline file should be written,
|
|
87
|
+
then re-run `pants pyrefly-update-baseline`.
|
|
88
|
+
"""
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return PyreflyUpdateBaseline(exit_code=1)
|
|
92
|
+
|
|
93
|
+
field_sets = tuple(
|
|
94
|
+
PyreflyFieldSet.create(tgt)
|
|
95
|
+
for tgt in targets
|
|
96
|
+
if PyreflyFieldSet.is_applicable(tgt) and not PyreflyFieldSet.opt_out(tgt)
|
|
97
|
+
)
|
|
98
|
+
if not field_sets:
|
|
99
|
+
logger.warning("No Pyrefly-applicable targets in scope; the baseline was not changed.")
|
|
100
|
+
return PyreflyUpdateBaseline(exit_code=0)
|
|
101
|
+
|
|
102
|
+
partitions = await pyrefly_determine_partitions(PyreflyRequest(field_sets), **implicitly())
|
|
103
|
+
processes = await concurrently(
|
|
104
|
+
_setup_pyrefly_process(
|
|
105
|
+
partition,
|
|
106
|
+
pyrefly,
|
|
107
|
+
platform,
|
|
108
|
+
python_setup,
|
|
109
|
+
update_baseline=True,
|
|
110
|
+
cache_scope=ProcessCacheScope.PER_SESSION,
|
|
111
|
+
)
|
|
112
|
+
for partition in partitions
|
|
113
|
+
)
|
|
114
|
+
results = await concurrently(execute_process(process, **implicitly()) for process in processes)
|
|
115
|
+
|
|
116
|
+
for result in results:
|
|
117
|
+
# 0 == no errors, 1 == errors found (both expected); anything else is a tool failure, and
|
|
118
|
+
# we must not clobber a good baseline with a partial/empty one.
|
|
119
|
+
if result.exit_code not in (0, 1):
|
|
120
|
+
logger.error(
|
|
121
|
+
f"Pyrefly exited with code {result.exit_code} while updating the baseline; "
|
|
122
|
+
f"leaving the existing baseline unchanged.\n"
|
|
123
|
+
f"{result.stderr.decode(errors='replace')}"
|
|
124
|
+
)
|
|
125
|
+
return PyreflyUpdateBaseline(exit_code=result.exit_code)
|
|
126
|
+
|
|
127
|
+
# Merge the per-partition baselines (each is `{"errors": [...]}`) into a single file.
|
|
128
|
+
all_errors: list = []
|
|
129
|
+
for result in results:
|
|
130
|
+
for file_content in await get_digest_contents(result.output_digest):
|
|
131
|
+
if file_content.path == _BASELINE_OUTPUT and file_content.content.strip():
|
|
132
|
+
all_errors.extend(json.loads(file_content.content).get("errors", []))
|
|
133
|
+
|
|
134
|
+
merged = json.dumps({"errors": all_errors}, indent=2).encode()
|
|
135
|
+
output_digest = await create_digest(CreateDigest([FileContent(pyrefly.baseline, merged)]))
|
|
136
|
+
workspace.write_digest(output_digest)
|
|
137
|
+
logger.info(f"Wrote Pyrefly baseline with {len(all_errors)} error(s) to `{pyrefly.baseline}`.")
|
|
138
|
+
return PyreflyUpdateBaseline(exit_code=0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---
|
|
142
|
+
# `pyrefly-lsp-config`
|
|
143
|
+
# ---
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PyreflyLspConfigSubsystem(GoalSubsystem):
|
|
147
|
+
name = "pyrefly-lsp-config"
|
|
148
|
+
help = softwrap(
|
|
149
|
+
"""
|
|
150
|
+
Write Pants's source roots (and target Python version) into `pyrefly.toml` as `search-path`,
|
|
151
|
+
so the Pyrefly IDE/LSP resolves first-party imports the way Pants does. Point your editor's
|
|
152
|
+
interpreter at a venv (e.g. `pants export`) for third-party imports.
|
|
153
|
+
"""
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PyreflyLspConfig(Goal):
|
|
158
|
+
subsystem_cls = PyreflyLspConfigSubsystem
|
|
159
|
+
environment_behavior = Goal.EnvironmentBehavior.LOCAL_ONLY
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@goal_rule
|
|
163
|
+
async def pyrefly_lsp_config(
|
|
164
|
+
all_targets: AllTargets,
|
|
165
|
+
python_setup: PythonSetup,
|
|
166
|
+
workspace: Workspace,
|
|
167
|
+
) -> PyreflyLspConfig:
|
|
168
|
+
python_targets = [tgt for tgt in all_targets if PyreflyFieldSet.is_applicable(tgt)]
|
|
169
|
+
if not python_targets:
|
|
170
|
+
logger.warning("No Python source targets found; not writing a Pyrefly LSP config.")
|
|
171
|
+
return PyreflyLspConfig(exit_code=0)
|
|
172
|
+
|
|
173
|
+
sources = await prepare_python_sources(PythonSourceFilesRequest(python_targets), **implicitly())
|
|
174
|
+
python_version = InterpreterConstraints(
|
|
175
|
+
python_setup.interpreter_constraints
|
|
176
|
+
).minimum_python_version(python_setup.interpreter_versions_universe)
|
|
177
|
+
|
|
178
|
+
settings: dict = {"search-path": sorted(sources.source_roots)}
|
|
179
|
+
if python_version:
|
|
180
|
+
settings["python-version"] = python_version
|
|
181
|
+
|
|
182
|
+
pyrefly_toml_digest, pyproject_digest = await concurrently(
|
|
183
|
+
path_globs_to_digest(
|
|
184
|
+
PathGlobs(["pyrefly.toml"], glob_match_error_behavior=GlobMatchErrorBehavior.ignore)
|
|
185
|
+
),
|
|
186
|
+
path_globs_to_digest(
|
|
187
|
+
PathGlobs(["pyproject.toml"], glob_match_error_behavior=GlobMatchErrorBehavior.ignore)
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
pyproject_has_pyrefly = False
|
|
192
|
+
if pyproject_digest != EMPTY_DIGEST:
|
|
193
|
+
pyproject_text = (await get_digest_contents(pyproject_digest))[0].content.decode()
|
|
194
|
+
pyproject_has_pyrefly = "pyrefly" in toml.loads(pyproject_text).get("tool", {})
|
|
195
|
+
|
|
196
|
+
# Don't create a `pyrefly.toml` that would shadow an existing `pyproject.toml [tool.pyrefly]`.
|
|
197
|
+
if pyrefly_toml_digest == EMPTY_DIGEST and pyproject_has_pyrefly:
|
|
198
|
+
logger.warning(
|
|
199
|
+
softwrap(
|
|
200
|
+
"""
|
|
201
|
+
Your Pyrefly config lives in `pyproject.toml` under `[tool.pyrefly]`. A separate
|
|
202
|
+
`pyrefly.toml` would take precedence over it, so it was not written. Add these keys
|
|
203
|
+
to your `[tool.pyrefly]` table instead:
|
|
204
|
+
"""
|
|
205
|
+
)
|
|
206
|
+
+ f"\n\n{toml.dumps(settings).strip()}\n"
|
|
207
|
+
)
|
|
208
|
+
return PyreflyLspConfig(exit_code=0)
|
|
209
|
+
|
|
210
|
+
# Merge into an existing `pyrefly.toml` (preserving other keys) or create a new one.
|
|
211
|
+
existing: dict = {}
|
|
212
|
+
if pyrefly_toml_digest != EMPTY_DIGEST:
|
|
213
|
+
existing = toml.loads((await get_digest_contents(pyrefly_toml_digest))[0].content.decode())
|
|
214
|
+
existing.update(settings)
|
|
215
|
+
|
|
216
|
+
output_digest = await create_digest(
|
|
217
|
+
CreateDigest([FileContent("pyrefly.toml", toml.dumps(existing).encode())])
|
|
218
|
+
)
|
|
219
|
+
workspace.write_digest(output_digest)
|
|
220
|
+
logger.info(
|
|
221
|
+
f"Wrote {len(settings['search-path'])} source root(s) to `pyrefly.toml` as Pyrefly "
|
|
222
|
+
f"`search-path`. Point your editor's interpreter at a venv (e.g. `pants export`) for "
|
|
223
|
+
f"third-party imports."
|
|
224
|
+
)
|
|
225
|
+
return PyreflyLspConfig(exit_code=0)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---
|
|
229
|
+
# `pyrefly-coverage`
|
|
230
|
+
# ---
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class PyreflyCoverageSubsystem(GoalSubsystem):
|
|
234
|
+
name = "pyrefly-coverage"
|
|
235
|
+
help = softwrap(
|
|
236
|
+
"""
|
|
237
|
+
Report Pyrefly type coverage — the share of typable symbols that have a non-`Any` type —
|
|
238
|
+
across the targeted Python sources.
|
|
239
|
+
"""
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
fail_under = FloatOption(
|
|
243
|
+
default=None,
|
|
244
|
+
help="If set, exit non-zero when overall type coverage is below this percentage (0-100).",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class PyreflyCoverage(Goal):
|
|
249
|
+
subsystem_cls = PyreflyCoverageSubsystem
|
|
250
|
+
environment_behavior = Goal.EnvironmentBehavior.LOCAL_ONLY
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@goal_rule
|
|
254
|
+
async def pyrefly_coverage(
|
|
255
|
+
targets: Targets,
|
|
256
|
+
pyrefly: Pyrefly,
|
|
257
|
+
coverage_subsystem: PyreflyCoverageSubsystem,
|
|
258
|
+
console: Console,
|
|
259
|
+
platform: Platform,
|
|
260
|
+
python_setup: PythonSetup,
|
|
261
|
+
) -> PyreflyCoverage:
|
|
262
|
+
field_sets = tuple(
|
|
263
|
+
PyreflyFieldSet.create(tgt)
|
|
264
|
+
for tgt in targets
|
|
265
|
+
if PyreflyFieldSet.is_applicable(tgt) and not PyreflyFieldSet.opt_out(tgt)
|
|
266
|
+
)
|
|
267
|
+
if not field_sets:
|
|
268
|
+
logger.warning("No Pyrefly-applicable targets in scope.")
|
|
269
|
+
return PyreflyCoverage(exit_code=0)
|
|
270
|
+
|
|
271
|
+
partitions = await pyrefly_determine_partitions(PyreflyRequest(field_sets), **implicitly())
|
|
272
|
+
processes = await concurrently(
|
|
273
|
+
_setup_pyrefly_process(
|
|
274
|
+
partition,
|
|
275
|
+
pyrefly,
|
|
276
|
+
platform,
|
|
277
|
+
python_setup,
|
|
278
|
+
subcommand=("coverage", "report"),
|
|
279
|
+
cache_scope=ProcessCacheScope.SUCCESSFUL,
|
|
280
|
+
)
|
|
281
|
+
for partition in partitions
|
|
282
|
+
)
|
|
283
|
+
results = await concurrently(execute_process(process, **implicitly()) for process in processes)
|
|
284
|
+
|
|
285
|
+
total_typable = 0
|
|
286
|
+
total_typed = 0
|
|
287
|
+
for result in results:
|
|
288
|
+
if result.exit_code != 0:
|
|
289
|
+
logger.error(
|
|
290
|
+
f"Pyrefly coverage failed (exit {result.exit_code}):\n"
|
|
291
|
+
f"{result.stderr.decode(errors='replace')}"
|
|
292
|
+
)
|
|
293
|
+
return PyreflyCoverage(exit_code=result.exit_code)
|
|
294
|
+
try:
|
|
295
|
+
report = json.loads(result.stdout)
|
|
296
|
+
except json.JSONDecodeError:
|
|
297
|
+
logger.error(
|
|
298
|
+
"Could not parse Pyrefly coverage output as JSON:\n"
|
|
299
|
+
f"{result.stdout.decode(errors='replace')[:500]}"
|
|
300
|
+
)
|
|
301
|
+
return PyreflyCoverage(exit_code=1)
|
|
302
|
+
for module_report in report.get("module_reports", []):
|
|
303
|
+
for symbol in module_report.get("symbol_reports", []):
|
|
304
|
+
total_typable += symbol.get("n_typable", 0)
|
|
305
|
+
total_typed += symbol.get("n_typed", 0)
|
|
306
|
+
|
|
307
|
+
pct = (100.0 * total_typed / total_typable) if total_typable else 100.0
|
|
308
|
+
console.print_stdout(
|
|
309
|
+
f"Pyrefly type coverage: {pct:.1f}% ({total_typed}/{total_typable} typable symbols)"
|
|
310
|
+
)
|
|
311
|
+
if coverage_subsystem.fail_under is not None and pct < coverage_subsystem.fail_under:
|
|
312
|
+
console.print_stderr(
|
|
313
|
+
f"Type coverage {pct:.1f}% is below the required {coverage_subsystem.fail_under}%."
|
|
314
|
+
)
|
|
315
|
+
return PyreflyCoverage(exit_code=1)
|
|
316
|
+
return PyreflyCoverage(exit_code=0)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def rules() -> Iterable[Rule | UnionRule]:
|
|
320
|
+
return (*collect_rules(),)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Copyright 2026 Tague Griffith
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
3
|
+
|
|
4
|
+
"""A Pants plugin that runs the Pyrefly type checker (https://pyrefly.org) in the `check` goal."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
from pants_pyrefly import goals
|
|
11
|
+
from pants_pyrefly import rules as pyrefly_rules
|
|
12
|
+
from pants_pyrefly import skip_field, subsystems
|
|
13
|
+
from pants.engine.rules import Rule
|
|
14
|
+
from pants.engine.unions import UnionRule
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def rules() -> Iterable[Rule | UnionRule]:
|
|
18
|
+
return (
|
|
19
|
+
*pyrefly_rules.rules(),
|
|
20
|
+
*goals.rules(),
|
|
21
|
+
*skip_field.rules(),
|
|
22
|
+
*subsystems.rules(),
|
|
23
|
+
)
|