diff --git a/docs/development/ADRs/next/README.md b/docs/development/ADRs/next/README.md index 24e42da696..1bf9d21812 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,15 +48,17 @@ 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) - [0027 - External Workspace Memory for DaCe Transients](0027-External_Workspace_Memory.md) ### 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/eve/type_validation.py b/src/gt4py/eve/type_validation.py index 56b0b6a073..e793099973 100644 --- a/src/gt4py/eve/type_validation.py +++ b/src/gt4py/eve/type_validation.py @@ -302,6 +302,49 @@ 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: + # bare `type` / `typing.Type` + return self.make_is_instance_of(name, type) + + # 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__] + + # `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 is_strict_arg(arg.__bound__): + return self.make_is_subclass_of(name, arg.__bound__) + return self.make_is_instance_of(name, type) + + 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(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) + ) + + return self.make_is_instance_of(name, type) + if isinstance(origin_type, type): # Deal with generic collections if issubclass(origin_type, tuple): @@ -406,6 +449,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/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 diff --git a/tests/eve_tests/unit_tests/test_type_validation.py b/tests/eve_tests/unit_tests/test_type_validation.py index ecc29fab78..d0976236fc 100644 --- a/tests/eve_tests/unit_tests/test_type_validation.py +++ b/tests/eve_tests/unit_tests/test_type_validation.py @@ -49,6 +49,23 @@ class SampleDataClass: a: int +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]] ) @@ -75,6 +92,39 @@ 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), + # `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[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), ( frozendict[int, str], ( 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))