Skip to content

Run pyrefly instead of mypy - #928

Open
garth-wells wants to merge 7 commits into
mainfrom
garth/pyrefly
Open

garth-wells wants to merge 7 commits into
mainfrom
garth/pyrefly

Conversation

@garth-wells

@garth-wells garth-wells commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Replaces the mypy -p ffcx CI step with pyrefly check, and fixes the type errors that surfaced.

Two commits: the CI and configuration swap, then the source changes it required.

Configuration

[tool.mypy] becomes [tool.pyrefly], mapping each option across:

mypy pyrefly
check_untyped_defs check-unannotated-defs
disallow_untyped_defs implicit-any-parameter, unannotated-return
warn_unused_ignores unused-ignore
per-module overrides [[sub-config]] on ffcx/ir/**, ffcx/codegeneration/**

The exempted modules use replace-imports-with-any rather than ignore-missing-imports. The latter applies only when a module cannot be found, so the result would depend on the platform and on how the dependency was installed: pygraphviz is installed on Linux only, and UFL resolves when pip-installed from git but not from an editable checkout. Checking against UFL's own types reports 61 errors where treating it as Any reports 45, so it is replaced with Any, as the mypy configuration effectively did. basix and numba are now checked for real, which costs one further error and caught a wrong return annotation.

types-setuptools is dropped: it was a stub package for mypy, nothing in ffcx imports setuptools, and pyrefly carries its own typeshed.

unbound-name is off. mypy's equivalent is opt-in and was not enabled, so turning it on would newly fail CI. It reports four sites; three are guarded twice on the same condition, but the t in elementtables.py looks genuinely reachable — an unhandled 3D cell type falls through with it unset.

Fixes

Six were real:

  • access.py used False as the not-found sentinel for a dict of handlers, so the value type was Callable | bool and the truthy branch was not callable. Now None, as definitions.py already does — this also drops a # type: ignore.
  • interface.py declared form_generator returning tuple[str]. The C backend returns (declaration, implementation); only numba returns a 1-tuple. The sibling protocols already use tuple[str, ...].
  • numba/utils.py declared get_void_pointer returning a RawPointer. numba.types.voidptr(arr) builds a Signature, as its own docstring says.
  • elementtables.py returned a bare dict of the table and its offsets, so reading the offsets back gave ndarray | int. It now has a TypedDict.
  • optimizer.py's _drop_unreferenced returns whatever shape it is given; overloads say so.
  • representation.py passed tensor_shape, a list, where a tuple is declared and only its length is read.

Three are suppressed, all the same thing: **-unpacking a dict into a NamedTuple, which the checker can follow neither for the field set nor for their types.

Why pyrefly is floored at 1.4

Worth knowing before the next upgrade. pyrefly 1.3 additionally reports singledispatchmethod making __call__ a descriptor where the protocol declares a plain method, and an isinstance that cannot narrow; 1.4 reports neither. Because unused-ignore is on — mypy's warn_unused_ignores, which this project had — suppressing them fails on 1.4 and not suppressing them fails on 1.3. There is no source that satisfies both, so the floor picks the version whose answer the source matches. Verified: clean on 1.4.0, 3 errors on 1.3.2.

This cuts both ways: a later release that fixes one of the three suppressions above will fail CI the same way until that suppression is removed. That is unused-ignore doing its job, but it does couple the repo to the checker version.

One bug left alone

ffcx/ir/integral.py compares p["part"], the option string "full"/"diagonal", against TensorPart, a plain Enum over ints. That comparison is always False, so its branch never runs. Comparing TensorPart.from_str(p["part"]) instead sets rank = 1 and fails all 18 part="diagonal" tests, so the live behaviour is load-bearing and the intent needs deciding rather than the comparison simply being repaired. The call site carries a FIXME; filed as #929.

Verification

pyrefly check reports 0 errors on 1.4.0, both with UFL resolvable and not, and exits 1 on a deliberately introduced error so the CI step fails when it should. Full suite 474 passed, demos 232 passed, ruff and ruff format clean.

Note this touches elementtables.py, which #903 restructures — they will conflict.

🤖 Generated with Claude Code

Replaces `[tool.mypy]` with `[tool.pyrefly]`, mapping each option across:
check_untyped_defs to check-unannotated-defs, disallow_untyped_defs to
the implicit-any-parameter and unannotated-return error kinds,
warn_unused_ignores to unused-ignore, and the two per-package overrides
to sub-config blocks matching ffcx/ir and ffcx/codegeneration.

The exempted modules use `replace-imports-with-any` rather than
`ignore-missing-imports`, which applies only when a module cannot be
found and so would give a different answer depending on the platform and
the way the dependency was installed: pygraphviz is installed on Linux
only, and UFL resolves when pip-installed from git but not from an
editable checkout. Checking against UFL's own types reports 61 errors
where treating it as Any reports 45, so it is replaced with Any, as the
mypy configuration effectively did. basix and numba are now checked for
real, which costs one further error and found a wrong return annotation.

types-setuptools was a stub package for mypy. Nothing in ffcx imports
setuptools, and pyrefly carries its own typeshed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six are real:

- access.py used `False` as the not-found sentinel for a dict of
  handlers, so the value type was `Callable | bool` and the truthy
  branch was not callable. Use None, as definitions.py already does;
  this also drops a `# type: ignore`.
- interface.py declared form_generator returning `tuple[str]`. The C
  backend returns (declaration, implementation) and only numba returns a
  1-tuple. Its sibling protocols already use `tuple[str, ...]`.
- numba/utils.py declared get_void_pointer returning a RawPointer.
  `numba.types.voidptr(arr)` builds a Signature, as its docstring says.
- elementtables.py returned a bare dict of the table and its offsets, so
  reading the offsets back gave `ndarray | int`. Give it a TypedDict.
- optimizer.py's _drop_unreferenced returns whatever shape it is given;
  say so with overloads.
- representation.py passed tensor_shape, a list, where a tuple is
  declared and only its length is read.

Three are suppressed, all the same thing: `**`-unpacking a dict into a
NamedTuple, which the checker can follow neither for the field set nor
for their types.

pyrefly is floored at 1.4 rather than left open below. 1.3 additionally
reported `singledispatchmethod` making __call__ a descriptor where the
protocol declares a plain method, and an isinstance that could not
narrow; 1.4 reports neither. Since unused-ignore is on, suppressing them
fails on 1.4 and not suppressing them fails on 1.3, so the floor picks
the version whose answer the source matches. Note this cuts both ways: a
later release that fixes one of the three suppressions above will fail
the same way until the suppression is removed.

integral.py has a seventh error that is left alone. `p["part"]` is the
option string "full"/"diagonal" while TensorPart is a plain Enum over
ints, so the comparison is always False and its branch never runs.
Comparing TensorPart.from_str(p["part"]) instead sets rank = 1 and fails
all 18 part="diagonal" tests, so the live behaviour is load-bearing and
the intent needs deciding rather than the comparison being repaired. The
call site carries a FIXME saying so.

unbound-name is off: mypy's equivalent is opt-in and was not enabled, so
turning it on would newly fail CI. It reports four sites, of which the
`t` in elementtables.py looks genuinely reachable -- an unhandled 3D cell
type falls through with it unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
no-any-return is mypy's warn_return_any, which the configuration this
replaced had and the conversion dropped. The two sites are asarray calls
whose list argument is Any, because basix.geometry is; bind the result to
the declared type the dtype= argument already asserts.

unbound-name has no mypy equivalent in use: possibly-undefined is opt-in
and was not enabled. Four sites:

- elementtables.py built a permuted facet table for a tetrahedron or a
  hexahedron and fell through for anything else, so an unsupported cell
  reached `t` unset and raised UnboundLocalError rather than saying what
  was unsupported. Raise NotImplementedError naming the cell and tdim.
- elementtables.py reads `weights` under the same `avg` test it is set
  under, which the checker cannot connect; assert the invariant.
- main.py created the profiler under `if xargs.profile` and used it under
  a second one. Hold it in an Optional and test that instead.
- naming.py sets `kind` on every iteration of a loop that could be empty.
  Note this changes an error path: compute_signature([]) raised
  NameError and now returns a signature with an empty kind. Nothing can
  depend on that, since it was a crash.

implicit-reexport reports nothing today and keeps it that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@schnellerhase

Copy link
Copy Markdown
Contributor

Why replace and not add as an additional check? The two catch different things.

@garth-wells

Copy link
Copy Markdown
Member Author

Why replace and not add as an additional check? The two catch different things.

I don't see that two tools (or 3 or 4, or ..) brings added value in this case. We use pyrefly only for DOLFINx and Basix. This provides consistency across the projects.

@schnellerhase

Copy link
Copy Markdown
Contributor

Mypy is more mature, if only one tool is to be picked, I think that's the one. We have been running in dolfiny ty, mypy and pyrefly, they do not collide and do catch different things in certain cases. More coverage is always preferable.

garth-wells and others added 4 commits October 20, 2026 07:17
The strict preset costs 115 errors here, all of them from four kinds:
implicit-any-lambda (43), implicit-any-type-argument (38),
missing-override-decorator (18) and implicit-any-empty-container (16).
Everything else in it already passes, so take the preset and opt out of
those four, each recorded with what it currently costs.

That turns on, among others, redundant-cast, redundant-condition,
unnecessary-comparison, unnecessary-isinstance, method-assign,
bad-typed-dict, unsafe-overlapping, assert-type, invalid-annotation,
unknown-base-class and deprecated -- none of which mypy was running, and
none of which FFCx violates today.

No source changes: this is configuration only.

The four opt-outs are annotation debt rather than defects, so they are
best paid down a package at a time. Two further steps are available and
not taken here: unknown-return-type (19) and checking UFL against its own
types rather than replacing it with Any (36), the latter at the cost of
the install-independence noted above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
unused-ignore, mapped from mypy's warn_unused_ignores, reports an unused
`# pyrefly: ignore` but says nothing about an unused `# type: ignore`:
pyrefly leaves those alone deliberately, since they may be shared with
another checker, and offers only a --remove-unused-ignores fixer. FFCx
had 20 `# type: ignore` against 4 `# pyrefly: ignore`, so the check was
watching 4 suppressions out of 24 where mypy had watched all of them.

Seven of the twenty were already dead. Stripping all twenty and running
`pyrefly suppress` brings back the thirteen that still suppress
something, each with the code it needs -- no-matching-overload or
bad-argument-type -- rather than a blanket ignore, and leaves the dead
seven out.

enabled-ignores keeps it that way: `# type: ignore` no longer suppresses
at all, so a suppression cannot be added that unused-ignore does not
watch. All 17 are now covered.

Comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_graph_name arrived from #916 while this branch was open. Its `name`
parameter is unannotated, so `parts` is a list[Any] and "_".join(parts)
is an implicit Any against the declared -> str, which no-any-return now
reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants