pants-pyrefly 0.1.0__py3-none-any.whl
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/__init__.py +0 -0
- pants_pyrefly/goals.py +320 -0
- pants_pyrefly/register.py +23 -0
- pants_pyrefly/rules.py +368 -0
- pants_pyrefly/skip_field.py +33 -0
- pants_pyrefly/subsystems.py +174 -0
- pants_pyrefly-0.1.0.dist-info/METADATA +181 -0
- pants_pyrefly-0.1.0.dist-info/RECORD +12 -0
- pants_pyrefly-0.1.0.dist-info/WHEEL +5 -0
- pants_pyrefly-0.1.0.dist-info/entry_points.txt +2 -0
- pants_pyrefly-0.1.0.dist-info/namespace_packages.txt +1 -0
- pants_pyrefly-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
pants_pyrefly/goals.py
ADDED
|
@@ -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
|
+
)
|
pants_pyrefly/rules.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
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 logging
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from pants_pyrefly.skip_field import SkipPyreflyField
|
|
12
|
+
from pants_pyrefly.subsystems import Pyrefly
|
|
13
|
+
|
|
14
|
+
from pants.backend.python.subsystems.setup import PythonSetup
|
|
15
|
+
from pants.backend.python.target_types import (
|
|
16
|
+
InterpreterConstraintsField,
|
|
17
|
+
PythonResolveField,
|
|
18
|
+
PythonSourceField,
|
|
19
|
+
)
|
|
20
|
+
from pants.backend.python.util_rules import pex_from_targets
|
|
21
|
+
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
|
|
22
|
+
from pants.backend.python.util_rules.partition import (
|
|
23
|
+
_partition_by_interpreter_constraints_and_resolve,
|
|
24
|
+
)
|
|
25
|
+
from pants.backend.python.util_rules.pex import Pex, PexRequest, create_pex, create_venv_pex
|
|
26
|
+
from pants.backend.python.util_rules.pex_from_targets import RequirementsPexRequest
|
|
27
|
+
from pants.backend.python.util_rules.pex_requirements import PexRequirements
|
|
28
|
+
from pants.backend.python.util_rules.python_sources import (
|
|
29
|
+
PythonSourceFilesRequest,
|
|
30
|
+
prepare_python_sources,
|
|
31
|
+
)
|
|
32
|
+
from pants.core.goals.check import CheckRequest, CheckResult, CheckResults, CheckSubsystem
|
|
33
|
+
from pants.core.util_rules import config_files
|
|
34
|
+
from pants.core.util_rules.config_files import find_config_file
|
|
35
|
+
from pants.core.util_rules.external_tool import download_external_tool
|
|
36
|
+
from pants.core.util_rules.source_files import SourceFilesRequest, determine_source_files
|
|
37
|
+
from pants.engine.collection import Collection
|
|
38
|
+
from pants.engine.fs import (
|
|
39
|
+
EMPTY_DIGEST,
|
|
40
|
+
CreateDigest,
|
|
41
|
+
FileContent,
|
|
42
|
+
GlobMatchErrorBehavior,
|
|
43
|
+
MergeDigests,
|
|
44
|
+
PathGlobs,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Pants >= 2.30 renamed this call-by-name rule.
|
|
49
|
+
from pants.engine.internals.graph import resolve_coarsened_targets as coarsened_targets_get
|
|
50
|
+
except ImportError:
|
|
51
|
+
# Pants < 2.30 (e.g. 2.27) — identical call signature, earlier name.
|
|
52
|
+
from pants.engine.internals.graph import coarsened_targets as coarsened_targets_get
|
|
53
|
+
from pants.engine.intrinsics import (
|
|
54
|
+
create_digest,
|
|
55
|
+
execute_process,
|
|
56
|
+
merge_digests,
|
|
57
|
+
path_globs_to_digest,
|
|
58
|
+
)
|
|
59
|
+
from pants.engine.platform import Platform
|
|
60
|
+
from pants.engine.process import Process, ProcessCacheScope
|
|
61
|
+
from pants.engine.rules import Rule, collect_rules, concurrently, implicitly, rule
|
|
62
|
+
from pants.engine.target import CoarsenedTargets, CoarsenedTargetsRequest, FieldSet, Target
|
|
63
|
+
from pants.engine.unions import UnionRule
|
|
64
|
+
from pants.util.logging import LogLevel
|
|
65
|
+
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
|
|
66
|
+
from pants.util.strutil import pluralize, softwrap
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class PyreflyFieldSet(FieldSet):
|
|
73
|
+
required_fields = (PythonSourceField,)
|
|
74
|
+
|
|
75
|
+
sources: PythonSourceField
|
|
76
|
+
resolve: PythonResolveField
|
|
77
|
+
interpreter_constraints: InterpreterConstraintsField
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def opt_out(cls, tgt: Target) -> bool:
|
|
81
|
+
return tgt.get(SkipPyreflyField).value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PyreflyRequest(CheckRequest):
|
|
85
|
+
field_set_type = PyreflyFieldSet
|
|
86
|
+
tool_name = Pyrefly.options_scope
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class PyreflyPartition:
|
|
91
|
+
field_sets: FrozenOrderedSet[PyreflyFieldSet]
|
|
92
|
+
root_targets: CoarsenedTargets
|
|
93
|
+
resolve_description: str | None
|
|
94
|
+
interpreter_constraints: InterpreterConstraints
|
|
95
|
+
|
|
96
|
+
def description(self) -> str:
|
|
97
|
+
ics = str(sorted(str(c) for c in self.interpreter_constraints))
|
|
98
|
+
return f"{self.resolve_description}, {ics}" if self.resolve_description else ics
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PyreflyPartitions(Collection[PyreflyPartition]):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@rule(
|
|
106
|
+
desc="Partition Pyrefly's input by resolve and interpreter constraints",
|
|
107
|
+
level=LogLevel.DEBUG,
|
|
108
|
+
)
|
|
109
|
+
async def pyrefly_determine_partitions(
|
|
110
|
+
request: PyreflyRequest, pyrefly: Pyrefly, python_setup: PythonSetup
|
|
111
|
+
) -> PyreflyPartitions:
|
|
112
|
+
resolve_and_interpreter_constraints_to_field_sets = (
|
|
113
|
+
_partition_by_interpreter_constraints_and_resolve(request.field_sets, python_setup)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
coarsened_targets = await coarsened_targets_get(
|
|
117
|
+
CoarsenedTargetsRequest(field_set.address for field_set in request.field_sets),
|
|
118
|
+
**implicitly(),
|
|
119
|
+
)
|
|
120
|
+
coarsened_targets_by_address = coarsened_targets.by_address()
|
|
121
|
+
|
|
122
|
+
return PyreflyPartitions(
|
|
123
|
+
PyreflyPartition(
|
|
124
|
+
FrozenOrderedSet(field_sets),
|
|
125
|
+
CoarsenedTargets(
|
|
126
|
+
OrderedSet(
|
|
127
|
+
coarsened_targets_by_address[field_set.address] for field_set in field_sets
|
|
128
|
+
)
|
|
129
|
+
),
|
|
130
|
+
resolve if len(python_setup.resolves) > 1 else None,
|
|
131
|
+
interpreter_constraints or pyrefly.interpreter_constraints,
|
|
132
|
+
)
|
|
133
|
+
for (resolve, interpreter_constraints), field_sets in sorted(
|
|
134
|
+
resolve_and_interpreter_constraints_to_field_sets.items()
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Fixed sandbox path where `--update-baseline` writes the baseline; the update-baseline goal
|
|
140
|
+
# relocates it to the user's configured `[pyrefly].baseline` path on write-back.
|
|
141
|
+
_BASELINE_OUTPUT = "__pyrefly_baseline_out.json"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _setup_pyrefly_process(
|
|
145
|
+
partition: PyreflyPartition,
|
|
146
|
+
pyrefly: Pyrefly,
|
|
147
|
+
platform: Platform,
|
|
148
|
+
python_setup: PythonSetup,
|
|
149
|
+
*,
|
|
150
|
+
subcommand: tuple[str, ...] = ("check",),
|
|
151
|
+
update_baseline: bool = False,
|
|
152
|
+
cache_scope: ProcessCacheScope,
|
|
153
|
+
) -> Process:
|
|
154
|
+
# Gather, concurrently:
|
|
155
|
+
# - the Pyrefly binary itself,
|
|
156
|
+
# - the root source files we are reporting on,
|
|
157
|
+
# - the full first-party dependency closure on disk (+ its source roots),
|
|
158
|
+
# - a PEX of the third-party requirements, and
|
|
159
|
+
# - any discovered Pyrefly config file.
|
|
160
|
+
(
|
|
161
|
+
downloaded_pyrefly,
|
|
162
|
+
root_sources,
|
|
163
|
+
transitive_sources,
|
|
164
|
+
requirements_pex,
|
|
165
|
+
config_file_snapshot,
|
|
166
|
+
) = await concurrently(
|
|
167
|
+
download_external_tool(pyrefly.get_request(platform)),
|
|
168
|
+
determine_source_files(SourceFilesRequest(fs.sources for fs in partition.field_sets)),
|
|
169
|
+
prepare_python_sources(
|
|
170
|
+
PythonSourceFilesRequest(partition.root_targets.closure()), **implicitly()
|
|
171
|
+
),
|
|
172
|
+
create_pex(
|
|
173
|
+
**implicitly(
|
|
174
|
+
RequirementsPexRequest(
|
|
175
|
+
(fs.address for fs in partition.field_sets),
|
|
176
|
+
hardcoded_interpreter_constraints=partition.interpreter_constraints,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
),
|
|
180
|
+
find_config_file(pyrefly.config_request()),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Optionally resolve extra type-stub packages and merge them into the same venv, so Pyrefly
|
|
184
|
+
# sees their types without them becoming runtime dependencies of the checked code.
|
|
185
|
+
extra_stub_pexes: list[Pex] = []
|
|
186
|
+
if pyrefly.extra_type_stubs:
|
|
187
|
+
extra_stubs_pex = await create_pex(
|
|
188
|
+
**implicitly(
|
|
189
|
+
PexRequest(
|
|
190
|
+
output_filename="pyrefly_extra_type_stubs.pex",
|
|
191
|
+
internal_only=True,
|
|
192
|
+
requirements=PexRequirements(
|
|
193
|
+
pyrefly.extra_type_stubs,
|
|
194
|
+
description_of_origin="the option `[pyrefly].extra_type_stubs`",
|
|
195
|
+
),
|
|
196
|
+
interpreter_constraints=partition.interpreter_constraints,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
extra_stub_pexes = [extra_stubs_pex]
|
|
201
|
+
|
|
202
|
+
# Wrap the third-party requirements (plus any extra type stubs) in a venv PEX. We point
|
|
203
|
+
# Pyrefly's `--python-interpreter-path` at this venv's Python so it can discover the third-party
|
|
204
|
+
# `site-packages` (and the target Python version) exactly the way `import` would at runtime.
|
|
205
|
+
requirements_venv_pex = await create_venv_pex(
|
|
206
|
+
**implicitly(
|
|
207
|
+
PexRequest(
|
|
208
|
+
output_filename="requirements_venv.pex",
|
|
209
|
+
internal_only=True,
|
|
210
|
+
pex_path=[requirements_pex, *extra_stub_pexes],
|
|
211
|
+
interpreter_constraints=partition.interpreter_constraints,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Baseline handling (check only). In `update` mode we (re)write a baseline to a fixed sandbox
|
|
217
|
+
# path and capture it; otherwise we materialize the user's baseline (if set) and gate on it.
|
|
218
|
+
is_check = subcommand == ("check",)
|
|
219
|
+
baseline_args: list[str] = []
|
|
220
|
+
baseline_digest = EMPTY_DIGEST
|
|
221
|
+
output_files: tuple[str, ...] = ()
|
|
222
|
+
if update_baseline:
|
|
223
|
+
baseline_args = [f"--baseline={_BASELINE_OUTPUT}", "--update-baseline"]
|
|
224
|
+
output_files = (_BASELINE_OUTPUT,)
|
|
225
|
+
elif is_check and pyrefly.baseline:
|
|
226
|
+
baseline_digest = await path_globs_to_digest(
|
|
227
|
+
PathGlobs(
|
|
228
|
+
[pyrefly.baseline],
|
|
229
|
+
glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
if baseline_digest != EMPTY_DIGEST:
|
|
233
|
+
baseline_args = [f"--baseline={pyrefly.baseline}"]
|
|
234
|
+
else:
|
|
235
|
+
logger.warning(
|
|
236
|
+
softwrap(
|
|
237
|
+
f"""
|
|
238
|
+
`[pyrefly].baseline` is set to `{pyrefly.baseline}`, but that file does not
|
|
239
|
+
exist. Run `pants pyrefly-update-baseline` to create it; checking without a
|
|
240
|
+
baseline for now.
|
|
241
|
+
"""
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Pass the files to check via an argfile rather than argv, so we never hit OS command-line
|
|
246
|
+
# length limits on targets with many files (Pyrefly reads `@<file>`, like other clap CLIs).
|
|
247
|
+
file_list_path = "__pyrefly_files.txt"
|
|
248
|
+
file_list_digest = await create_digest(
|
|
249
|
+
CreateDigest([FileContent(file_list_path, "\n".join(root_sources.snapshot.files).encode())])
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
input_digest = await merge_digests(
|
|
253
|
+
MergeDigests(
|
|
254
|
+
(
|
|
255
|
+
root_sources.snapshot.digest,
|
|
256
|
+
transitive_sources.source_files.snapshot.digest,
|
|
257
|
+
config_file_snapshot.snapshot.digest,
|
|
258
|
+
requirements_venv_pex.digest,
|
|
259
|
+
file_list_digest,
|
|
260
|
+
baseline_digest,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
tool_key = "__pyrefly_tool"
|
|
266
|
+
exe_path = os.path.normpath(os.path.join(tool_key, downloaded_pyrefly.exe))
|
|
267
|
+
|
|
268
|
+
python_version = partition.interpreter_constraints.minimum_python_version(
|
|
269
|
+
python_setup.interpreter_versions_universe
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
argv: list[str] = [exe_path, *subcommand]
|
|
273
|
+
# First-party import roots (the analogue of MYPYPATH / sys.path).
|
|
274
|
+
argv.extend(f"--search-path={source_root}" for source_root in transitive_sources.source_roots)
|
|
275
|
+
# Third-party deps + interpreter introspection.
|
|
276
|
+
argv.append(f"--python-interpreter-path={requirements_venv_pex.python.argv0}")
|
|
277
|
+
if python_version:
|
|
278
|
+
argv.append(f"--python-version={python_version}")
|
|
279
|
+
# An explicitly-configured config file. Discovered configs are found by Pyrefly itself
|
|
280
|
+
# relative to the sandbox cwd; both are materialized into the input digest above.
|
|
281
|
+
if pyrefly.config:
|
|
282
|
+
argv.append(f"--config={pyrefly.config}")
|
|
283
|
+
if is_check:
|
|
284
|
+
# `check`-only flags; `coverage report`/`check` do not accept these.
|
|
285
|
+
if pyrefly.output_format:
|
|
286
|
+
argv.append(f"--output-format={pyrefly.output_format}")
|
|
287
|
+
if pyrefly.min_severity:
|
|
288
|
+
argv.append(f"--min-severity={pyrefly.min_severity}")
|
|
289
|
+
argv.extend(f"--only={error_kind}" for error_kind in pyrefly.only)
|
|
290
|
+
argv.extend(baseline_args)
|
|
291
|
+
# User-provided args (can override any of the above).
|
|
292
|
+
argv.extend(pyrefly.args)
|
|
293
|
+
# The files to report on, passed via the argfile created above.
|
|
294
|
+
argv.append(f"@{file_list_path}")
|
|
295
|
+
|
|
296
|
+
return Process(
|
|
297
|
+
argv=tuple(argv),
|
|
298
|
+
input_digest=input_digest,
|
|
299
|
+
immutable_input_digests={tool_key: downloaded_pyrefly.digest},
|
|
300
|
+
append_only_caches=requirements_venv_pex.append_only_caches or {},
|
|
301
|
+
output_files=output_files,
|
|
302
|
+
description=f"Run Pyrefly on {pluralize(len(root_sources.snapshot.files), 'file')}.",
|
|
303
|
+
level=LogLevel.DEBUG,
|
|
304
|
+
cache_scope=cache_scope,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@rule(
|
|
309
|
+
desc="Pyrefly typecheck each partition based on its interpreter_constraints",
|
|
310
|
+
level=LogLevel.DEBUG,
|
|
311
|
+
)
|
|
312
|
+
async def pyrefly_typecheck_partition(
|
|
313
|
+
partition: PyreflyPartition,
|
|
314
|
+
pyrefly: Pyrefly,
|
|
315
|
+
check_subsystem: CheckSubsystem,
|
|
316
|
+
platform: Platform,
|
|
317
|
+
python_setup: PythonSetup,
|
|
318
|
+
) -> CheckResult:
|
|
319
|
+
process = await _setup_pyrefly_process(
|
|
320
|
+
partition,
|
|
321
|
+
pyrefly,
|
|
322
|
+
platform,
|
|
323
|
+
python_setup,
|
|
324
|
+
update_baseline=False,
|
|
325
|
+
# `default_process_cache_scope` (which honors `--force`) exists on Pants >= 2.30;
|
|
326
|
+
# on 2.27 fall back to the normal "cache successful runs" scope.
|
|
327
|
+
cache_scope=getattr(
|
|
328
|
+
check_subsystem, "default_process_cache_scope", ProcessCacheScope.SUCCESSFUL
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
process_result = await execute_process(process, **implicitly())
|
|
332
|
+
# Exit 0 == clean, 1 == type errors found. Anything else (e.g. 3, or a 101 panic) is a Pyrefly
|
|
333
|
+
# tool failure, not type errors — flag it so users don't misread a crash as code problems.
|
|
334
|
+
if process_result.exit_code not in (0, 1):
|
|
335
|
+
logger.warning(
|
|
336
|
+
softwrap(
|
|
337
|
+
f"""
|
|
338
|
+
Pyrefly exited with code {process_result.exit_code} on partition
|
|
339
|
+
({partition.description()}). This usually indicates a Pyrefly tool error rather than
|
|
340
|
+
type errors in your code; see the output above.
|
|
341
|
+
"""
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
return CheckResult.from_fallible_process_result(
|
|
345
|
+
process_result,
|
|
346
|
+
partition_description=partition.description(),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@rule(desc="Typecheck using Pyrefly", level=LogLevel.DEBUG)
|
|
351
|
+
async def pyrefly_typecheck(request: PyreflyRequest, pyrefly: Pyrefly) -> CheckResults:
|
|
352
|
+
if pyrefly.skip:
|
|
353
|
+
return CheckResults([], checker_name=request.tool_name)
|
|
354
|
+
|
|
355
|
+
partitions = await pyrefly_determine_partitions(request, **implicitly())
|
|
356
|
+
partitioned_results = await concurrently(
|
|
357
|
+
pyrefly_typecheck_partition(partition, **implicitly()) for partition in partitions
|
|
358
|
+
)
|
|
359
|
+
return CheckResults(partitioned_results, checker_name=request.tool_name)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def rules() -> Iterable[Rule | UnionRule]:
|
|
363
|
+
return (
|
|
364
|
+
*collect_rules(),
|
|
365
|
+
*config_files.rules(),
|
|
366
|
+
*pex_from_targets.rules(),
|
|
367
|
+
UnionRule(CheckRequest, PyreflyRequest),
|
|
368
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2026 Tague Griffith
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from pants.backend.python.target_types import (
|
|
9
|
+
PythonSourcesGeneratorTarget,
|
|
10
|
+
PythonSourceTarget,
|
|
11
|
+
PythonTestsGeneratorTarget,
|
|
12
|
+
PythonTestTarget,
|
|
13
|
+
PythonTestUtilsGeneratorTarget,
|
|
14
|
+
)
|
|
15
|
+
from pants.engine.rules import Rule
|
|
16
|
+
from pants.engine.target import BoolField
|
|
17
|
+
from pants.engine.unions import UnionRule
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SkipPyreflyField(BoolField):
|
|
21
|
+
alias = "skip_pyrefly"
|
|
22
|
+
default = False
|
|
23
|
+
help = "If true, don't run Pyrefly on this target's code."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rules() -> Iterable[Rule | UnionRule]:
|
|
27
|
+
return (
|
|
28
|
+
PythonSourcesGeneratorTarget.register_plugin_field(SkipPyreflyField),
|
|
29
|
+
PythonSourceTarget.register_plugin_field(SkipPyreflyField),
|
|
30
|
+
PythonTestsGeneratorTarget.register_plugin_field(SkipPyreflyField),
|
|
31
|
+
PythonTestTarget.register_plugin_field(SkipPyreflyField),
|
|
32
|
+
PythonTestUtilsGeneratorTarget.register_plugin_field(SkipPyreflyField),
|
|
33
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Copyright 2026 Tague Griffith
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
|
|
9
|
+
from pants.core.goals.resolves import ExportableTool
|
|
10
|
+
from pants.core.util_rules.config_files import ConfigFilesRequest
|
|
11
|
+
from pants.core.util_rules.external_tool import TemplatedExternalTool
|
|
12
|
+
from pants.engine.platform import Platform
|
|
13
|
+
from pants.engine.rules import Rule, collect_rules
|
|
14
|
+
from pants.engine.unions import UnionRule
|
|
15
|
+
from pants.option.option_types import (
|
|
16
|
+
ArgsListOption,
|
|
17
|
+
BoolOption,
|
|
18
|
+
FileOption,
|
|
19
|
+
SkipOption,
|
|
20
|
+
StrListOption,
|
|
21
|
+
StrOption,
|
|
22
|
+
)
|
|
23
|
+
from pants.util.strutil import help_text
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Pyrefly(TemplatedExternalTool):
|
|
27
|
+
options_scope = "pyrefly"
|
|
28
|
+
name = "Pyrefly"
|
|
29
|
+
help = help_text(
|
|
30
|
+
"""
|
|
31
|
+
Pyrefly, a fast Python type checker written in Rust (https://pyrefly.org).
|
|
32
|
+
|
|
33
|
+
Pants downloads the official prebuilt Pyrefly binary from the project's GitHub
|
|
34
|
+
releases and runs it as part of the `check` goal.
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
default_version = "1.1.1"
|
|
39
|
+
default_url_template = (
|
|
40
|
+
"https://github.com/facebook/pyrefly/releases/download/{version}/pyrefly-{platform}.tar.gz"
|
|
41
|
+
)
|
|
42
|
+
# Linux uses the statically-linked musl builds so the binary runs on any distro
|
|
43
|
+
# regardless of the host glibc version.
|
|
44
|
+
default_url_platform_mapping = {
|
|
45
|
+
"macos_arm64": "macos-arm64",
|
|
46
|
+
"macos_x86_64": "macos-x86_64",
|
|
47
|
+
"linux_arm64": "linux-arm64-musl",
|
|
48
|
+
"linux_x86_64": "linux-x86_64-musl",
|
|
49
|
+
}
|
|
50
|
+
default_known_versions = [
|
|
51
|
+
"1.1.1|macos_arm64|022a989d2af4748e4d75a48fed7dbb0cc49f30a4b83745d4e4f742d0920ada70|12621775",
|
|
52
|
+
"1.1.1|macos_x86_64|191c7ee2891d2ab55a05b078c94832266e1dda78a9a0381a95fde13a2a27a38b|13278762",
|
|
53
|
+
"1.1.1|linux_arm64|f55454ac41ed1c086af1bd3cfbe2c2a25b960e46551df50ce047bc1ccb11fb35|13028969",
|
|
54
|
+
"1.1.1|linux_x86_64|fc591b4b283ceddb81116a8dd5c0e70d4f1a7dd291521c4debe0cd588c7fd74c|13660591",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
skip = SkipOption("check")
|
|
58
|
+
args = ArgsListOption(example="--python-version 3.12")
|
|
59
|
+
|
|
60
|
+
output_format = StrOption(
|
|
61
|
+
default=None,
|
|
62
|
+
help=help_text(
|
|
63
|
+
"""
|
|
64
|
+
Override Pyrefly's error output format: one of `min-text`, `full-text`, `json`,
|
|
65
|
+
`github` (GitHub Actions annotations), `junit-xml`, or `omit-errors`. Defaults to
|
|
66
|
+
Pyrefly's own default.
|
|
67
|
+
"""
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
min_severity = StrOption(
|
|
72
|
+
default=None,
|
|
73
|
+
help=help_text(
|
|
74
|
+
"""
|
|
75
|
+
Only display errors at or above this severity: one of `ignore`, `info`, `warn`, or
|
|
76
|
+
`error`.
|
|
77
|
+
"""
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
only = StrListOption(
|
|
82
|
+
default=[],
|
|
83
|
+
help=help_text(
|
|
84
|
+
"""
|
|
85
|
+
Only report these Pyrefly error kinds (e.g. `bad-assignment`, `missing-attribute`),
|
|
86
|
+
filtering out all others. Useful for triaging one category at a time.
|
|
87
|
+
"""
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
extra_type_stubs = StrListOption(
|
|
92
|
+
advanced=True,
|
|
93
|
+
default=[],
|
|
94
|
+
help=help_text(
|
|
95
|
+
"""
|
|
96
|
+
Extra type-stub requirements to make available to Pyrefly without adding them as
|
|
97
|
+
runtime dependencies of your code, e.g.
|
|
98
|
+
`["types-requests", "sqlalchemy2-stubs==0.0.2a38"]`.
|
|
99
|
+
|
|
100
|
+
They are resolved and merged into the third-party environment Pyrefly inspects. Pin
|
|
101
|
+
versions in the requirement strings for reproducible results, since they are resolved
|
|
102
|
+
directly rather than from a lockfile.
|
|
103
|
+
"""
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
config = FileOption(
|
|
108
|
+
default=None,
|
|
109
|
+
advanced=True,
|
|
110
|
+
help=help_text(
|
|
111
|
+
"""
|
|
112
|
+
Path to a Pyrefly config file (a `pyrefly.toml`, or a `pyproject.toml` with a
|
|
113
|
+
`[tool.pyrefly]` table).
|
|
114
|
+
|
|
115
|
+
Setting this option disables config discovery; use it only when the config lives in a
|
|
116
|
+
non-standard location.
|
|
117
|
+
"""
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
config_discovery = BoolOption(
|
|
121
|
+
default=True,
|
|
122
|
+
advanced=True,
|
|
123
|
+
help=help_text(
|
|
124
|
+
"""
|
|
125
|
+
If true, Pants will include any relevant config files during runs (`pyrefly.toml` and
|
|
126
|
+
`pyproject.toml` files with a `[tool.pyrefly]` table).
|
|
127
|
+
|
|
128
|
+
Use `[pyrefly].config` instead if your config is in a non-standard location.
|
|
129
|
+
"""
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Deliberately a StrOption, not a FileOption: the path need not exist yet (it is created by
|
|
134
|
+
# `pants pyrefly-update-baseline`), and FileOption validates existence at option-parse time.
|
|
135
|
+
baseline = StrOption(
|
|
136
|
+
default=None,
|
|
137
|
+
help=help_text(
|
|
138
|
+
"""
|
|
139
|
+
Path to a Pyrefly baseline JSON file. When set, `pants check` reports only type errors
|
|
140
|
+
introduced *after* the baseline was taken — handy for adopting Pyrefly on code that
|
|
141
|
+
already has errors. Create or refresh it with `pants pyrefly-update-baseline`.
|
|
142
|
+
"""
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
_interpreter_constraints = StrListOption(
|
|
147
|
+
advanced=True,
|
|
148
|
+
default=["CPython>=3.9,<3.15"],
|
|
149
|
+
help="Fallback interpreter constraints to use when a target has none of its own.",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def interpreter_constraints(self) -> InterpreterConstraints:
|
|
154
|
+
return InterpreterConstraints(self._interpreter_constraints)
|
|
155
|
+
|
|
156
|
+
def generate_exe(self, plat: Platform) -> str:
|
|
157
|
+
# Every release archive unpacks to a single `pyrefly` binary at the root.
|
|
158
|
+
return "./pyrefly"
|
|
159
|
+
|
|
160
|
+
def config_request(self) -> ConfigFilesRequest:
|
|
161
|
+
return ConfigFilesRequest(
|
|
162
|
+
specified=self.config,
|
|
163
|
+
specified_option_name=f"[{self.options_scope}].config",
|
|
164
|
+
discovery=self.config_discovery,
|
|
165
|
+
check_existence=["pyrefly.toml"],
|
|
166
|
+
check_content={"pyproject.toml": b"[tool.pyrefly"},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def rules() -> Iterable[Rule | UnionRule]:
|
|
171
|
+
return (
|
|
172
|
+
*collect_rules(),
|
|
173
|
+
UnionRule(ExportableTool, Pyrefly),
|
|
174
|
+
)
|
|
@@ -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,12 @@
|
|
|
1
|
+
pants_pyrefly/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pants_pyrefly/goals.py,sha256=7dsPU6ZO0uaLMVINRqHwJBeJSFWcTiRok1YRDWhWW7c,11275
|
|
3
|
+
pants_pyrefly/register.py,sha256=FQW3z-YcFY9j5L3Eja3fZoRc2JgxOOamK4VMJOrdcXo,657
|
|
4
|
+
pants_pyrefly/rules.py,sha256=Yf0CnEiLpLOwxoirzj0I2rZ5DNs2y3ZrCpm-H1BZ9IQ,14226
|
|
5
|
+
pants_pyrefly/skip_field.py,sha256=vk0vZvJa4HZNqiC41iOKP25ECl1GkBuDe_Z_fenBV88,1068
|
|
6
|
+
pants_pyrefly/subsystems.py,sha256=OqINGovLcV-g2AbFm6vHdxrpcJLR27v6UVeJjqpJ4q0,6110
|
|
7
|
+
pants_pyrefly-0.1.0.dist-info/METADATA,sha256=4E7OdiETFoXzVaeWLCENnc4PXhNduPjmST3MwkE5s80,7379
|
|
8
|
+
pants_pyrefly-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
pants_pyrefly-0.1.0.dist-info/entry_points.txt,sha256=zsmc2XTk2lGkWilIEcw16FCxV3ziKw4-pc7MNbadG6Q,57
|
|
10
|
+
pants_pyrefly-0.1.0.dist-info/namespace_packages.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
+
pants_pyrefly-0.1.0.dist-info/top_level.txt,sha256=3ye01bqySdEJIjGqezAFXmKfg1G4niqb30BKZNC1M2Q,14
|
|
12
|
+
pants_pyrefly-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pants_pyrefly
|