Migrate a file to use stricter Pyrefly type checking with annotations required for all functions, classes, and attributes.
pyrefly.toml.pyrefly, lintrunner, and the project's test runner must be on PATH. If any
are missing, stop and ask whether a conda environment needs activating — don't
install or substitute (per repo CLAUDE.md).Delete any of these from the top of the file (pyrefly honors # mypy: ignore-errors
for mypy compat, so that one must go too):
# pyre-ignore-all-errors
# pyre-ignore-all-errors[16,21,53,56]
# @lint-ignore-every PYRELINT
# mypy: ignore-errors
pyrefly.toml[[sub-config]]
matches = "path/to/directory/**"
[sub-config.errors]
implicit-import = false
implicit-any = true
bad-param-name-override = false
unannotated-return = true
unannotated-parameter = true
IMPORTANT: Setting any error key in [sub-config.errors] overrides only that key
relative to the parent — but enabling unannotated-return / unannotated-parameter /
implicit-any will resurface errors that were previously hidden file-wide. If you see
unrelated errors (e.g., bad-param-name-override) flooding the output, mirror the
parent config's setting for that key in the sub-config to silence them.
pyrefly check <FILENAME>
Goal: resolve all unannotated-return, unannotated-parameter, and implicit-any
errors by adding annotations — see Step 4's ladder. These three target categories are
always resolvable; never suppress them with # pyrefly: ignore. The single
exception is @compatibility(is_backward_compatible=True) (Step 4).
Other categories (bad-argument-type, missing-attribute, …) are real type bugs.
Handle them by where pyrefly reports them:
# pyrefly: ignore[<category>] # TODO.bad-return because an imported function's annotation is wrong):
suppress locally with the same TODO comment. Don't invent a cast() that
papers over the upstream gap.Use # pyrefly: ignore[...] only as a last resort, and only on non-target categories.
Examine call sites when the right type isn't obvious from the function body.
int | None, list[str]) — assume Python >= 3.10.collections.abc over typing for ABCs (Callable, Sequence, Generator, ...).typing when available on the project's minimum
Python version, and from typing_extensions only when you need a newer feature
(e.g., Self and override if supporting < 3.11/3.12, or PEP 696 default= for
TypeVar / ParamSpec). Don't blanket-import from typing_extensions.Callable (never bare Callable). Prefer
Callable[..., object]; reach for Callable[..., Any] only when a caller
genuinely consumes the dynamic return — if the result is just passed through
(or the callable isn't even invoked), object is stricter and equally
correct. (See ParamSpec below for the signature-preserving wrapper case.)TypeVar/ParamSpec (matching the string arg: _T = TypeVar("_T"),
_P = ParamSpec("_P"), _R = TypeVar("_R")), TypeAliases, helper constants,
and sentinels alike. This is the prevailing torch convention for non-public
names (_P outnumbers P ~6:1 in the tree). Exceptions (leave un-underscored):
a name imported by other modules, listed in __all__, or used as a runtime
token (e.g. an annotation-string dispatch marker). Applies only to names you
add — do not rename pre-existing globals; that's an unrelated refactor
outside this skill's scope.is_*/has_* name, takes a broad type (often object),
returns bool — usually wants TypeGuard[X] (or TypeIs[X], which also
narrows the negative branch). TypeGuard is in typing (>= 3.10, so import
from there); TypeIs only entered typing in 3.13, so import it from
typing_extensions (>= 4.10) to stay 3.10-compatible. An issubclass-style
helper taking klass: type[_T] should return TypeGuard[type[_T]]. Prefer an
explicit isinstance(x, type) guard over try/except TypeError around
issubclass() — clearer, and it lets the checker narrow.TypeVar (or, for a callable arg whose signature flows
through, Callable[_P, _R] with ParamSpec/TypeVar) rather than widening to
object/Any. "Output type == some input type" is exactly what a TypeVar
encodes; object in / object out discards it. Caveat: if the function
transforms the value so the output type differs from the input (e.g. converts
an array to an int), a single TypeVar is wrong — name the actual domain type
instead.__init__ should get a class-level annotation so pyrefly can see them.if TYPE_CHECKING: — annotation-only imports go inside the
guard, and use from __future__ import annotations (or string forward refs) so
runtime imports stay lazy:from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from torch.fx import GraphModule
def transform(gm: GraphModule) -> GraphModule: ...
unannotated-return,
unannotated-parameter, and implicit-any are always resolvable by adding
an annotation; # pyrefly: ignore[<one of those>] is not an acceptable
outcome. The single exception is the Backward compatibility carve-out below.X | Y), Sequence[X]-style abstract type, or a bound TypeVar
for genuinely generic functions (identity-passthrough, container helpers).object — strictest fallback that still type-checks. Forces callers to
narrow before use, e.g., def serialize(value: object) -> str:. Visually
similar to Any but stricter — pyrefly rejects value.foo() without an
isinstance.Any — last rung. Always preferred over a # pyrefly: ignore on a target
category, but only after rungs 1–3 fail. Be able to articulate why each
earlier rung doesn't fit (e.g., "union exceeds 8 types", "no observable
common bound", "callers genuinely never narrow").object/Any in return position — a function
usually knows more about what it produces than its callers do. A wide return is
right only at a genuine boundary (it returns its input unchanged, or the value
is handler/caller-defined); if the body builds a known shape, name it (a domain
alias or union beats object).Any —
don't pattern-match "looks dynamic" on the first try.# pyrefly: ignore[...] (on a non-target category) is reserved
for cases where pyrefly is actually wrong about a specific local error —
dynamic metaprogramming, third-party stub gaps:# pyrefly: ignore[attr-defined]
result = getattr(obj, dynamic_name)()
# pyrefly: ignore[...] would push a line past the length
limit, put it on the line immediately above the flagged line rather than
reaching for # fmt: skip to keep it inline — pyrefly honors a previous-line
ignore. (Exception: the backward-compat carve-out below, where it must sit on
the def line.)CRITICAL: Functions decorated with @compatibility(is_backward_compatible=True)
must NOT have their signatures changed. The backward-compat test
(test_function_back_compat) compares stringified inspect.signature against a golden
file — adding annotations (even -> None) changes that string and the test fails.
Use pyrefly ignore comments instead:
@compatibility(is_backward_compatible=True)
def my_function( # pyrefly: ignore[unannotated-return]
self,
arg1, # can't add type here either
):
...
The # pyrefly: ignore comment must be on the def line (where pyrefly reports the error),
not on the closing ).
ParamSpec for signature-preserving wrappers (decorators, functools.wraps-style
helpers). Use Callable[P, R] so the wrapped function's signature flows through
to the caller — Callable[..., Any] loses it. Skip ParamSpec if the wrapper
genuinely accepts arbitrary callables. Pair with Concatenate[X, P] when the
wrapper prepends or appends args.
from collections.abc import Callable
from typing import ParamSpec, TypeVar
_P = ParamSpec("_P")
_R = TypeVar("_R")
def log_calls(fn: Callable[_P, _R]) -> Callable[_P, _R]:
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
return fn(*args, **kwargs)
return wrapper
Re-run pyrefly check. New annotations often surface bad-return errors where the
function actually returns an incompatible type — fix those. Repeat until clean.
Tightening a shared helper (e.g. adding a TypeGuard or a precise return) can
make pre-existing # pyrefly: ignore comments in its callers unused. Re-check and
delete now-dead suppressions and any stale explanatory comments — don't leave them.
Required before handing off — annotations frequently shift import order and line length:
lintrunner -a <files...>
Resolve anything lintrunner can't auto-fix manually.
Precedence when something fails: tests passing > pyrefly clean > annotation
strictness. If a freshly-added annotation breaks a test, narrow it one rung in
the discipline ladder (e.g., concrete → object, or remove an Any widening
that broke a downstream isinstance check) before reverting the file.
Backward-compat check. Run iff
grep -l '@compatibility(is_backward_compatible=True)' <target> returns the
file — the decorator is the actual precondition for the golden file. The
broader "imports torch.fx" heuristic catches half of torch/.
python -m pytest test/test_fx.py::TestFXAPIBackwardCompatibility -x -v
Unit tests for the modified module. Search both ways before concluding no coverage exists:
# torch/foo/bar.py is usually covered by test/test_foo.py or test/test_bar.py
ls test/ | grep -i <module-name>
# or by import
grep -rl "from torch.foo.bar import\|import torch.foo.bar" test/
If both come up empty, tell the user — don't silently skip. Type changes can
introduce real runtime regressions (Optional[X] vs X, Sequence vs
list when .append is called, etc.).
from __future__ import annotations
still need string quoting:class MyClass:
def __new__(cls) -> "MyClass": ...