Skip to content

feat[eve,next]: runtime PEP 695 type alias support and located annotation diagnostics - #2754

Merged
egparedes merged 20 commits into
mainfrom
pep695-type-aliases
Aug 13, 2026
Merged

feat[eve,next]: runtime PEP 695 type alias support and located annotation diagnostics#2754
egparedes merged 20 commits into
mainfrom
pep695-type-aliases

Conversation

@egparedes

@egparedes egparedes commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

Python 3.12 (PEP 695) type X = ... binds a typing.TypeAliasType rather than
the annotation itself, and nothing unwraps it at runtime: get_type_hints
returns it as is, __value__ is lazy and raises NameError until the names it
mentions exist, and a plain isinstance(x, xtyping.TypeAliasType) misses
native aliases because eve.extended_typing star-imports both typing and
typing_extensions. Now that the floor is 3.12, users write these in datamodel
fields and DSL signatures.

eve. extended_typing exposes is_type_alias() and eval_type_alias(),
and resolution happens at the three annotation-dispatch funnels
(type_validation.SimpleTypeValidatorFactory.__call__,
datamodels.core._make_type_converter, get_represented_types) rather than at
annotation-storage time, so nesting (list[MyAlias]) is covered for free and
the stored annotation keeps the alias name for reprs and signatures.

Failures split on whether the annotation can still become valid. NameError
defers to first instantiation via ForwardRefValidator, or the new
DeferredTypeConverter for coerced fields, exactly as string forward
references already do. Anything else is permanent — an alias value is arbitrary
user code, so a typo'd dtype raises AttributeError — and is wrapped into a
TypeError naming the alias and the cause. Cycles are detected by the set of
visited aliases, with the depth cap (64) kept as the backstop for aliases that
grow on every step (type G[T] = G[Tuple[T]]).

next. from_type_hint resolves aliases, and annotation failures reach
the user as a located InvalidAnnotationError instead of a bare ValueError
or a raw traceback, through a shared type_from_annotation() in
dialect_parser. Two span defects are fixed: StringifyAnnotationsPass left
rebuilt annotations with no location at all, crashing visit_AnnAssign on
every annotated assignment inside a field operator; and both return-annotation
diagnostics pointed at the whole function definition. This reorders one case —
a bad return annotation is now reported before body type-deduction errors.

For reviewers

  • get_represented_types fails silently: an unresolved alias matches none of
    its branches and falls through to its empty-tuple return, which would quietly
    empty the import-time isinstance tuples in next/common.py and
    next/named_collections.py. It had no test coverage before this PR.
  • DSLTypeError loses its __init__. That override was a pass-through with a
    narrower signature than DSLError, so removing it only widens what is
    accepted; it is required for subclasses to pass label / notes / hints.
  • Aliases are supported as annotations. One also used as a runtime value
    (common.Tag, subclassed at iterator/embedded.py:102) cannot be rewritten
    to type X = ... at all, so ruff's UP040 stays off repo-wide. PEP 695
    generics already worked and are unchanged.

Requirements

  • All fixes and/or new features come with corresponding tests.
  • Important design decisions have been documented in the appropriate ADR inside the docs/development/ADRs/ folder.
    • By request, the rationale lives in comments next to the code in
      eve/extended_typing.py instead; the ADRs/eve/ hierarchy added earlier
      on this branch was removed.

'get_represented_types' is a third annotation-dispatch funnel alongside
'type_validation' and 'datamodels', but it was not wired into
'eval_type_alias'. A PEP 695 alias matched none of its branches and fell
through to the empty-tuple return, so the isinstance tuples built from it
('NUMERIC_VALUE_TYPES', 'PRIMITIVE_VALUE_TYPES', 'NAMED_COLLECTION_TYPES')
would silently become empty and every check against them constantly false.

Resolve aliases on entry, which also covers nesting since the generic
branches recurse through the same function. Update the ADR to list all
three funnels and add the first tests for this function.
'StringifyAnnotationsPass' replaced every annotation with a freshly built
'ast.Constant'. The pass runs after 'FixMissingLocations', so the new node
carried no location at all and 'visit_AnnAssign' crashed with a bare
'AssertionError' when asking for it, breaking every annotated assignment
inside a field operator.

Copy the location of the original annotation node, which also makes the
variable-annotation diagnostic point at the annotation instead of the whole
statement. Add the missing diagnostic test for that path and a first
end-to-end test of a PEP 695 alias used as a field operator annotation.
'_make_type_converter' evaluated the alias eagerly, so a coerced field
annotated with an alias whose value is not resolvable yet failed at class
creation, while the validator path two lines away already deferred it. Add
a 'DeferredTypeConverter' mirroring 'ForwardRefValidator' so both paths
follow the deferral contract described in the ADR.

Also declare 'typing_extensions.TypeAliasType' in 'SingleTypeAnnotation',
which was missing even though the runtime helpers accept it; report the
original annotation instead of an internal link in the alias depth-limit
error; and propagate 'globalns'/'localns' through the recursive calls of
'get_represented_types', which were resolving nested forward references
against the wrong scope.

@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.

A couple of questions

Comment thread src/gt4py/next/errors/exceptions.py
Comment thread src/gt4py/eve/extended_typing.py Outdated
The value of a PEP 695 alias is arbitrary user code evaluated on first
access, so it can fail in any way, but only 'NameError' was handled. A
typo'd dtype ('type F = Field[Dims[I], np.foat64]') raised 'AttributeError'
straight through 'eval_type_alias', '_resolve_type_alias' and
'type_from_annotation' to the user, with no source location and no
diagnostic.

Wrap every non-'NameError' failure of the alias value into a 'TypeError'
naming the alias and the cause. 'NameError' stays unwrapped because it is
the deferral signal: it means the name is not defined _yet_, while any
other failure will never resolve and so must be reported where it is found
instead of at a later instantiation. Consumers pass the message through
verbatim rather than re-wording it, as it is the only text naming the
actual cause; on the frontend it now reaches the user as an
'InvalidAnnotationError' with the carets under the annotation.
A caret-run regex without a trailing lookahead ('\^{13}') also matches any
longer run, so it matched the 63-caret whole-function span it was meant to
rule out and pinned nothing. Assert on the 'SourceLocation' columns, which
is renderer-independent, and add the lookahead.

Doing so showed the parameter diagnostic covers 'a: BrokenFieldAlias', not
the annotation alone: 'visit_arg' locates the whole 'ast.arg'. Fix the
comment to describe what the code actually does.
Both return-annotation diagnostics used the location of the whole function
definition, so a bad annotation underlined the entire signature and body
instead of the offending span, against rule 2 of the error-message guide.

Validate the annotation in 'visit_FunctionDef', where the 'ast' node it was
written at is still available. The check stays split rather than moved,
because '_postprocess_dialect_ast' compares the annotated type against the
deduced one, which does not exist until the body has been typed. Only the
location is taken from the ast; the value still comes from 'annotations',
so the stringified annotation is never re-evaluated.

Point the annotated-vs-deduced mismatch at the returned expression, which
is where the deduced type comes from, falling back to the function when
there is more than one return statement.

Note that a bad return annotation is now reported before body type
deduction errors, which is the opposite of the previous order.
The depth bound alone could not tell a cycle from a legitimately deep
chain, so 'type A = A' and a mutual 'A -> B -> A' were both reported as
"recursive or nested too deeply".

Keep the set of aliases already walked through: a cycle repeats one, so it
is detected as soon as an alias is seen twice, however long the cycle is.
The bound stays as the backstop it has to be, since a parametrized alias
which grows on every step ('type G[T] = G[Tuple[T]]') builds a bigger
annotation each time and never repeats one.

Note that tracking 'id()' instead of the objects would be unsound here:
the intermediate annotations are temporaries, and a freed id gets reused,
which reports a cycle where there is none.
Keeping the rationale in 'docs/development/ADRs/eve/' put it a directory
tree away from the three functions it constrains, where a reader touching
'eval_type_alias' would not find it.

Move the parts that are not evident from the code into comments next to
what they explain: why aliases are resolved at the dispatch funnels rather
than by rewriting stored annotations, that a funnel left out fails silently
by returning an empty tuple, why both alias classes have to be checked and
why 'hasattr(obj, "__value__")' is not equivalent, why 'NameError' is a
deferral signal while every other failure is permanent, and the two known
limits ('ClassVar' behind an alias, and aliases used as runtime values,
which is what keeps ruff's 'UP040' disabled).

Drop the rest: the context section restates PEP 695, and the rejected
alternatives are not needed to work on the code.

This removes the whole 'ADRs/eve' hierarchy, which was added by this same
branch and holds no other decision. Nothing links to it.
Comment thread src/gt4py/eve/extended_typing.py Outdated
Halve the folded-in rationale (46 lines out, 23 in), keeping only what is
not evident from the code: resolution at the dispatch funnels and why not
eagerly, that a missed funnel fails silently, the 'hasattr' trap, the
'NameError'-defers / everything-else-fails split, and the two unsupported
cases.
@egparedes egparedes changed the title feat[eve, next]: support PEP 695 type aliases in runtime annotation introspection feat[eve,next]: runtime PEP 695 type alias support and located annotation diagnostics Aug 10, 2026
@egparedes
egparedes requested review from havogt and romanc and removed request for romanc August 10, 2026 13:07
Comment thread src/gt4py/eve/extended_typing.py
Comment thread src/gt4py/eve/type_validation.py Outdated
Comment thread src/gt4py/next/errors/exceptions.py
Comment thread src/gt4py/next/ffront/func_to_foast.py Outdated
Comment thread src/gt4py/next/ffront/func_to_foast.py Outdated
Comment thread src/gt4py/next/ffront/func_to_foast.py
Three points from @havogt's review:

- The deferral rationale was repeated, with small variations, at four call
  sites. Keep it once in 'eval_type_alias', where the signal originates, and
  leave one-line pointers at the sites that react to it.

- The return-annotation check in 'visit_FunctionDef' discarded the result of
  'type_from_annotation', which read as a mistake. Name it for what it does,
  '_reject_invalid_return_annotation', and explain in its docstring why the
  translation happens twice: '_postprocess_dialect_ast' needs the value to
  compare against the deduced type, but can no longer report a failure, since
  by then the ast is gone and it could only point at the whole function.

- '_returned_value_location' used neither 'cls' nor 'self'. Make it a module
  level helper, as 'dialect_parser' does with '_describe_unsupported_feature'.

No behavior change; error messages, spans and tests are untouched.
The lockfile update on main (#2746) moved ruff to 0.16.1, which flags
'implicit-string-concatenation-in-collection-literal' on the hint tuple in
'InvalidAnnotationError'. Wrap the two fragments, as the rule suggests.

Found only in CI because 'pull_request' runs check the merge with the
current base, so they were already using the new ruff while this branch was
still on 0.15.7.

@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

@egparedes
egparedes merged commit beeb498 into main Aug 13, 2026
30 checks passed
egparedes added a commit that referenced this pull request Aug 13, 2026
Three files conflicted, all against #2772 and #2754.

'gtscript_frontend.py': #2772 removed the same deprecated 'ast' usage this
branch fixes, but replaced the ellipsis check with
'isinstance(cn, types.EllipsisType)', which never matches — that is the type
of the '...' object, not of its AST node ('ast.Constant' with an 'Ellipsis'
value). Kept this branch's '_is_ellipsis_node' helper, which does match, and
took #2772's better 'visit_Expr': it drops only *string* constant statements
rather than every constant, matching the documented intent.

The two cartesian test hunks were pure additions on both sides (the ellipsis
tests here, the IntEnum tests from #2323) and are both kept.

'test_diagnostic_messages.py': #2754 added PEP 695 alias tests; this branch
deleted 'test_add_note_folded_into_str_on_py310', whose
'skipif(version_info >= (3, 11))' means it runs on no supported version. Both
intents are preserved, which leaves 'import sys' unused — dropped.

#2754 also added test cases spelled with the deprecated 'typing' aliases this
branch removes ('List[SampleIntAlias]', 'type SampleGenericAlias[T] =
Tuple[T, T]', ...). Those are migrated to the builtin spelling, which git
could not do since the two changes touch different lines.
egparedes added a commit that referenced this pull request Aug 13, 2026
'_is_strictly_immutable_type' is a fourth annotation-dispatch funnel, added on
this branch after #2754 landed runtime PEP 695 support, so it did not learn to
resolve aliases. A 'type MyPair = tuple[int, int]' field was rejected while the
identical plain annotation was accepted — conservative, but it makes
'frozen="strict"' unusable with the alias feature that just landed, and an
alias hiding a mutable type was only rejected by accident.

Resolve the alias first and check what it stands for, so parametrized aliases
('PairAlias[int]') are decomposed like any other annotation. An alias whose
value cannot be evaluated — undefined name, recursive, not parametrizable —
proves nothing about immutability and is rejected like any other unresolved
annotation, rather than escaping as the raw 'NameError' / 'TypeError' from
'eval_type_alias'.

The new tests define their aliases at module level: this file uses PEP 563, so
a function-local alias would only ever be seen as an unresolvable forward
reference and the tests would pass for the wrong reason.
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