Typing & annotations¶
How the Python side of TanGA is typed, and how that typing is enforced.
py/pytanga and py/examples are fully annotated — every public and
private function, method, dataclass field, and module-level constant declares
its types. Two independent gates keep it that way:
| Gate | Command | Catches |
|---|---|---|
ty |
uv run ty check |
wrong types: bad calls, bad attributes, unsound returns, unresolved names |
ruff ANN |
uv run ruff check . |
missing annotations (coverage) |
Both run as a pre-commit hook (ty) and in the CI lint job (see
.pre-commit-config.yaml, .github/workflows/ci.yml). ty checks the trees
listed in [tool.ty.src] include (py/pytanga, py/examples); ruff covers the
whole repository, with py/tests/** deferred from the coverage rule (see
Deferred scope).
This note is the policy; the phase-by-phase record of how the (initially 584
diagnostics / 935 missing annotations) were resolved lives in
dev/todos/ty-type-hints/.
Rule 1 — annotate everything, always¶
New code — library, example, script, notebook cell, or tool — ships with
annotations for every parameter and return value, including -> None and
including private helpers (_foo). Do not add an unannotated function "just
for now": ruff ANN rejects it, and the annotation is cheapest to write while
the types are still in your head.
# ✅
def _axis_ticks(
self, scale: Scale, lo: float, hi: float
) -> list[tuple[float, str]]: ...
# ❌ (ANN001/ANN202)
def _axis_ticks(self, scale, lo, hi): ...
Rule 2 — avoid Any; make it a deliberate, documented choice¶
ANN401 is intentionally not enabled, because a handful of surfaces are
dynamic by nature (the C++ binding dispatch layer, **properties: Any payload
dicts, handler value arguments, the loosely-typed example helpers). That
relaxation is not a licence to reach for Any: it means the gate cannot catch
it, so review has to.
Before writing Any, exhaust the alternatives:
- A real type, union, or
Sequence. MostAnys hide a type you already know (Path | None,str | None,Mapping[str, object]). objectwhen the value genuinely can be anything you only introspect (isinstance-dispatch helpers) — but see Rule 4 on dispatchers.- A
Protocolwhen you need structure, not identity — seeviz/_basis_views.py(ConformalBasis,PGABasis2D|3D),CreateModule, andStylesMapfor the pattern: declare the duck-typed contract, thencastonce at the binding boundary where the runtime contract is guaranteed. - A
TypeVar(or@overload) for "same type in, same type out".
When Any is genuinely right, keep it at the edge — an untyped C++ return
becomes a float, a tuple[float, float, float], or a documented alias as
soon as it crosses into library code.
Rule 3 — never leave a declared type a lie¶
An annotation that disagrees with the runtime is worse than no annotation, because both the reader and the checker trust it. Two directions:
- The runtime accepts more than the annotation → widen it. Examples from
this codebase:
Sequence[...]instead oflist[...](list invariance makeslist[MV]not assignable tolist[MV | float]),EAnchor | str/EStackDirection | str/EProduct | strforStrEnumparameters whose string values the docs and tests already pass, andVizInputTypefor theupdate_entity()-style entry points that resolve MVs internally. - The runtime accepts less than the annotation (i.e. it would only fail
later) → reject it early with a clear
ValueError/TypeError, asscale_matrix()does for a half-specifiedsy/szpair.
Rule 4 — prefer proving over casting¶
cast is a promise to the checker; the goal is to need as few as possible.
TypeIsturns a hand-written predicate into a narrowing test:_is_number_value(value: Any) -> TypeIs[int | float],entity._util._is_mv. Use it instead ofhasattrduck-checks inifconditions —hasattrproduces synthesized protocol intersections that no downstream call accepts.isinstance+assertin examples and scripts documents the runtime invariant and narrows the type.field(default_factory=...)removes the "optional then always set" field, which in turn removes theX | Noneunion and every.access that guarded it.- Hoist to the common base when sibling classes duplicate a method with the
same signature (
VizNode.patch,VizNode.style) — the base declaration is what lets call sites stop narrowing. @overloadis the tool for dispatchers whose return type depends on the argument (Geometry.__call__,Visualizer.add,MVTensor.__getitem__). Returningobjectfrom a dispatcher is not "safe", it is unusable: every call site then needs a cast.
Rule 5 — no None-then-assign attributes¶
Lifecycle hooks (init(), build(), mount()) that assign an attribute after
__init__ should declare it non-optional on the class rather than
initialising it to None:
class TableEditingApp(VisualizerApp):
"""..."""
#: Created in :meth:`init`, which the app framework calls before any event.
_table: TableView
Every event handler can then use self._table.undo() directly instead of
carrying a None check.
Rule 6 — narrow with a helper when the checker loses the fact¶
assert isinstance(...) narrows in the current scope, not inside a
comprehension or a nested function — and ty widens narrowings again inside
loops. In an animation loop a module-level assert does not protect a call
made in the loop body. The fix is a tiny helper whose return type is the
narrowed type:
def _single_mv(value: "MV | list[MV]") -> MV:
"""Narrow an `MV | list[MV]` result to the single MV it holds here."""
assert isinstance(value, MV), "expected a single multivector"
return value
py/examples/ga/numerics/solver_rotor_estimation.py and
py/examples/viz/animation/two_body_gravity.py (_as_direction) use this.
Rule 7 — suppressions are documented false positives¶
When a diagnostic is wrong (not the code), suppress it narrowly and say why:
return f(a_ids, b_ids, left, complete) # ty: ignore[unsound-return-statement] # binding resolved by name via getattr
Always the narrowest rule id, always a trailing reason, never a blanket
# type: ignore. The suppressions currently in the tree are:
| Site | Rule | Why it is a false positive |
|---|---|---|
py/pytanga/blade_mask/_dispatch.py |
unsound-return-statement |
the binding is resolved by name via getattr |
py/examples/ga/basis/basis_usage.py (3 lines) |
unresolved-reference |
the blade names are injected by globals().update(); the example documents this |
Deferred scope¶
py/tests/**— remaining lint debt only.F401/E402/F841/E741are suppressed in[tool.ruff.lint.per-file-ignores](tracked separately). Star imports are not allowed anywhere: the two geometry analysis tests import their entity/operator classes explicitly, soF403/F405are enforced repo-wide. ANN coverage is also enforced: the pre-existing unannotated tests carry per-line# noqa: ANN…directives (a frozen baseline added withruff check --add-noqa), so new test functions must be annotated to pass the gate.- Notebooks — the
docs/py/viz/**/*.ipynbdocumentation notebooks are annotated (they are part ofruff check .), buttydoes not read them.
Tooling reference¶
uv run ty check # correctness (py/pytanga + py/examples)
uv run ruff check . # lint, incl. ANN coverage
uv run ruff format . # formatting
uv run pre-commit run --all-files # all gates
ty's rule severities live in [tool.ty.rules] in pyproject.toml
(unsound-return-statement, missing-type-argument and
dynamic-function-decorator-return are errors;
possibly-unresolved-reference warns).