Skip to content

fix[eve]: handle all modern typing spellings at annotation dispatch sites - #2841

Merged
egparedes merged 1 commit into
mainfrom
py312-1-eve-annotation-funnels
Aug 31, 2026
Merged

fix[eve]: handle all modern typing spellings at annotation dispatch sites#2841
egparedes merged 1 commit into
mainfrom
py312-1-eve-annotation-funnels

Conversation

@egparedes

@egparedes egparedes commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

eve builds datamodel validators and converters at run time from live annotation
objects, dispatching on annotation identity. Python spells the same type more than one
way, and a dispatch site that has not been taught a spelling does not raise — it falls
through to a no-match branch and silently builds the wrong validator or converter.

Fixed across _make_type_converter, type_validation, _is_strictly_immutable_type,
get_represented_types and infer_type:

  • PEP 604 unions. origin_type is Union missed types.UnionType, so int | None
    reached is_actual_type() — which accepts it, being a real class — and built a
    converter calling types.UnionType(value): the class built, every instantiation
    raised. On 3.14 typing.Union is types.UnionType, so non-optional unions reach
    that same fallback; they are now rejected explicitly, at class creation.
  • Optional argument order. typing preserves the written order, so
    Union[None, int] recursed into NoneType and raised.
  • Annotated. No funnel saw through it. On 3.12 typing.Annotated is a class, so
    two of them took it for an ordinary type — building a typing.Annotated(value) call,
    and an isinstance check nothing can satisfy; the immutability check tested the
    metadata and called immutable fields mutable; get_represented_types reported the
    special form itself. Inside type[...] neither the metadata nor a nested PEP 695
    alias was resolved, so type[Annotated[int, "meta"]] accepted str.
  • Literal. get_represented_types returned (), and callers pass the result to
    isinstance(), making every such check False.
  • infer_type recognized only PEP 585 generics, so typing.List[int],
    Optional[int], int | str and PEP 695 aliases came back as the CPython-internal
    class implementing them.
  • Cycles of aliases and Annotated. type R = Annotated[R, ...], or a mutually
    wrapping pair, resolves only to itself. In type_validation the placeholder became
    its own validator, so the annotation built cleanly and exhausted the stack on the
    first value checked; the other funnels walked the two wrappers until the interpreter
    stack ran out. Both are now reported as unresolvable while building.

normalize_union, strip_annotated and resolve_annotation carry the reasoning the
funnels would otherwise each repeat. Each returns the identical object when it has
nothing to do, so a caller can detect the no-op with is -- which normalize_union
has to check for explicitly, since from 3.14 typing.Union is types.UnionType and
an isinstance test alone also matches annotations that are already normalized. The last resolves aliases and Annotated together
as a bounded fixpoint with cycle detection, which is what keeps the cycles above from
recursing: applied in separate branches the two wrappers walk each other forever. Also removed: a discarded duplicate make_recursive() call that built
every Sequence/Set member validator twice.

Behaviour change: x: int | str = field(converter="coerce") now raises EveTypeError
at class creation instead of building a converter that fails on every value. No in-tree
Coerced[...] site is affected.

tests/eve_tests/unit_tests/test_annotation_spelling.py is a conformance matrix
crossing each spelling with each dispatch site, asserting that two spellings of one
type agree rather than checking fixed expectations, so it keeps working as sites are
added. Every fix above was verified by reverting it alone and confirming the matrix
fails. Three ways the matrix could quietly stop asserting anything are themselves
tested: PEP 563 string annotations, typing's parametrization cache turning a string
round-trip into an identity, and get_partial_type_hints stripping Annotated before
datamodel-mediated funnels ever see it.

Not fixed here: 13 further gaps found auditing the dispatch sites. Eight need a PEP 695
alias to trigger and src/ has none — the worst is eve/traits.py:118, where a
SymbolRef behind an alias is never collected and dangling-symbol validation becomes a
no-op; fixing them means normalizing annotations where they are stored, which is an ADR
decision. The other four activate with the upcoming annotation sweep and must land in
the same commit as the rewrite that triggers them, notably next/otf/workflow.py:159,
where issubclass(get_origin(X | None), ...) is False and an optional pipeline step
is silently dropped from step_order.

Verified on 3.12, 3.13 and 3.14: 858 passed + 57 doctests each. next / storage /
cartesian unit tests: 3057 passed, 108 skipped, 13 xfailed. pre-commit run --all-files clean, including mypy and tach. Not run locally: the backend matrix.

@egparedes
egparedes changed the base branch from modernize-py312-style to main August 27, 2026 18:37
@egparedes
egparedes force-pushed the py312-1-eve-annotation-funnels branch from f0b7b75 to be948cb Compare August 27, 2026 18:37
@egparedes egparedes changed the title fix[eve]: handle modern typing spellings at every annotation funnel fix[eve]: handle all modern typing spellings at annotation dispatch sites Aug 28, 2026

@havogt havogt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 4 of the 5 comments are actually 1. It seems we are missing an abstraction for the pattern "if Annotated then do something on the type, not the metadata".
I didn't pay too close attention to the test/test structure. Did you?

Comment thread src/gt4py/eve/datamodels/core.py Outdated
Comment thread src/gt4py/eve/datamodels/core.py Outdated
Comment thread src/gt4py/eve/extended_typing.py Outdated
Comment thread src/gt4py/eve/type_validation.py Outdated
@havogt

havogt commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Three small things from reviewing this at be948cbef. Method for the evidence below: I reverted each fix individually and re-ran test_annotation_spelling.py, so "red"/"green" means the new suite does/doesn't catch that change.

1. type_validation.py:338 — duplicated make_recursive call (pre-existing, but in a function this PR edits)

assert len(type_args) == 1
make_recursive(type_args[0])                                    # <- result discarded
if (member_validator := make_recursive(type_args[0])) is None:

The first call's result is thrown away and the same validator is built again on the next line, so every Sequence/Set-typed field builds its member validator twice. It's benign — _alias_memo is never popped, so a recursive alias returns the memoized deferred rather than recursing again — but it's wasted work on most IR nodes. Not introduced here, though it sits inside SimpleTypeValidatorFactory.__call__, which this PR does modify.

2. extended_typing.py:989 — the _types.UnionType arm of the infer_type pass-through is redundant

if (
    isinstance(value, (StdGenericAliasType, _TypingSpecialFormType, _types.UnionType))
    or is_type_alias(value)
    or get_origin(value) is not None
):

isinstance(x, types.UnionType) is true exactly when get_origin(x) is types.UnionType, i.e. non-None, so line 991 already subsumes it — on 3.12 and, since Union is UnionType there, on 3.14 as well. Evidence: dropping _types.UnionType alone leaves the new file at 348/348 green, while dropping either is_type_alias or get_origin alone turns it red. Worth either removing it or keeping it deliberately as documentation.

3. Minor — part of the TRANSPARENT_WRAPPERS matrix cannot fail

get_partial_type_hints without include_extras strips Annotated recursively (checked on 3.12.12 for Annotated[int, "m"], List[Annotated[int, "m"]] and dict[str, Annotated[int, "m"]]), so every funnel reached through a datamodel compares X against X for these rows. That's converter, coerced, datamodel and strict-frozen — 4 wrappers × 4 funnels = 16 assertions that pass regardless of what the funnels do. The module docstring notes this for the two directly-called funnels; it holds for four.

Confirmed by mutation: reverting any of the Annotated branches turns red only the type_converter, immutability, represented_types and validator cells, never the datamodel-mediated ones.

Not a defect — the direct-call observers are exactly the compensation, and the file is upfront about why they exist. The gap is that test_spelling_pairs_are_distinct guards a row against degenerating into f(x) == f(x) but not a row×funnel cell, so a fifth datamodel-mediated funnel added to FUNNELS later would silently join the tautological set — which is a bit at odds with "adding a funnel means adding an observer" being the advertised workflow.

@egparedes
egparedes force-pushed the py312-1-eve-annotation-funnels branch from be948cb to c597562 Compare August 28, 2026 18:45
@egparedes

Copy link
Copy Markdown
Contributor Author

Thanks — all three addressed, and the abstraction you asked for is in. Rebased onto current main (dfc8953c2) along the way.

1. Duplicated make_recursive call — removed. Confirmed dead as you described: the second call returns the memo placeholder, which delegates identically, so the only effect was building every Sequence/Set member validator twice.

2. _types.UnionType arm of infer_type — removed. Verified your reasoning directly: isinstance(x, types.UnionType) is true only when get_origin(x) is non-None, on 3.12 and on 3.14 where Union is UnionType. The remaining two clauses still go red individually when dropped.

3. The TRANSPARENT_WRAPPERS cells that cannot fail — this was the useful one, so I made it enforced rather than documented. Both funnel sets are now pinned (_DATAMODEL_MEDIATED_FUNNELS / _DIRECTLY_CALLED_FUNNELS) and test_every_funnel_is_classified fails if a funnel is added to FUNNELS without being classified, which closes the "adding a funnel means adding an observer" gap you pointed at. A separate test pins the root cause — that get_partial_type_hints strips Annotated at every depth — so if that ever changes, it is a test failure rather than a silent shift in what the matrix covers.

On your question about the test structure: it was worth asking. Three things surfaced after your review that the suite was not catching:

  • The new test file was red on 3.14. Two of the four failures were a real bug, not a test artefact: on 3.14 typing.Union is types.UnionType, so a union origin passes is_actual_type() and _make_type_converter built a converter calling types.UnionType(value) — the datamodel constructs fine and every value fails at instantiation. That is the same defect as the first one in this PR's description, reappearing by another route. Pre-existing on main; fixed here since it is squarely what this PR is about. nox -s test_eve-3.13/3.14 now runs as part of my checks.
  • Annotated inside type[...] was not seen through at all, so type[Annotated[int, "meta"]] accepted str. Nested aliases and Annotated can also wrap each other repeatedly, so they are now unwrapped to a bounded fixpoint.
  • type R = Annotated[R, "meta"] hit a RecursionError — the cycle-breaker for genuine recursive aliases handed it its own placeholder as its validator, so it built cleanly and blew the stack on the first value. Now rejected at build time; type Tree = list[Tree] still works.

One correction to my own earlier text: I wrote "typing.Annotated is a class since 3.12" in a few places. That is true only on 3.12 — 3.13 reverted it to a special form, so from 3.13 the funnels stop claiming it by accident and fall through to a no-match branch instead. The abstraction is still needed on all three; the docstring now describes both regimes.

Also recorded, not fixed: the Coerced/Unchecked tag lookup reads the Annotated metadata off the raw annotation, so type MyCoerced = Coerced[int] silently loses the tag (true on main too). It is in the "Not supported" list in extended_typing.py now rather than unrecorded.

Verified on 3.12, 3.13 and 3.14: 851 passed + 56 doctests each; next/storage/cartesian unit tests 3056 passed, 108 skipped, 13 xfailed; pre-commit run --all-files clean. Every fix is mutation-tested — reverted alone, suite confirmed red.

@egparedes

Copy link
Copy Markdown
Contributor Author

cscs-ci run default

@egparedes egparedes left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address my comments to make the source code comments more concise.

Comment thread src/gt4py/eve/datamodels/core.py Outdated
Comment on lines +1054 to +1059
# A union of two or more non-'None' types has no single type to coerce to.
# Saying so explicitly is what keeps this branch honest on Python 3.14, where
# 'typing.Union' *is* 'types.UnionType' -- a real class, and therefore accepted
# by the 'is_actual_type(origin_type)' fallback below, which would build a
# converter calling 'types.UnionType(value)'. That is the same silent breakage
# this function already avoids for 'X | Y' on 3.12, reached by another route.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too verbose, rewrite it in a concise way .

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut from six lines to three — kept the 3.14 reason, dropped the "same breakage by another route" aside.

Comment thread src/gt4py/eve/extended_typing.py Outdated
Comment on lines +490 to +503
The extra arguments of an ``Annotated`` are metadata values, not types, so every
funnel that dispatches on an annotation has to look straight through them to
``X``. What makes this worth naming is that the failure is quiet, and quiet in a
different way on each interpreter. On 3.12 ``typing.Annotated`` is a class, so a
funnel dispatching on "is this a class" claims it by accident and builds
something meaningless: a converter calling ``typing.Annotated(value)``, an
``isinstance`` check no value can satisfy, or a mutability verdict read off the
metadata instead of the type. From 3.13 on it is a special form again, so those
funnels stop claiming it and it falls through to a no-match branch instead --
a raise, or a wrong answer, depending on the funnel.

``typing`` flattens nested ``Annotated``, so one step is always enough. Any other
annotation is returned unchanged (as the identical object), which lets a caller
detect the no-op with ``is``.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The content of the comment is good but the style is too verbose. Make it concise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tightened to nine lines from fourteen, with the content intact: both interpreter regimes and the "one step is enough" note are still there, just said once each.

Comment thread src/gt4py/eve/extended_typing.py Outdated
Comment on lines +1013 to +1018
# An annotation passed in as a value is returned unchanged. Only the PEP 585
# spelling ('list[int]') and the bare special forms used to be recognized here: a
# parametrized 'typing' alias ('typing.List[int]', 'Optional[int]'), a PEP 604
# union ('int | str') and a PEP 695 alias all fell through to the 'type(value)'
# fallback below and were reported as the CPython-internal class implementing
# them ('typing._GenericAlias', 'types.UnionType', ...).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the comment document the current behavior, not the history of how it arrived to this state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to state what the check does — which annotation shapes are returned unchanged, and what the type(value) fallback would otherwise report. No more "used to be".

@egparedes
egparedes force-pushed the py312-1-eve-annotation-funnels branch from c597562 to a009efc Compare August 31, 2026 07:30
@egparedes

Copy link
Copy Markdown
Contributor Author

Done — all three rewritten, pushed in a009efc4f.

I also applied the same standard to the comments you did not flag but which were mine in that style, so you do not have to point at each one: the self-referential-alias guard (7 lines → 4), the type[...] fixpoint comment (6 → 4), the _MAX_TYPE_ARG_UNWRAPS note (3 → 2), normalize_union's docstring (7 → 5), and _IDENTITY_ROUND_TRIP_ROWS in the test (13 → 8). −23 lines net, no content dropped.

Two I compressed rather than cut, since earlier review rounds turned on them: the ordering note in the type[...] branch (swapping those two statements leaves the whole suite green while reintroducing the bug), and the PEP 563 note on why the test module omits from __future__ import annotations. Happy to trim them further if you would rather.

Re-verified: test_eve on 3.12/3.13/3.14 — 851 passed + 56 doctests each; next/storage/cartesian unit tests 3056 passed, 108 skipped, 13 xfailed; pre-commit run --all-files clean.

@egparedes egparedes changed the title fix[eve]: handle all modern typing spellings at annotation dispatch sites fix[eve]: dispatch on all modern typing spellings when building datamodel validators Aug 31, 2026
@egparedes egparedes changed the title fix[eve]: dispatch on all modern typing spellings when building datamodel validators fix[eve]: handle all modern typing spellings at annotation dispatch sites Aug 31, 2026
@egparedes
egparedes force-pushed the py312-1-eve-annotation-funnels branch 2 times, most recently from 62880cd to ed1eac2 Compare August 31, 2026 09:39
@egparedes
egparedes requested a review from havogt August 31, 2026 10:02
@havogt

havogt commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Two nits from re-reviewing at ed1eac261. Neither is blocking — both are contract/documentation accuracy, not behaviour.

nit 1 — normalize_union's "identical object" promise does not hold on 3.14

The docstring says any other annotation "is returned unchanged (as the identical object)", which invites callers to detect the no-op with is. On 3.14 typing.Union is types.UnionType, so isinstance(annotation, _types.UnionType) is also true for Optional[int] and Union[X, Y], and those get rebuilt through _typing.Union[annotation.__args__] rather than returned as-is.

Measured by wrapping the function and counting calls where the result is not the argument, over tests/eve_tests with test_annotation_spelling.py excluded (470 tests, identical set on each version):

3.12 3.13 3.14
normalize_union returned a different object 6 6 60

The results are equivalent, so nothing is wrong today — no in-tree caller identity-checks this particular return value (_make_type_converter and the validator factory both apply is to resolve_annotation's result, not this one). But the documented contract is what a future funnel would rely on, and "same annotation, different identity depending on interpreter" is the shape of trap this PR exists to close. Either narrow the sentence to the pre-3.14 spelling, or return early when get_origin(annotation) is _typing.Union.

nit 2 — _MAX_TYPE_ALIAS_DEPTH = 64, but the effective limit is 62

Building a chain of PEP 695 aliases (type A0 = int, type A1 = A0, … type A{n} = A{n-1}):

  • resolve_annotation(A62)int
  • resolve_annotation(A63)TypeError: ... cannot be resolved (nested too deeply)

Same boundary for the interleaved form (type B{i} = Annotated[B{i-1}, "meta"]): B62 resolves, B63 raises. The two iterations of slack are presumably the initial step plus the fixpoint confirmation, so the constant overstates what it permits by 2. Nobody is chaining 60 aliases, so this only matters if the number is ever quoted in a message or a doc.

For what it is worth, the rest held up: test_eve is 857 passed + 57 doctests on 3.12, 3.13 and 3.14 identically, and I mutation-tested the restructured code — strip_annotated, normalize_union, resolve_annotation's cycle detection, the converter head, resolve_annotation vs eval_type_alias, the validator head, the type[...] resolve, and the statement ordering inside type[...] all turn the suite red when reverted individually. Nothing in the new code is unpinned.

One correction to my earlier round: I had flagged the type[...] ordering as untestable, taking the "swapping those two statements leaves the whole suite green" note at face value. That is not true — annotated-inside-type-union catches the swap, so the note is stale rather than the coverage being absent.

@havogt havogt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, see comment just before this one for 2 small things, you can decide what to do with it.

'eve' builds datamodel validators and converters at runtime from live
annotation objects, dispatching on annotation identity at several
independent sites. Python has more than one spelling for the same type,
and a site that has not been taught one does not raise: it falls through
to a no-match branch and silently builds the wrong thing.

Teach the funnels the spellings they were missing:

- '_make_type_converter' compared against 'typing.Union' only, so
  'int | None' (whose origin is 'types.UnionType') fell through to
  'is_actual_type(origin_type)' -- which accepts 'types.UnionType',
  since it is a real class -- and built a converter calling
  'types.UnionType(value)'. Class creation succeeded and every
  instantiation raised. Add 'xtyping.normalize_union' and call it at the
  head of the funnel, so the recursive calls are covered too.
- The same function's Optional branch took 'args[0]' as the wrapped
  type, but 'typing' preserves the written order, so 'Union[None, int]'
  recursed into 'NoneType' and raised.
- Neither '_make_type_converter' nor the validator funnel had an
  'Annotated' branch. 'typing.Annotated' is a class since 3.12, so both
  accepted it as an ordinary type: the converter built one calling
  'typing.Annotated(value)', and the validator built an 'is instance of
  typing.Annotated' check that no value can satisfy.
- 'get_represented_types' reported the 'Annotated' special form itself
  as the represented type, and returned an empty tuple for 'Literal',
  whose arguments are values rather than types. Callers turn the result
  into an 'isinstance()' argument, so both made every check silently
  false.
- '_is_strictly_immutable_type' had no 'Annotated' branch either, so it
  type-checked the metadata and reported immutable fields as mutable.
- 'infer_type' passed through only the PEP 585 spelling, reporting
  'typing.List[int]', 'Optional[int]', 'int | str' and PEP 695 aliases
  as the CPython-internal class implementing them.

'type_validation' keeps its behaviour; its inline union normalization is
replaced by the shared helper.

Add 'tests/eve_tests/unit_tests/test_annotation_spelling.py': a
conformance matrix crossing every spelling with every funnel, asserting
that two spellings of one type are indistinguishable. It is written as
an agreement property rather than against fixed expectations, so it
keeps working as funnels are added -- adding one means adding an
observer.

Two funnels are observed by calling them directly rather than through a
datamodel, because 'get_partial_type_hints' strips 'Annotated' before a
field annotation reaches them: routed through a datamodel those
observers compare 'X' against 'X' and cannot fail. Each fix here was
checked by reverting it alone and confirming the matrix goes red.

'infer_type' is not a matrix column: it returns annotations unchanged,
so two spellings correctly give two different objects. It gets its own
pass-through and value-inference tests instead.
@egparedes
egparedes force-pushed the py312-1-eve-annotation-funnels branch from ed1eac2 to 2f4b999 Compare August 31, 2026 11:37
@egparedes

Copy link
Copy Markdown
Contributor Author

Thanks — both verified, and they split differently than they look.

nit 1: real, and mine. Fixed.

Reproduced exactly as you describe:

3.12/3.13   normalize_union(Optional[int]) is Optional[int]  ->  True
3.14        normalize_union(Optional[int]) is Optional[int]  ->  False

normalize_union now returns early when the annotation is already a typing.Union:

if isinstance(annotation, _types.UnionType) and get_origin(annotation) is not _typing.Union:

so the is contract holds on all three interpreters, and a genuine PEP 604 union is still rewritten before 3.14. I took the guard rather than narrowing the docstring, because the sentence is what a future funnel would build on, and you are right that "same annotation, different identity depending on interpreter" is the shape of trap this PR exists to close.

Pinned by test_normalize_union_returns_identical_objects_for_normalized_input, which is version-sensitive in the correct direction: dropping the guard fails on 3.14 and correctly still passes on 3.12, where the guard is a no-op.

nit 2: pre-existing, and not resolve_annotation's doing.

The boundary is eval_type_alias's own loop, on main as it stands:

eval_type_alias(A62) -> <class 'int'>
eval_type_alias(A63) -> TypeError: ... cannot be resolved (nested too deeply).

resolve_annotation inherits it rather than adding to it — its own loop never gets a second iteration for a plain chain, since eval_type_alias resolves the whole thing in one call. So the two-step slack is inside the existing helper.

I left it alone: changing that loop's bound in a PR about annotation dispatch would need its own test, and nothing quotes the number in a message or a doc. Happy to fix it as a one-liner separately if you would rather have it exact.

And thanks for the correction on the type[...] ordering — you are right that annotated-inside-type-union catches the swap; my note was written before that row existed and I did not go back and revise it.

Rebased onto main (7bf6c14da) in the same push. Verified on 3.12, 3.13 and 3.14: 858 passed + 57 doctests each; next/storage/cartesian unit tests 3057 passed, 108 skipped, 13 xfailed; pre-commit run --all-files clean.

@egparedes
egparedes merged commit cc707df into main Aug 31, 2026
30 checks passed
egparedes added a commit that referenced this pull request Aug 31, 2026
`DimensionType: TypeAlias = type[DimensionBase]` names "a concrete dimension, i.e. the class
itself", and replaces the 208 `type[Dimension]` annotations in `src/gt4py/next`. It is spelled
against `DimensionBase` rather than `Dimension` so that parameterized dimension types, which are
not user-declarable, are admitted once they exist. Exported as `gtx.DimensionType`.

Two exceptions keep the narrower `type[Dimension]`, both noted in place: the `Dimension(...)`
factory overload, which always returns a user-declarable dimension and whose exact type call
sites assert, and `TYPE_BUILTINS`, which derives DSL names from `__name__`.

`DimensionBase` also gains an explicit `value`. The metaclass reads `value` in `__repr__`,
`__eq__` and `__hash__`, but `__init_subclass__` only sets it on *subclasses*, so hashing or
printing the root itself raised `AttributeError`. Nothing reached it before; a
`type[DimensionBase]` annotation does, because `eve` memoizes its validator factory on the
annotation object.

A `TypeAlias` and not a PEP 695 `type` statement: `src/` deliberately has no PEP 695 aliases,
and #2841 documented eight latent `eve` dispatch gaps that need one to trigger.
egparedes added a commit that referenced this pull request Aug 31, 2026
`DimensionType: TypeAlias = type[DimensionBase]` names "a concrete dimension, i.e. the class
itself", and replaces the 208 `type[Dimension]` annotations in `src/gt4py/next`. It is spelled
against `DimensionBase` rather than `Dimension` so that parameterized dimension types, which are
not user-declarable, are admitted once they exist. Exported as `gtx.DimensionType`.

Two exceptions keep the narrower `type[Dimension]`, both noted in place: the `Dimension(...)`
factory overload, which always returns a user-declarable dimension and whose exact type call
sites assert, and `TYPE_BUILTINS`, which derives DSL names from `__name__`.

`DimensionBase` also gains an explicit `value`. The metaclass reads `value` in `__repr__`,
`__eq__` and `__hash__`, but `__init_subclass__` only sets it on *subclasses*, so hashing or
printing the root itself raised `AttributeError`. Nothing reached it before; a
`type[DimensionBase]` annotation does, because `eve` memoizes its validator factory on the
annotation object.

A `TypeAlias` and not a PEP 695 `type` statement: `src/` deliberately has no PEP 695 aliases,
and #2841 documented eight latent `eve` dispatch gaps that need one to trigger.
egparedes added a commit that referenced this pull request Sep 1, 2026
`DimensionClass: TypeAlias = type[DimensionBase]` names "a concrete dimension, i.e. the class
itself", and replaces the 208 `type[Dimension]` annotations in `src/gt4py/next`. It is spelled
against `DimensionBase` rather than `Dimension` so that parameterized dimension types, which are
not user-declarable, are admitted once they exist. Exported as `gtx.DimensionClass`.

Named `DimensionClass`, not `DimensionType`, because `*Type` already means something else here:
`ts.DimensionType` and its 14 siblings are the *DSL* types of a value. Nine modules refer to both
concepts, so reusing the suffix would have put `dims: list[common.DimensionType]` and
`ts.DimensionType(dim=value)` in the same file meaning different things. `*T` (TypeVar, as in
`DimT`) and `*Like` (coercible-to, as in `RangeLike`) are likewise taken.

Two exceptions keep the narrower `type[Dimension]`, both noted in place: the `Dimension(...)`
factory overload, which always returns a user-declarable dimension and whose exact type call
sites assert, and `TYPE_BUILTINS`, which derives DSL names from `__name__`.

`DimensionBase` also gains an explicit `value`. The metaclass reads `value` in `__repr__`,
`__eq__` and `__hash__`, but `__init_subclass__` only sets it on *subclasses*, so hashing or
printing the root itself raised `AttributeError`. Nothing reached it before; a
`type[DimensionBase]` annotation does, because `eve` memoizes its validator factory on the
annotation object.

A `TypeAlias` and not a PEP 695 `type` statement: `src/` deliberately has no PEP 695 aliases,
and #2841 documented eight latent `eve` dispatch gaps that need one to trigger.
egparedes added a commit that referenced this pull request Sep 2, 2026
…ndices

A concrete dimension is now declared as a class, and an index along it is an instance of that
class -- the shape `enum.Enum` uses, where the class is the collection and the instances are
its members:

```python
class IDim(gtx.DimensionIndex): ...
class KDim(gtx.DimensionIndex, kind=gtx.DimensionKind.VERTICAL): ...

IDim                       # the dimension    -- annotated `gtx.Dimension`
IDim(0)                    # an index into it -- annotated `IDim`
```

so `gtx.Field[gtx.Dims[IDim], gtx.float64]` is a valid annotation for any PEP 484 type checker,
with no gt4py mypy plugin. `DimensionMeta` carries the API that belongs to the dimension itself
(`I + 1`, `I > 5`, `I == 5`, `repr`, equality, hashing); binary operators on a class object
dispatch through the metaclass, so that is the only place they can live.

This drops the dimension half of `mypy_plugin.py`, which substituted at most four distinct
placeholders per run (`_DimA`..`_DimD`, then `_AnyDim` for everything after), made `TypeVar`s
over dimensions impossible, and served only mypy.

Because `IDim(0)` is now ordinary instantiation, the whole apparatus that a foreign return type
required is gone: no `__new__` returning a non-instance, no duplicated overloads on both
`DimensionMeta.__call__` and `__new__`, and no `# type: ignore[misc]` anywhere on the
instantiation path. mypy and pyright agree natively -- pinned by the typing tests in #2845.
Indices also carry their dimension in the type, so mixing them is a static error;
`common.NamedIndex` is deleted.

Naming: the dimension's name is `.tag` (typed `common.Tag`, which already existed), and `.value`
keeps its meaning as the index position. The reverse split does not type-check at all -- an
instance attribute cannot shadow a `ClassVar` -- and this direction leaves every index
expression, downstream included, untouched. `.dim` survives as a property returning `type(self)`.

`common.Dimension` is a plain `TypeAlias` for `type[DimensionIndex]`, re-exported as
`gtx.Dimension`, and `common.dimension(tag, kind)` is the programmatic constructor for the IR
boundaries that rebuild a dimension from its tag. A `TYPE_CHECKING`-split callable shim was
tried first and rejected: `eve.datamodels` resolves annotations at run time, so a `Dimension`
field would see the shim rather than a type. A PEP 695 alias was also rejected: `get_origin()`
of one is `None` rather than `type`, which silently misroutes the `get_origin(t) is type`
dispatch in `ffront.fbuiltins` (see #2841).

Reading `.value` on a dimension *class* would otherwise return the `__slots__` member
descriptor rather than raising, and the nonsense value only surfaces much later as a missing
offset-provider key or an `AxisLiteral` validation failure. A metaclass property makes it a
loud `AttributeError` pointing at `.tag`; instance access is unaffected, since a metaclass
attribute is not on an instance's lookup path.

Also: pickling is registered through `copyreg` because `pickle.Pickler.save` routes anything
whose type subclasses `type` to `save_global` before consulting `__reduce_ex__`, and
`fingerprinting.py` gets a `DimensionMeta` deconstructor keyed on `(tag, kind)` so a dimension
is not fingerprinted by qualified name.

Behaviour change: `repr()` of a dimension is now `I[horizontal]`; `str()` is unchanged, so
error messages are byte-identical.

Design record: ADR 0028, added here. Implements the `shared/dimensions-as-types` proposal
(gt4py_knowledge#27, @havogt) and closes the static-typing gap reported in #2503.

Deliberately not here: a `DimensionBase` root above the user-declarable class, deferred until
the requirements of non-user-declarable dimensions such as `Staggered[D]` are known; ICON4Py
migration, which needs a note for `.tag` and for the removal of `NamedIndex`.
egparedes added a commit that referenced this pull request Sep 2, 2026
…ndices

A concrete dimension is now declared as a class, and an index along it is an instance of that
class -- the shape `enum.Enum` uses, where the class is the collection and the instances are
its members:

```python
class IDim(gtx.DimensionIndex): ...
class KDim(gtx.DimensionIndex, kind=gtx.DimensionKind.VERTICAL): ...

IDim                       # the dimension    -- annotated `gtx.Dimension`
IDim(0)                    # an index into it -- annotated `IDim`
```

so `gtx.Field[gtx.Dims[IDim], gtx.float64]` is a valid annotation for any PEP 484 type checker,
with no gt4py mypy plugin. `DimensionMeta` carries the API that belongs to the dimension itself
(`I + 1`, `I > 5`, `I == 5`, `repr`, equality, hashing); binary operators on a class object
dispatch through the metaclass, so that is the only place they can live.

This drops the dimension half of `mypy_plugin.py`, which substituted at most four distinct
placeholders per run (`_DimA`..`_DimD`, then `_AnyDim` for everything after), made `TypeVar`s
over dimensions impossible, and served only mypy.

Because `IDim(0)` is now ordinary instantiation, the whole apparatus that a foreign return type
required is gone: no `__new__` returning a non-instance, no duplicated overloads on both
`DimensionMeta.__call__` and `__new__`, and no `# type: ignore[misc]` anywhere on the
instantiation path. mypy and pyright agree natively -- pinned by the typing tests in #2845.
Indices also carry their dimension in the type, so mixing them is a static error;
`common.NamedIndex` is deleted.

Naming: the dimension's name is `.tag` (typed `common.Tag`, which already existed), and `.value`
keeps its meaning as the index position. The reverse split does not type-check at all -- an
instance attribute cannot shadow a `ClassVar` -- and this direction leaves every index
expression, downstream included, untouched. `.dim` survives as a property returning `type(self)`.

`common.Dimension` is a PEP 695 alias for `type[DimensionIndex]`, re-exported as
`gtx.Dimension`, and `common.dimension(tag, kind)` is the programmatic constructor for the IR
boundaries that rebuild a dimension from its tag.

The PEP 695 spelling is what makes the deprecated `gtx.Dimension("I")` raise rather than
silently misbehave. A plain `TypeAlias` for `type[X]` is a `types.GenericAlias`, and calling one
forwards to its `__origin__` while discarding the arguments -- so `Dimension("I")` would
evaluate to `type("I")`, i.e. `str`, with no error. Its cost is that `get_origin()` of such an
alias is `None`, so a site dispatching on an annotation's shape must resolve it first; exactly
one in-tree site needed that, `ffront.fbuiltins._type_conversion_helper`, via the
`xtyping.resolve_annotation` helper added in #2841. A `TYPE_CHECKING`-split callable shim was
also tried and rejected: `eve.datamodels` resolves annotations at run time, so a `Dimension`
field would see the shim rather than a type.

Reading `.value` on a dimension *class* would otherwise return the `__slots__` member
descriptor rather than raising, and the nonsense value only surfaces much later as a missing
offset-provider key or an `AxisLiteral` validation failure. A metaclass property makes it a
loud `AttributeError` pointing at `.tag`; instance access is unaffected, since a metaclass
attribute is not on an instance's lookup path.

Also: pickling is registered through `copyreg` because `pickle.Pickler.save` routes anything
whose type subclasses `type` to `save_global` before consulting `__reduce_ex__`, and
`fingerprinting.py` gets a `DimensionMeta` deconstructor keyed on `(tag, kind)` so a dimension
is not fingerprinted by qualified name.

Behaviour change: `repr()` of a dimension is now `I[horizontal]`; `str()` is unchanged, so
error messages are byte-identical.

Design record: ADR 0028, added here. Implements the `shared/dimensions-as-types` proposal
(gt4py_knowledge#27, @havogt) and closes the static-typing gap reported in #2503.

Deliberately not here: a `DimensionBase` root above the user-declarable class, deferred until
the requirements of non-user-declarable dimensions such as `Staggered[D]` are known; ICON4Py
migration, which needs a note for `.tag` and for the removal of `NamedIndex`.
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