From 6791489a9a52c1fe41e04e35ff1b28f83f26b05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 27 Aug 2026 14:49:46 +0200 Subject: [PATCH 1/4] fix[eve]: validate 'type[X]' annotations by subclass, not by 'is a class' 'type[X]' fell through to the custom-generic-type branch, which validates only 'isinstance(value, type)'. A DataModel field annotated 'type[Foo]' therefore accepted any class at all, including 'int'. --- src/gt4py/eve/type_validation.py | 28 +++++++++++++++++++ .../unit_tests/test_type_validation.py | 3 ++ 2 files changed, 31 insertions(+) diff --git a/src/gt4py/eve/type_validation.py b/src/gt4py/eve/type_validation.py index 56b0b6a073..eaa52177c3 100644 --- a/src/gt4py/eve/type_validation.py +++ b/src/gt4py/eve/type_validation.py @@ -302,6 +302,22 @@ def __call__( ) return self.combine_optional(name, validator) if has_none else validator + if origin_type is type: + # `type[X]`. Without this case the annotation falls through to the + # generic-collection branch below and degrades to `isinstance(value, type)`, + # i.e. "is any class at all". + if len(type_args) != 1: + raise exceptions.EveValueError( + f"{type_annotation} type annotation is not supported." + ) + if xtyping.is_Any(type_args[0]) or isinstance(type_args[0], typing.TypeVar): + return self.make_is_instance_of(name, type) + if not xtyping.is_actual_type(type_args[0]): + raise exceptions.EveValueError( + f"{type_annotation} type annotation is not supported." + ) + return self.make_is_subclass_of(name, type_args[0]) + if isinstance(origin_type, type): # Deal with generic collections if issubclass(origin_type, tuple): @@ -406,6 +422,18 @@ def _is_instance_of(value: Any, **kwargs: Any) -> None: return _is_instance_of + @staticmethod + def make_is_subclass_of(name: str, type_: type) -> FixedTypeValidator: + """Create a ``FixedTypeValidator`` validator for ``type[type_]`` annotations.""" + + def _is_subclass_of(value: Any, **kwargs: Any) -> None: + if not (isinstance(value, type) and issubclass(value, type_)): + raise TypeError( + f"'{name}' must be a subclass of {type_} (got '{value}' which is a {type(value)})." + ) + + return _is_subclass_of + @staticmethod def make_is_instance_of_int(name: str) -> FixedTypeValidator: """Create an ``FixedTypeValidator`` validator for ``int`` values which fails with ``bool`` values.""" diff --git a/tests/eve_tests/unit_tests/test_type_validation.py b/tests/eve_tests/unit_tests/test_type_validation.py index ecc29fab78..ca37483b14 100644 --- a/tests/eve_tests/unit_tests/test_type_validation.py +++ b/tests/eve_tests/unit_tests/test_type_validation.py @@ -75,6 +75,9 @@ class SampleDataClass: (typing.List[int], ([1, 2, 3], []), (1, [1.0]), None, None), (typing.Set[int], ({1, 2, 3}, set()), (1, [1], (1,), {1: None}), None, None), (typing.Dict[int, str], ({}, {3: "three"}), ([(3, "three")], 3, "three", []), None, None), + (type[SampleEmptyClass], [SampleEmptyClass], [SampleEmptyClass(), int, 3], None, None), + (type[SampleDataClass], [SampleDataClass], [SampleDataClass(a=1), str], None, None), + (type[Any], [SampleEmptyClass, int, str], [3, "int", SampleEmptyClass()], None, None), ( frozendict[int, str], ( From 32ec0a61b33354ce976c3a97cb5be9664e176bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 27 Aug 2026 14:51:19 +0200 Subject: [PATCH 2/4] docs: fix ADR index links and a stale ADR reference The next ADR index pointed at two files that do not exist and omitted eight ADRs entirely. 'connectivity_for_cartesian_shift' cited ADR 0024 (Compilation Runners) for the staggered-index convention, which is ADR 0026. Also records the mypy plugin's undocumented '*Dim' naming requirement. --- docs/development/ADRs/next/README.md | 10 ++++++++-- src/gt4py/next/common.py | 2 +- src/gt4py/next/type_system/mypy_plugin.py | 5 +++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/development/ADRs/next/README.md b/docs/development/ADRs/next/README.md index 24e42da696..1148dcf011 100644 --- a/docs/development/ADRs/next/README.md +++ b/docs/development/ADRs/next/README.md @@ -17,8 +17,11 @@ Writing a new ADR is simple: ### General architecture #general - [0005 - Extending Iterator IR](0005-Extending_Iterator_IR.md) +- [0019 - Connectivities](0019-Connectivities.md) +- [0020 - Runtime domains](0020-Runtime-domains.md) +- [0021 - Argument Descriptors](0021-Argument-Descriptors.md) - [0023 - Fingerprinting](0023-Fingerprinting.md) -- [0026 - Staggered Dimensions](0024-Staggered_Dimensions.md) +- [0026 - Staggered Dimensions](0026-Staggered_Dimensions.md) ### Frontend and Parsing #frontend @@ -45,6 +48,7 @@ Writing a new ADR is simple: - [0006 - C++ Backend](0006-Cpp-Backend.md) - [0007 - Fencil Processors](0007-Fencil-Processors.md) - [0008 - Mapping Domain to Cpp Backend](0008-Mapping_Domain_to_Cpp-Backend.md) +- [0014 - DaCe backend](0014-DaCe_backend.md) - [0016 - Multiple Backends and Build Systems](0016-Multiple-Backends-and-Build-Systems.md) - [0017 - Toolchain Configuration](0017-Toolchain-Configuration.md) - [0018 - Canonical Form of an SDFG in GT4Py (Especially for Optimizations)](0018-Canonical_SDFG_in_GT4Py_Transformations.md) @@ -53,7 +57,9 @@ Writing a new ADR is simple: ### Python Integration - [0011 - On The Fly Compilation](0011-On_The_Fly_Compilation.md) -- [0012 - GridTools C++ OTF](0011-_GridTools_Cpp_OTF.md) +- [0012 - GridTools C++ OTF Steps](0012-GridTools_Cpp_OTF_Steps.md) +- [0024 - Compilation Runners](0024-Compilation-Runners.md) +- [0025 - Crash Consistent Build Caches](0025-Crash_Consistent_Build_Caches.md) ### Testing diff --git a/src/gt4py/next/common.py b/src/gt4py/next/common.py index 741cbeda33..dd85aba445 100644 --- a/src/gt4py/next/common.py +++ b/src/gt4py/next/common.py @@ -1473,7 +1473,7 @@ def connectivity_for_cartesian_shift(dim: Dimension, offset: int | float) -> Car `flip_staggered(dim)`). The half-integer case encodes the convention that a staggered index sits half a cell *below* - its base index (see ADR 0024): `IHalf(0)` is the edge below `I(0)`. Because of this asymmetry, + its base index (see ADR 0026): `IHalf(0)` is the edge below `I(0)`. Because of this asymmetry, shifting out of a non-staggered dimension needs a `+1` index correction that shifting out of a staggered dimension does not, e.g. `I + 0.5` maps `I(i)` to `IHalf(i+1)` (position `i+½`) while `IHalf + 0.5` maps `IHalf(i)` to `I(i)`. diff --git a/src/gt4py/next/type_system/mypy_plugin.py b/src/gt4py/next/type_system/mypy_plugin.py index 1c76e631bf..c3af362960 100644 --- a/src/gt4py/next/type_system/mypy_plugin.py +++ b/src/gt4py/next/type_system/mypy_plugin.py @@ -34,6 +34,11 @@ The documentation for how to write tests in that format is at https://github.com/typeddjango/pytest-mypy-plugins. The documentation on mypy plugins is at https://mypy.readthedocs.io/en/latest/extending_mypy.html + +Known limitation: the fallback hook that rewrites stray 'Dimension' instances in type aliases +matches on the *variable name* ending in 'Dim' ('fullname.endswith("Dim")'). A dimension bound to +a name that does not end in 'Dim' -- e.g. 'I = gtx.Dimension("I")' -- silently gets no plugin +support at all. """ from __future__ import annotations From 93f8d87d518b242ed6aa7baaeb8bea1f395f7507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 28 Aug 2026 15:15:43 +0200 Subject: [PATCH 3/4] fix[eve]: never reject a 'type[X]' shape that used to validate Review follow-up on the new 'type[X]' case. It raised 'EveValueError' at class-creation time for three shapes that validated fine before it existed: - 'type[A | B]' -- one arg, but a 'UnionType', so not an "actual type" - bare 'typing.Type' / 'type' -- no args at all - 'type[SomeAlias]' with a PEP 695 'type X = ...' -- the argument was never resolved, because alias resolution runs once at the top of the factory for the *whole* annotation and does not reach inside 'type[...]' None of them is used by an in-repo field, so the suite stayed green, but each was an import-time break for downstream datamodels. Unrecognized shapes now fall back to the loose 'isinstance(value, type)' check this branch replaced, instead of raising. Unions of classes validate as an OR of subclass checks, and a nested PEP 695 alias is resolved first. Also honours the bound on 'type[T]', mirroring the plain-TypeVar branch above it: previously every TypeVar collapsed to "is any class", so a field annotated 'type[T]' with 'T' bound to 'Dimension' accepted 'int'. --- src/gt4py/eve/type_validation.py | 43 +++++++++++++++---- .../unit_tests/test_type_validation.py | 22 ++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/gt4py/eve/type_validation.py b/src/gt4py/eve/type_validation.py index eaa52177c3..9ea48ee07a 100644 --- a/src/gt4py/eve/type_validation.py +++ b/src/gt4py/eve/type_validation.py @@ -306,17 +306,42 @@ def __call__( # `type[X]`. Without this case the annotation falls through to the # generic-collection branch below and degrades to `isinstance(value, type)`, # i.e. "is any class at all". + # + # Every shape that is not recognized here falls back to that same loose + # check rather than raising: `type[X]` annotations validated (loosely) + # before this branch existed, so refusing one now would turn a working + # downstream datamodel into an error at class-creation time. if len(type_args) != 1: - raise exceptions.EveValueError( - f"{type_annotation} type annotation is not supported." - ) - if xtyping.is_Any(type_args[0]) or isinstance(type_args[0], typing.TypeVar): + # bare `type` / `typing.Type` return self.make_is_instance_of(name, type) - if not xtyping.is_actual_type(type_args[0]): - raise exceptions.EveValueError( - f"{type_annotation} type annotation is not supported." - ) - return self.make_is_subclass_of(name, type_args[0]) + + # A PEP 695 alias nested inside `type[...]` is not resolved by the + # whole-annotation pass at the top of this function, so resolve it here. + try: + arg = xtyping.eval_type_alias(type_args[0]) + except TypeError: + return self.make_is_instance_of(name, type) + + if isinstance(arg, types.UnionType): # `type[A | B]` + arg = typing.Union[arg.__args__] + + if isinstance(arg, typing.TypeVar): + # Mirror the plain-`TypeVar` branch above, which honours the bound. + if xtyping.is_actual_type(arg.__bound__): + return self.make_is_subclass_of(name, arg.__bound__) + return self.make_is_instance_of(name, type) + + if xtyping.is_actual_type(arg): + return self.make_is_subclass_of(name, arg) + + if xtyping.get_origin(arg) is Union: # `type[A | B]`, `type[Union[A, B]]` + members = xtyping.get_args(arg) + if members and all(xtyping.is_actual_type(m) for m in members): + return self.combine_validators_as_or( + name, *(self.make_is_subclass_of(name, m) for m in members) + ) + + return self.make_is_instance_of(name, type) if isinstance(origin_type, type): # Deal with generic collections diff --git a/tests/eve_tests/unit_tests/test_type_validation.py b/tests/eve_tests/unit_tests/test_type_validation.py index ca37483b14..81d27ad79d 100644 --- a/tests/eve_tests/unit_tests/test_type_validation.py +++ b/tests/eve_tests/unit_tests/test_type_validation.py @@ -49,6 +49,12 @@ class SampleDataClass: a: int +type SampleTypeAlias = SampleEmptyClass # PEP 695, nested inside `type[...]` + +SampleBoundTypeVar = typing.TypeVar("SampleBoundTypeVar", bound=SampleEmptyClass) +SampleUnboundTypeVar = typing.TypeVar("SampleUnboundTypeVar") + + # Each item should be a tuple like: # ( annotation: Any, valid_values: Sequence, wrong_values: Sequence, # globalns: Optional[Dict[str, Any]], localns: Optional[Dict[str, Any]] ) @@ -78,6 +84,22 @@ class SampleDataClass: (type[SampleEmptyClass], [SampleEmptyClass], [SampleEmptyClass(), int, 3], None, None), (type[SampleDataClass], [SampleDataClass], [SampleDataClass(a=1), str], None, None), (type[Any], [SampleEmptyClass, int, str], [3, "int", SampleEmptyClass()], None, None), + # `type[X]` shapes that are not a plain class must stay *usable*: they validated + # loosely before the `type[X]` case existed, so they fall back to "is a class" + # rather than being rejected at class-creation time. + (type, [SampleEmptyClass, int], [3, "int"], None, None), + (typing.Type, [SampleEmptyClass, int], [3, "int"], None, None), + ( + type[SampleEmptyClass | SampleDataClass], + [SampleEmptyClass, SampleDataClass], + [int, SampleEmptyClass(), 3], + None, + None, + ), + (type[SampleTypeAlias], [SampleEmptyClass], [int, SampleEmptyClass(), 3], None, None), + # a bounded TypeVar is honoured, mirroring the plain-TypeVar branch + (type[SampleBoundTypeVar], [SampleEmptyClass], [int, SampleEmptyClass()], None, None), + (type[SampleUnboundTypeVar], [SampleEmptyClass, int], [3, "int"], None, None), ( frozendict[int, str], ( From 99e76efa0fd5e58b7697f5afa9200b15cc404789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 28 Aug 2026 17:06:51 +0200 Subject: [PATCH 4/4] fix[eve]: keep 'type[Protocol]' annotations usable, and close review gaps Addresses the review feedback on the 'type[X]' validation branch: - Drop the second paragraph of the explanatory comment in the 'origin_type is type' branch, as requested. - Fall back to the loose "is a class" check when the argument of 'type[...]' is a protocol class. 'issubclass()' rejects protocols which are not '@runtime_checkable', and also '@runtime_checkable' protocols with non-method members, so the strict path used to raise 'TypeError' for *every* value of such a field. The check happens in the branch itself, next to the other strict-vs-loose decisions, rather than inside 'make_is_subclass_of'. - Test the 'typing.Union[A, B]' spelling of 'type[A | B]' (a different runtime object handled by a different code path than 'A | B') and the 'except TypeError' fallback around 'eval_type_alias', which an unresolvable (recursive) PEP 695 alias reaches. - Add a datamodel-level test for 'ts.DeferredType.constraint', the only field in the whole tree whose annotation contains a 'type[...]' and hence the only observable behaviour change of this PR. It lives under 'tests/next_tests' because 'tests/eve_tests' must not depend on 'gt4py.next'. - Remove the duplicate ADR 0018 entry from the 'gt4py.next' ADR index, which listed it both under "Transformations" and under "Backends and Code Generation". --- docs/development/ADRs/next/README.md | 1 - src/gt4py/eve/type_validation.py | 18 +++++---- .../unit_tests/test_type_validation.py | 25 ++++++++++++ .../test_type_specifications.py | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 tests/next_tests/unit_tests/type_system_tests/test_type_specifications.py diff --git a/docs/development/ADRs/next/README.md b/docs/development/ADRs/next/README.md index 1148dcf011..1bf9d21812 100644 --- a/docs/development/ADRs/next/README.md +++ b/docs/development/ADRs/next/README.md @@ -51,7 +51,6 @@ Writing a new ADR is simple: - [0014 - DaCe backend](0014-DaCe_backend.md) - [0016 - Multiple Backends and Build Systems](0016-Multiple-Backends-and-Build-Systems.md) - [0017 - Toolchain Configuration](0017-Toolchain-Configuration.md) -- [0018 - Canonical Form of an SDFG in GT4Py (Especially for Optimizations)](0018-Canonical_SDFG_in_GT4Py_Transformations.md) - [0027 - External Workspace Memory for DaCe Transients](0027-External_Workspace_Memory.md) ### Python Integration diff --git a/src/gt4py/eve/type_validation.py b/src/gt4py/eve/type_validation.py index 9ea48ee07a..e793099973 100644 --- a/src/gt4py/eve/type_validation.py +++ b/src/gt4py/eve/type_validation.py @@ -306,11 +306,6 @@ def __call__( # `type[X]`. Without this case the annotation falls through to the # generic-collection branch below and degrades to `isinstance(value, type)`, # i.e. "is any class at all". - # - # Every shape that is not recognized here falls back to that same loose - # check rather than raising: `type[X]` annotations validated (loosely) - # before this branch existed, so refusing one now would turn a working - # downstream datamodel into an error at class-creation time. if len(type_args) != 1: # bare `type` / `typing.Type` return self.make_is_instance_of(name, type) @@ -325,18 +320,25 @@ def __call__( if isinstance(arg, types.UnionType): # `type[A | B]` arg = typing.Union[arg.__args__] + # `issubclass()` is not generally usable with protocol classes: it is + # rejected outright unless the protocol is `@runtime_checkable`, and also + # for `@runtime_checkable` protocols with non-method members. Protocols + # therefore keep the loose check, like any other unsupported shape. + def is_strict_arg(a: Any) -> xtyping.TypeGuard[type]: + return xtyping.is_actual_type(a) and not xtyping.is_protocol(a) + if isinstance(arg, typing.TypeVar): # Mirror the plain-`TypeVar` branch above, which honours the bound. - if xtyping.is_actual_type(arg.__bound__): + if is_strict_arg(arg.__bound__): return self.make_is_subclass_of(name, arg.__bound__) return self.make_is_instance_of(name, type) - if xtyping.is_actual_type(arg): + if is_strict_arg(arg): return self.make_is_subclass_of(name, arg) if xtyping.get_origin(arg) is Union: # `type[A | B]`, `type[Union[A, B]]` members = xtyping.get_args(arg) - if members and all(xtyping.is_actual_type(m) for m in members): + if members and all(is_strict_arg(m) for m in members): return self.combine_validators_as_or( name, *(self.make_is_subclass_of(name, m) for m in members) ) diff --git a/tests/eve_tests/unit_tests/test_type_validation.py b/tests/eve_tests/unit_tests/test_type_validation.py index 81d27ad79d..d0976236fc 100644 --- a/tests/eve_tests/unit_tests/test_type_validation.py +++ b/tests/eve_tests/unit_tests/test_type_validation.py @@ -51,10 +51,21 @@ class SampleDataClass: type SampleTypeAlias = SampleEmptyClass # PEP 695, nested inside `type[...]` +type SampleRecursiveTypeAlias = SampleRecursiveTypeAlias # cannot be resolved + SampleBoundTypeVar = typing.TypeVar("SampleBoundTypeVar", bound=SampleEmptyClass) SampleUnboundTypeVar = typing.TypeVar("SampleUnboundTypeVar") +class SampleProtocol(typing.Protocol): + def method(self) -> None: ... + + +@typing.runtime_checkable +class SampleRuntimeCheckableProtocol(typing.Protocol): + attribute: int + + # Each item should be a tuple like: # ( annotation: Any, valid_values: Sequence, wrong_values: Sequence, # globalns: Optional[Dict[str, Any]], localns: Optional[Dict[str, Any]] ) @@ -96,7 +107,21 @@ class SampleDataClass: None, None, ), + ( + type[typing.Union[SampleEmptyClass, SampleDataClass]], + [SampleEmptyClass, SampleDataClass], + [int, SampleEmptyClass(), 3], + None, + None, + ), (type[SampleTypeAlias], [SampleEmptyClass], [int, SampleEmptyClass(), 3], None, None), + # An alias which cannot be resolved keeps the loose check instead of propagating + # the resolution error out of the class definition. + (type[SampleRecursiveTypeAlias], [SampleEmptyClass, int], [3, "int"], None, None), + # Protocols do not support 'issubclass()' in general, so they keep the loose check + # too, independently of whether they are '@runtime_checkable'. + (type[SampleProtocol], [SampleEmptyClass, int], [3, "int"], None, None), + (type[SampleRuntimeCheckableProtocol], [SampleEmptyClass, int], [3, "int"], None, None), # a bounded TypeVar is honoured, mirroring the plain-TypeVar branch (type[SampleBoundTypeVar], [SampleEmptyClass], [int, SampleEmptyClass()], None, None), (type[SampleUnboundTypeVar], [SampleEmptyClass, int], [3, "int"], None, None), diff --git a/tests/next_tests/unit_tests/type_system_tests/test_type_specifications.py b/tests/next_tests/unit_tests/type_system_tests/test_type_specifications.py new file mode 100644 index 0000000000..145d1c4179 --- /dev/null +++ b/tests/next_tests/unit_tests/type_system_tests/test_type_specifications.py @@ -0,0 +1,38 @@ +# GT4Py - GridTools Framework +# +# Copyright (c) 2014-2024, ETH Zurich +# All rights reserved. +# +# Please, refer to the LICENSE file in the root directory. +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + +from gt4py.next.type_system import type_specifications as ts + + +@pytest.mark.parametrize( + "constraint", + [ + None, + ts.ScalarType, + ts.TypeSpec, + (ts.ScalarType,), + (ts.ScalarType, ts.FieldType), + ], +) +def test_deferred_type_accepts_type_spec_constraints(constraint): + assert ts.DeferredType(constraint=constraint).constraint is constraint + + +@pytest.mark.parametrize("constraint", [int, ts.ScalarType(kind=ts.ScalarKind.INT32)]) +def test_deferred_type_rejects_non_type_spec_constraint(constraint): + # 'constraint' is annotated as 'type[TypeSpec] | tuple[type[TypeSpec], ...] | None', + # which is checked with 'issubclass()' and not just with "is a class at all". + with pytest.raises(TypeError, match="constraint"): + ts.DeferredType(constraint=constraint) + + +def test_deferred_type_rejects_non_type_spec_constraint_in_tuple(): + with pytest.raises(TypeError, match="constraint"): + ts.DeferredType(constraint=(ts.ScalarType, int))