From 6552b5d1409b87b7cfbaeda586e9c2209fd2a7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 01:05:41 +0200 Subject: [PATCH 01/24] fix[cartesian]: detect '...' indices without 'ast.Ellipsis' 'ELLIPSIS_TYPE = getattr(ast, "Ellipsis", types.EllipsisType)' resolved differently across supported Python versions. On 3.12/3.13 it returned the deprecated 'ast.Ellipsis' (emitting a DeprecationWarning on every import); on 3.14, where that alias is removed, it fell back to 'types.EllipsisType', which is the type of the '...' object rather than of its AST node, so the 'isinstance()' check would silently never match and '_eval_index' would stop recognizing '...' indices. Match 'ast.Constant' with an 'Ellipsis' value instead, which behaves the same on every supported version. --- .../cartesian/frontend/gtscript_frontend.py | 13 ++++++++++-- .../frontend_tests/test_gtscript_frontend.py | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/gt4py/cartesian/frontend/gtscript_frontend.py b/src/gt4py/cartesian/frontend/gtscript_frontend.py index ebd8263432..60297492a4 100644 --- a/src/gt4py/cartesian/frontend/gtscript_frontend.py +++ b/src/gt4py/cartesian/frontend/gtscript_frontend.py @@ -49,7 +49,16 @@ PYTHON_AST_VERSION: Final = (3, 12) -ELLIPSIS_TYPE = getattr(ast, "Ellipsis", types.EllipsisType) + + +def _is_ellipsis_node(node: ast.AST) -> bool: + """Check whether an AST node is the '...' literal. + + 'ast.Ellipsis' is a deprecated alias scheduled for removal in Python 3.14, and + 'types.EllipsisType' is the type of the '...' object itself, not of its AST node, + so neither is usable as an 'isinstance()' target here. + """ + return isinstance(node, ast.Constant) and node.value is Ellipsis class AssertionChecker(ast.NodeTransformer): @@ -1359,7 +1368,7 @@ def _eval_index( if any(isinstance(cn, ast.Slice) for cn in index_nodes): raise GTScriptSyntaxError(message="Invalid target in assignment.", loc=node) - if any(isinstance(cn, ELLIPSIS_TYPE) for cn in index_nodes): + if any(_is_ellipsis_node(cn) for cn in index_nodes): return None # Determine if we are using the new-style axis syntax, or the old style. diff --git a/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py b/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py index 98746cf547..fe591dcbba 100644 --- a/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py +++ b/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py @@ -2480,3 +2480,24 @@ def stencil(in_field: gtscript.Field[float], out_field: gtscript.Field[float]): name=inspect.stack()[0][3], module=self.__class__.__name__, ) + + +class TestEllipsisNodeDetection: + # 'ast.Ellipsis' is removed in Python 3.14 and 'types.EllipsisType' is the type of + # the '...' object rather than of its AST node, so this must not go back to being + # an 'isinstance()' check against either of them. + @pytest.mark.parametrize( + "source, expected", [("...", True), ("1", False), ("None", False), ("x", False)] + ) + def test_is_ellipsis_node(self, source, expected): + import ast + + node = ast.parse(source, mode="eval").body + assert gt_frontend._is_ellipsis_node(node) is expected + + def test_ellipsis_index_parses(self): + def stencil(field_a: gtscript.Field[np.float64], field_b: gtscript.Field[np.float64]): + with computation(PARALLEL), interval(...): # noqa: F821 [undefined-name] + field_b[...] = field_a + + parse_definition(stencil, name=inspect.stack()[0][3], module=self.__class__.__name__) From 2a350058dc319245442ed4d0e6097eb4214951e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 00:51:23 +0200 Subject: [PATCH 02/24] fix[next]: accept 'types.UnionType' in '_type_conversion_helper' 'Union[A, B]' and 'A | B' are different runtime objects: the PEP 604 form builds a 'types.UnionType', which carries no '__origin__' at all, so the '__origin__ is Union' branch never matched it and control fell through to 'raise AssertionError("Illegal type encountered.")'. The same applied to the builtin 'tuple' spelling, which 't is Tuple' missed. Dispatch on 'get_origin()' instead, which normalizes both spellings, and accept 'types.UnionType' alongside 'typing.Union' the way 'eve' already does. --- src/gt4py/next/ffront/fbuiltins.py | 11 +++++++--- .../unit_tests/ffront_tests/test_fbuiltins.py | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/gt4py/next/ffront/fbuiltins.py b/src/gt4py/next/ffront/fbuiltins.py index 37ddf9183a..12fb192999 100644 --- a/src/gt4py/next/ffront/fbuiltins.py +++ b/src/gt4py/next/ffront/fbuiltins.py @@ -12,6 +12,7 @@ import math import operator from builtins import bool, float, int, tuple # noqa: A004 shadowing a Python built-in +from types import UnionType from typing import ( Any, Callable, @@ -23,6 +24,8 @@ TypeVar, Union, cast, + get_args, + get_origin, overload, ) @@ -138,10 +141,12 @@ def _type_conversion_helper(t: type) -> type[ts.TypeSpec] | tuple[type[ts.TypeSp return ( ts.ConstructorType ) # our type of type is currently represented by the type constructor function - elif t is Tuple or (hasattr(t, "__origin__") and t.__origin__ is tuple): + elif t is tuple or get_origin(t) is tuple: return ts.TupleType - elif hasattr(t, "__origin__") and t.__origin__ is Union: - types = [_type_conversion_helper(e) for e in t.__args__] # type: ignore[attr-defined] + # 'Union[A, B]' and 'A | B' are different runtime objects: the latter is a + # 'types.UnionType', which carries no '__origin__' at all. + elif get_origin(t) in (Union, UnionType): + types = [_type_conversion_helper(e) for e in get_args(t)] assert all(type(t) is type and issubclass(t, ts.TypeSpec) for t in types) return cast(tuple[type[ts.TypeSpec], ...], tuple(types)) # `cast` to break the recursion elif t in named_collections.CUSTOM_NAMED_COLLECTION_TYPES: diff --git a/tests/next_tests/unit_tests/ffront_tests/test_fbuiltins.py b/tests/next_tests/unit_tests/ffront_tests/test_fbuiltins.py index 87787195ef..907703ba92 100644 --- a/tests/next_tests/unit_tests/ffront_tests/test_fbuiltins.py +++ b/tests/next_tests/unit_tests/ffront_tests/test_fbuiltins.py @@ -6,16 +6,37 @@ # Please, refer to the LICENSE file in the root directory. # SPDX-License-Identifier: BSD-3-Clause +import typing + import numpy as np import pytest +from gt4py.next import common from gt4py.next.ffront import fbuiltins +from gt4py.next.type_system import type_specifications as ts # values inside the domain of every unary math builtin (0.5 is invalid for `arccosh`) _SAFE_INPUT = {"arccosh": 2.0} +@pytest.mark.parametrize("tuple_spelling", [typing.Tuple, tuple, typing.Tuple[int], tuple[int]]) +def test_type_conversion_helper_accepts_both_tuple_spellings(tuple_spelling): + assert fbuiltins._type_conversion_helper(tuple_spelling) is ts.TupleType + + +@pytest.mark.parametrize( + "union", + [ + typing.Union[common.Field, typing.Tuple], + # PEP 604 builds a 'types.UnionType', which has no '__origin__' at all + common.Field | tuple, + ], +) +def test_type_conversion_helper_accepts_both_union_spellings(union): + assert fbuiltins._type_conversion_helper(union) == (ts.FieldType, ts.TupleType) + + @pytest.mark.parametrize("dtype", [np.float32, np.float64]) @pytest.mark.parametrize( "name", fbuiltins.UNARY_MATH_NUMBER_BUILTIN_NAMES + fbuiltins.UNARY_MATH_FP_BUILTIN_NAMES From 5ee8d91b6c7aac998ac1a8895ee7266537c0ad15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 00:55:22 +0200 Subject: [PATCH 03/24] fix[eve]: repair the 'frozen="strict"' hashability check The check called 'xtyping.is_hashable_type', which does not exist, so every 'frozen="strict"' datamodel raised 'AttributeError' at class definition. The star-import plus module '__getattr__' in 'extended_typing' hid the missing name from both ruff and mypy. Use the represented types rather than the annotation object: a parametrized generic alias such as 'List[int]' is itself hashable, so checking it directly would have wrongly accepted a mutable 'list' field. An annotation which represents no concrete type is now rejected as well, since strict immutability cannot be established for it. --- src/gt4py/eve/datamodels/core.py | 7 ++- tests/eve_tests/unit_tests/test_datamodels.py | 48 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index 30232ec2c6..09d4775aed 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -1218,7 +1218,12 @@ def _make_datamodel( if is_datamodel(f_attr.type): if getattr(f_attr.type, MODEL_PARAM_DEFINITIONS_ATTR).strict_frozen is True: continue - elif xtyping.is_hashable_type(f_attr.type): + # Check the types the annotation actually stands for: a parametrized + # generic alias like 'List[int]' is itself a hashable object, so asking + # it directly would wrongly accept a mutable 'list' field. + elif (represented_types := xtyping.get_represented_types(f_attr.type)) and all( + xtyping.is_type_with_custom_hash(t) for t in represented_types + ): continue unhashable_fields.add(f_attr.name) diff --git a/tests/eve_tests/unit_tests/test_datamodels.py b/tests/eve_tests/unit_tests/test_datamodels.py index d2a1564fc2..c51144407c 100644 --- a/tests/eve_tests/unit_tests/test_datamodels.py +++ b/tests/eve_tests/unit_tests/test_datamodels.py @@ -40,7 +40,7 @@ import pytest import pytest_factoryboy as pytfboy -from gt4py.eve import datamodels, utils +from gt4py.eve import datamodels, exceptions, utils T = TypeVar("T") @@ -1007,6 +1007,19 @@ class Model: assert Model.__datamodel_fields__.value.metadata["my_metadata"] == "META" +# Nested models for the 'frozen="strict"' tests. They have to live at module level: +# this file uses PEP 563 annotations, and forward references are resolved against +# module globals only, so a class defined inside a test method is not visible. +@datamodels.datamodel(frozen="strict") +class StrictFrozenInner: + value: int + + +@datamodels.datamodel(frozen=True) +class PlainFrozenInner: + values: List[int] + + # Test datamodel options class TestDatamodelOptions: def test_frozen(self): @@ -1066,6 +1079,39 @@ class FrozenModel: assert hash(FrozenModel(value=string_value)) == hash(FrozenModel(value=string_value)) + def test_strict_frozen_with_hashable_fields(self): + @datamodels.datamodel(frozen="strict") + class StrictModel: + value: int + name: str + + assert StrictModel.__datamodel_params__.frozen is True + assert StrictModel.__datamodel_params__.strict_frozen is True + assert hash(StrictModel(value=1, name="a")) == hash(StrictModel(value=1, name="a")) + + def test_strict_frozen_rejects_unhashable_fields(self): + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class StrictModelWithList: + values: List[int] + + def test_strict_frozen_accepts_nested_strict_frozen_model(self): + @datamodels.datamodel(frozen="strict") + class Outer: + inner: StrictFrozenInner + + assert hash(Outer(inner=StrictFrozenInner(value=1))) == hash( + Outer(inner=StrictFrozenInner(value=1)) + ) + + def test_strict_frozen_rejects_non_strict_datamodel_field(self): + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class Outer: + inner: PlainFrozenInner + # Test module functions def test_info_functions(): From 13e1da667f07fa42238e95f1111f4bea1a6d1118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 01:07:30 +0200 Subject: [PATCH 04/24] build: align configuration with the Python 3.12 floor - '.gitpod.Dockerfile' still based the workspace on Python 3.11, which no longer satisfies 'requires-python', so '.gitpod.yml' would build a venv the project cannot be installed into. - The noxfile's PEP 723 block declared 'requires-python = ">=3.11"' and its note referred to a '--python 3.11' shebang it does not have. - '[tool.mypy]' declared no 'python_version', so the type-check floor followed whichever interpreter ran mypy instead of the lowest supported one. - Dropped a mypy override for 'typing_tests.test_next_exports' and an isort 'known-third-party' entry for 'importlib_resources'; neither exists (the only use is 'import importlib.resources as importlib_resources', from the stdlib). --- .gitpod.Dockerfile | 2 +- noxfile.py | 4 ++-- pyproject.toml | 10 +++------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.gitpod.Dockerfile b/.gitpod.Dockerfile index 5d02a0f436..11364c8774 100644 --- a/.gitpod.Dockerfile +++ b/.gitpod.Dockerfile @@ -1,4 +1,4 @@ -FROM gitpod/workspace-python-3.11 +FROM gitpod/workspace-python-3.12 USER root RUN apt-get update \ && apt-get install -y libboost-dev \ diff --git a/noxfile.py b/noxfile.py index e3163edbaa..a3e7758d5a 100755 --- a/noxfile.py +++ b/noxfile.py @@ -9,11 +9,11 @@ # SPDX-License-Identifier: BSD-3-Clause # # Note: -# The explicit '--python 3.11' in the shebang is only needed due +# The explicit '--python 3.12' in the shebang is only needed due # to the existence of the .python-versions file, which overrides # the PEP 723 'requires-python' metadata. # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.12" # dependencies = ["nox>=2025.02.09", "uv>=0.6.10"] # /// diff --git a/pyproject.toml b/pyproject.toml index 6b5a08f78a..c74346c908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,6 +199,9 @@ implicit_reexport = false install_types = true namespace_packages = false plugins = ['gt4py.next.type_system.mypy_plugin'] +# Pin to the lowest supported version, otherwise the type-check floor silently +# follows whichever interpreter happens to run mypy. +python_version = '3.12' # pretty = true show_column_numbers = true show_error_codes = true @@ -263,12 +266,6 @@ implicit_reexport = true # factory-boy is broken, see https://github.com/FactoryBoy/factory_boy/pull/1114 module = "factory.*" -[[tool.mypy.overrides]] -disallow_incomplete_defs = false -disallow_untyped_defs = false -ignore_errors = false -module = "typing_tests.test_next_exports" - # -- pytest -- [tool.pytest] @@ -397,7 +394,6 @@ known-third-party = [ 'devtools', 'factory', 'hypothesis', - 'importlib_resources', 'jinja2', 'mako', 'networkx', From 37035af81885bcac3a4203622262d650bc07d41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 01:09:03 +0200 Subject: [PATCH 05/24] docs: correct statements that still assume a Python 3.10 floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'docs/development/next/error-messages.md' told contributors to import 'Self' from 'gt4py.eve.extended_typing' rather than 'typing' and not to reference 'ast' nodes newer than 3.10 — advice that produces needlessly compatible code now that the floor is 3.12. It now describes what is actually available, and which shims are dead and on their way out. Also fixes the language line in 'AGENTS.md' and three stale in-code remarks. --- AGENTS.md | 2 +- docs/development/next/error-messages.md | 17 ++++++++--------- src/gt4py/eve/datamodels/core.py | 3 +-- src/gt4py/next/fingerprinting.py | 4 ++-- tests/eve_tests/unit_tests/test_datamodels.py | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 85321d3e5d..c429eaa5ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ conventions in [`src/gt4py/next/AGENTS.md`](src/gt4py/next/AGENTS.md). ## Stack -- Language: **Python 3.10–3.14** (see `.python-versions`). +- Language: **Python 3.12–3.14** (see `.python-versions`). - Environment / dependencies: **`uv`** (lockfile is `uv.lock`) — always go through `uv`, never bare `pip` / `python`. - Test runner: **`nox`** (sessions in `noxfile.py`). diff --git a/docs/development/next/error-messages.md b/docs/development/next/error-messages.md index 5f9342dc45..ec2b39691a 100644 --- a/docs/development/next/error-messages.md +++ b/docs/development/next/error-messages.md @@ -201,14 +201,13 @@ Unsupported operand type(s) for +: 'Field[[IDim], float64]' and 'Field[[IDim], b ## Python-version caveat -The supported floor is Python 3.10, so the diagnostics code carries a few -forward-compat shims; respect them: +The supported floor is Python 3.12, so `Self`, `BaseException.add_note` (PEP +678\) and every `ast` node up to 3.12 can be used directly. -- Import `Self` from `gt4py.eve.extended_typing`, not `typing` (3.11+ only). -- `DSLError.add_note` works on every Python because `DSLError` defines it; - don't rely on `add_note` for *other* `GT4PyError`s — it is a builtin only on - 3.11+. -- The catalogue must not reference `ast` nodes added after 3.10 (e.g. - `ast.TryStar`) unconditionally — that breaks import on 3.10. +The diagnostics code still carries shims written for the old 3.10 floor — +a `sys.version_info < (3, 11)` `add_note` fallback in `errors/exceptions.py` and +a `TODO(havogt)` in `ffront/dialect_parser.py` deferring `ast.TryStar`. They are +dead on every supported version and are being removed; do not add new ones. -These spots are flagged with `TODO(havogt)`. +Nodes introduced *after* 3.12 (for example `ast.TemplateStr` for PEP 750 +t-strings, 3.14) still cannot be referenced unconditionally in the catalogue. diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index 09d4775aed..3d22b69011 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -353,8 +353,7 @@ def datamodel( # redefinition of unused symbol ``__delattr__()`` methods should not be defined in the class. match_args: If ``True`` (default) and ``__match_args__`` is not already defined in the class, set ``__match_args__`` on the class to support PEP 634 (Structural Pattern Matching). - It is a tuple of all positional-only ``__init__`` parameter names on - Python 3.10 and later. Ignored on older Python versions. + It is a tuple of all positional-only ``__init__`` parameter names. kw_only: If ``True`` (default is ``False``), make all fields keyword-only in the generated ``__init__`` (if ``init`` is ``False``, this parameter is ignored). slots: slots: If ``True`` (the default is ``False``), ``__slots__`` attribute will be generated diff --git a/src/gt4py/next/fingerprinting.py b/src/gt4py/next/fingerprinting.py index 3539165e6a..0c3ee09ad0 100644 --- a/src/gt4py/next/fingerprinting.py +++ b/src/gt4py/next/fingerprinting.py @@ -571,8 +571,8 @@ def catabolize( The traversal scheme is fixed: an iterative post-order walk over the one-level deconstructions produced by `deconstructor`, so the structure - depth is bounded neither by the Python recursion limit nor (on Python - <= 3.10) by the C stack. The reduction logic is supplied as the + depth is bounded neither by the Python recursion limit nor by the C stack. + The reduction logic is supplied as the `aggregator` (the algebra of the catamorphism): it aggregates an `EmptyDeconstruction` into a result and, for non-terminal objects, a `Deconstruction` whose pieces have been replaced by the diff --git a/tests/eve_tests/unit_tests/test_datamodels.py b/tests/eve_tests/unit_tests/test_datamodels.py index c51144407c..669b446e51 100644 --- a/tests/eve_tests/unit_tests/test_datamodels.py +++ b/tests/eve_tests/unit_tests/test_datamodels.py @@ -1023,7 +1023,7 @@ class PlainFrozenInner: # Test datamodel options class TestDatamodelOptions: def test_frozen(self): - import attr # Missing library stubs for Python 3.10) + import attr @datamodels.datamodel(frozen=True) class FrozenModel: From 6da11b0f439523edcfb805f35bcd35207a2aaf8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 17:41:04 +0200 Subject: [PATCH 06/24] refactor[next]: drop diagnostics shims written for the 3.10 floor The Python floor is 3.12, so the version shims in the diagnostics code are dead on every supported version: - 'errors/exceptions.py': the 'sys.version_info < (3, 11)' 'add_note' override and the matching '__notes__' fold-in in '__str__'. 'DSLError' now inherits 'BaseException.add_note' (PEP 678), which is what already happened at runtime. - 'errors/exceptions.py': 'Self' is imported from 'typing' directly instead of 'gt4py.eve.extended_typing'. - 'ffront/dialect_parser.py': the deferred 'ast.TryStar' catalogue entry is now present, so 'try*' is named as a construct instead of falling back to the generic 'ast' class name. Note that 'ast.TryStar' is not reachable through the frontend today: a 'try*' statement always names an exception type in its 'except*' clause, and that name is rejected as an unsupported closure variable before the AST is visited. The entry is added for consistency with 'ast.Try' and pinned at the catalogue level; the reachability gap is pre-existing and affects 'try'/'except X' too. Also correct the recipe step 4 description of 'add_note', which claimed the breadcrumb is routed into the structured 'notes' field. It goes to '__notes__'; 'notes' is reserved for content authored at the raise site, as 'test_add_note_uses_pep678_notes' has always pinned. --- docs/development/next/error-messages.md | 22 +++++----- src/gt4py/next/errors/exceptions.py | 24 +---------- src/gt4py/next/ffront/dialect_parser.py | 6 ++- .../ffront_tests/test_diagnostic_messages.py | 43 +++++++++++++------ 4 files changed, 48 insertions(+), 47 deletions(-) diff --git a/docs/development/next/error-messages.md b/docs/development/next/error-messages.md index ec2b39691a..dc2c81ddf1 100644 --- a/docs/development/next/error-messages.md +++ b/docs/development/next/error-messages.md @@ -119,13 +119,15 @@ except errors.DSLError as err: raise ``` -`DSLError.add_note` overrides `BaseException.add_note` to route the note into -the structured `notes` field instead of `__notes__`: the traceback machinery -(and therefore pytest and IPython/Jupyter) prints the exception via -`str(err)`, which already renders the structured notes, so writing `__notes__` -as well would duplicate them. The seam is wired at `func_to_foast` -(`ffront/func_to_foast.py`); add it at later stages as they gain useful -context. +This is the stock `BaseException.add_note`, so the breadcrumb lands in +`__notes__`, not in the structured `notes` field — `notes` is reserved for +content authored at the raise site. The two have different renderers: +`DSLError.__str__` emits only the structured parts, while `__notes__` is +printed by the traceback machinery (and therefore by pytest and +IPython/Jupyter). The excepthook in `errors/excepthook.py` replaces that +machinery, so it appends `__notes__` itself. The seam is wired at +`func_to_foast` (`ffront/func_to_foast.py`); add it at later stages as they +gain useful context. ### 5. Always: a test @@ -204,10 +206,8 @@ Unsupported operand type(s) for +: 'Field[[IDim], float64]' and 'Field[[IDim], b The supported floor is Python 3.12, so `Self`, `BaseException.add_note` (PEP 678\) and every `ast` node up to 3.12 can be used directly. -The diagnostics code still carries shims written for the old 3.10 floor — -a `sys.version_info < (3, 11)` `add_note` fallback in `errors/exceptions.py` and -a `TODO(havogt)` in `ffront/dialect_parser.py` deferring `ast.TryStar`. They are -dead on every supported version and are being removed; do not add new ones. +The diagnostics code carries no version shims left over from the old 3.10 +floor; do not add new ones. Nodes introduced *after* 3.12 (for example `ast.TemplateStr` for PEP 750 t-strings, 3.14) still cannot be referenced unconditionally in the catalogue. diff --git a/src/gt4py/next/errors/exceptions.py b/src/gt4py/next/errors/exceptions.py index fb7e7eaa7d..9778f68045 100644 --- a/src/gt4py/next/errors/exceptions.py +++ b/src/gt4py/next/errors/exceptions.py @@ -20,13 +20,9 @@ from __future__ import annotations import difflib -import sys -from typing import Any, ClassVar, Iterable, Optional, Sequence +from typing import Any, ClassVar, Iterable, Optional, Self, Sequence from gt4py.eve import SourceLocation - -# TODO(havogt): import 'Self' from 'typing' directly once the Python floor is >=3.12. -from gt4py.eve.extended_typing import Self from gt4py.next.errors import formatting @@ -95,18 +91,8 @@ def with_location(self, location: Optional[SourceLocation]) -> Self: self.location = location return self - # TODO(havogt): drop this shim and the matching '__notes__' fold-in in - # '__str__' once the Python floor is >=3.11, where 'BaseException.add_note' - # (PEP 678) and its automatic '__notes__' traceback rendering are built in. - if sys.version_info < (3, 11): - - def add_note(self, note: str) -> None: - if not hasattr(self, "__notes__"): - self.__notes__ = [] - self.__notes__.append(note) - def __str__(self) -> str: - body = formatting.format_diagnostic_parts( + return formatting.format_diagnostic_parts( self.message, self.location, label=self.label, @@ -114,12 +100,6 @@ def __str__(self) -> str: notes=self.notes, hints=self.hints, ) - if sys.version_info < (3, 11): - # On 3.10 the traceback machinery doesn't render '__notes__'; fold - # them in so they surface through 'str()' (pytest, IPython, logging) - # the way the >=3.11 machinery does automatically. - body += "".join(f"\n{note}" for note in getattr(self, "__notes__", [])) - return body class UnsupportedPythonFeatureError(DSLError): diff --git a/src/gt4py/next/ffront/dialect_parser.py b/src/gt4py/next/ffront/dialect_parser.py index aa4aae7b49..8eb90a1826 100644 --- a/src/gt4py/next/ffront/dialect_parser.py +++ b/src/gt4py/next/ffront/dialect_parser.py @@ -28,8 +28,6 @@ #: what to use instead. Constructs not listed here get a generic message naming #: the `ast` class. Keep the hints actionable: name the closest supported #: alternative, not just the restriction. -# TODO(havogt): add 'ast.TryStar' ('try*' statement, Python >=3.11) once the -# Python floor is >=3.12; referencing it unconditionally breaks import on 3.10. _UNSUPPORTED_FEATURE_HINTS: dict[type[ast.AST], tuple[str, tuple[str, ...]]] = { ast.For: ( "'for' loop", @@ -58,6 +56,10 @@ ("Define a separate function decorated with '@field_operator' instead.",), ), ast.Try: ("'try' statement", ("Exception handling is not available inside GT4Py functions.",)), + ast.TryStar: ( + "'try*' statement", + ("Exception handling is not available inside GT4Py functions.",), + ), ast.Raise: ( "'raise' statement", ("Exception handling is not available inside GT4Py functions.",), diff --git a/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py b/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py index d0bd52f360..0ce41f4713 100644 --- a/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py +++ b/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py @@ -15,13 +15,14 @@ regress. When changing a message, update the expectation here alongside. """ +import ast import re -import sys import pytest import gt4py.next as gtx from gt4py.next import errors, float32, float64 +from gt4py.next.ffront import dialect_parser from gt4py.next.ffront.func_to_foast import FieldOperatorParser @@ -76,6 +77,35 @@ def with_while(a: gtx.Field[[IDim], float64]) -> gtx.Field[[IDim], float64]: assert "Note: Only a subset of Python is valid inside GT4Py functions." in rendered +def test_try_statement_names_construct(): + def with_try(a: gtx.Field[[IDim], float64]) -> gtx.Field[[IDim], float64]: + try: + a = a + 1.0 + finally: + pass + return a + + err = parse_error(with_try) + + assert isinstance(err, errors.UnsupportedPythonFeatureError) + assert err.message == "Unsupported Python syntax: 'try' statement." + assert any("Exception handling" in hint for hint in err.hints) + + +def test_try_star_statement_is_catalogued(): + # 'try*' cannot be reached through the frontend: it always names an exception + # type in its 'except*' clause, and that name is rejected as an unsupported + # closure variable before the AST is visited. Pin the catalogue entry itself, + # so the construct is named correctly if it ever does surface. + node = ast.parse("try:\n pass\nexcept* ValueError:\n pass").body[0] + + feature, hints = dialect_parser._describe_unsupported_feature(node) + + assert isinstance(node, ast.TryStar) + assert feature == "'try*' statement" + assert any("Exception handling" in hint for hint in hints) + + def test_unlisted_construct_falls_back_to_ast_name(): def with_string(a: gtx.Field[[IDim], float64]) -> gtx.Field[[IDim], float64]: f"{a}" @@ -151,17 +181,6 @@ def test_add_note_uses_pep678_notes(): assert err.notes == [] -@pytest.mark.skipif( - sys.version_info >= (3, 11), - reason="On >=3.11 the traceback machinery renders '__notes__'; 'str()' does not.", -) -def test_add_note_folded_into_str_on_py310(): - err = errors.DSLError(None, "A message.") - err.add_note("Extra context.") - - assert "Extra context." in str(err) - - def test_toolchain_step_attaches_definition_context(): from gt4py.next.ffront import stages as ffront_stages from gt4py.next.ffront.func_to_foast import func_to_foast From 7b642fa4e1cb35e34cdacbd8dad02ed822235269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 17:45:37 +0200 Subject: [PATCH 07/24] fix[eve]: apply the 'frozen="strict"' check to union members Extract the per-annotation immutability test into '_is_strictly_immutable_type' and recurse into the types an annotation actually stands for, so wrapping a field in a union no longer bypasses the check: 'Optional[PlainFrozenInner]' and 'Optional[List[int]]' are now rejected the same way the bare annotations are. 'Literal' arguments are values rather than types, so they are checked via the type of each argument. Also name the offending fields and their datamodel in the error message, and document the 'frozen="strict"' value in the 'datamodel' docstring. --- src/gt4py/eve/datamodels/core.py | 58 ++++++++++++++----- tests/eve_tests/unit_tests/test_datamodels.py | 24 ++++++++ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index 3d22b69011..c228f58efc 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -351,6 +351,9 @@ def datamodel( # redefinition of unused symbol frozen: If ``True`` (default is ``False``), assigning to fields will generate an exception. This emulates read-only frozen instances. The ``__setattr__()`` and ``__delattr__()`` methods should not be defined in the class. + The special value ``"strict"`` additionally requires every field annotation + to stand for strictly immutable values (other ``frozen="strict"`` datamodels + or types defining a custom ``__hash__``), raising ``EveTypeError`` otherwise. match_args: If ``True`` (default) and ``__match_args__`` is not already defined in the class, set ``__match_args__`` on the class to support PEP 634 (Structural Pattern Matching). It is a tuple of all positional-only ``__init__`` parameter names. @@ -1018,6 +1021,36 @@ def _type_converter(value: Any) -> _T: _KNOWN_MUTABLE_TYPES: Final = (list, dict, set) +def _is_strictly_immutable_type(type_annotation: TypeAnnotation) -> bool: + """Check whether an annotation only admits strictly immutable (hashable) values. + + A datamodel qualifies only if it is itself defined with ``frozen="strict"``; + any other type qualifies if it defines a custom ``__hash__``. Annotations + standing for several types (unions, type variables, forward references) are + checked member by member with the same rules, so that wrapping a type in a + union does not weaken the check. + """ + if is_datamodel(type_annotation): + return getattr(type_annotation, MODEL_PARAM_DEFINITIONS_ATTR).strict_frozen is True + + if xtyping.get_origin(type_annotation) is Literal: + # 'Literal' arguments are values, not types. + return all( + xtyping.is_type_with_custom_hash(type(arg)) for arg in xtyping.get_args(type_annotation) + ) + + # Check the types the annotation actually stands for: a parametrized generic + # alias like 'List[int]' is itself a hashable object, so asking it directly + # would wrongly accept a mutable 'list' field. + represented_types = xtyping.get_represented_types(type_annotation) + if not represented_types: + return False + if represented_types == (type_annotation,): # plain type, already known not to be a datamodel + return xtyping.is_type_with_custom_hash(represented_types[0]) + + return all(map(_is_strictly_immutable_type, represented_types)) + + def _make_datamodel( cls: Type[_T], *, @@ -1212,23 +1245,16 @@ def _make_datamodel( # Final checks and postprocessing if strict_frozen: - unhashable_fields = set() - for f_attr in new_cls.__attrs_attrs__: - if is_datamodel(f_attr.type): - if getattr(f_attr.type, MODEL_PARAM_DEFINITIONS_ATTR).strict_frozen is True: - continue - # Check the types the annotation actually stands for: a parametrized - # generic alias like 'List[int]' is itself a hashable object, so asking - # it directly would wrongly accept a mutable 'list' field. - elif (represented_types := xtyping.get_represented_types(f_attr.type)) and all( - xtyping.is_type_with_custom_hash(t) for t in represented_types - ): - continue - unhashable_fields.add(f_attr.name) - - if unhashable_fields: + mutable_fields = [ + f_attr.name + for f_attr in new_cls.__attrs_attrs__ + if not _is_strictly_immutable_type(f_attr.type) + ] + if mutable_fields: + names = ", ".join(f"'{name}'" for name in mutable_fields) raise exceptions.EveTypeError( - f"Some fields ({unhashable_fields}) can not be considered strictly immutable." + f"Fields ({names}) of datamodel '{new_cls.__name__}' " + "can not be considered strictly immutable." ) if "__attrs_init__" in new_cls.__dict__: diff --git a/tests/eve_tests/unit_tests/test_datamodels.py b/tests/eve_tests/unit_tests/test_datamodels.py index 669b446e51..c46ca9926c 100644 --- a/tests/eve_tests/unit_tests/test_datamodels.py +++ b/tests/eve_tests/unit_tests/test_datamodels.py @@ -1112,6 +1112,30 @@ def test_strict_frozen_rejects_non_strict_datamodel_field(self): class Outer: inner: PlainFrozenInner + def test_strict_frozen_rejects_union_wrapped_unhashable_fields(self): + # A union member must satisfy the same rules as a bare annotation, otherwise + # 'Optional[...]' would be a trivial way around the check. + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class OptionalNonStrictModel: + inner: Optional[PlainFrozenInner] = None + + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class OptionalListModel: + values: Optional[List[int]] = None + + def test_strict_frozen_accepts_optional_and_literal_fields(self): + @datamodels.datamodel(frozen="strict") + class StrictModel: + value: Optional[int] = None + mode: Literal["a", "b"] = "a" + inner: Optional[StrictFrozenInner] = None + + assert hash(StrictModel()) == hash(StrictModel()) + # Test module functions def test_info_functions(): From d0480d3a377b6c5eec8fd0a6483117fdd66f3206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 17:45:41 +0200 Subject: [PATCH 08/24] refactor[cartesian]: reuse '_is_ellipsis_node' in the interval parser Replace the inline 'isinstance(node, ast.Constant) and node.value is Ellipsis' test with the '_is_ellipsis_node' helper, and update the docstring that still referred to the removed 'ast.Ellipsis' node. Drop the 'ast.Str' fallback in 'CallInliner.visit_Expr': 'ast.Str' was removed in Python 3.12, so the 'hasattr' guard is always false and 'ast.Constant' alone covers string statements. --- src/gt4py/cartesian/frontend/gtscript_frontend.py | 7 +++---- .../unit_tests/frontend_tests/test_gtscript_frontend.py | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/gt4py/cartesian/frontend/gtscript_frontend.py b/src/gt4py/cartesian/frontend/gtscript_frontend.py index 60297492a4..1920b61ab4 100644 --- a/src/gt4py/cartesian/frontend/gtscript_frontend.py +++ b/src/gt4py/cartesian/frontend/gtscript_frontend.py @@ -307,7 +307,7 @@ def visit_Subscript(self, node: ast.Subscript) -> nodes.AxisBound: class VerticalIntervalParser(IntervalParser): """Parse Python AST interval syntax in the form of a Slice. - Corner cases: `ast.Ellipsis` refers to the entire interval, and + Corner cases: an ellipsis (`...`) constant refers to the entire interval, and if an `ast.Subscript` is passed, this parses its slice attribute. """ @@ -353,7 +353,7 @@ def apply( if isinstance(node, ast.Subscript): raise parser.interval_error - if isinstance(node, ast.Constant) and node.value is Ellipsis: + if _is_ellipsis_node(node): interval = nodes.AxisInterval.full_interval() interval.loc = loc return interval @@ -748,8 +748,7 @@ def visit_Call(self, node: ast.Call, *, target_node=None): # Cyclomatic complex def visit_Expr(self, node: ast.Expr): """Ignore pure string statements in callee.""" - pure_str_types = (ast.Constant,) + ((ast.Str,) if hasattr(ast, "Str") else ()) - if not isinstance(node.value, pure_str_types): + if not isinstance(node.value, ast.Constant): return super().visit(node.value) diff --git a/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py b/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py index fe591dcbba..27137d6a21 100644 --- a/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py +++ b/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py @@ -6,6 +6,7 @@ # Please, refer to the LICENSE file in the root directory. # SPDX-License-Identifier: BSD-3-Clause +import ast import inspect import functools import textwrap @@ -2490,8 +2491,6 @@ class TestEllipsisNodeDetection: "source, expected", [("...", True), ("1", False), ("None", False), ("x", False)] ) def test_is_ellipsis_node(self, source, expected): - import ast - node = ast.parse(source, mode="eval").body assert gt_frontend._is_ellipsis_node(node) is expected From 068e3222523e201ba72edc334f88d415ddacf7bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 6 Aug 2026 17:45:46 +0200 Subject: [PATCH 09/24] style[next]: avoid shadowing 't' in '_type_conversion_helper' The union branch rebound the parameter name 't' in its comprehension and assertion, making the assertion read as if it constrained the argument rather than the converted members. Rename to 'member_types'/'m'. --- src/gt4py/next/ffront/fbuiltins.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/gt4py/next/ffront/fbuiltins.py b/src/gt4py/next/ffront/fbuiltins.py index 12fb192999..f90de2950f 100644 --- a/src/gt4py/next/ffront/fbuiltins.py +++ b/src/gt4py/next/ffront/fbuiltins.py @@ -146,9 +146,10 @@ def _type_conversion_helper(t: type) -> type[ts.TypeSpec] | tuple[type[ts.TypeSp # 'Union[A, B]' and 'A | B' are different runtime objects: the latter is a # 'types.UnionType', which carries no '__origin__' at all. elif get_origin(t) in (Union, UnionType): - types = [_type_conversion_helper(e) for e in get_args(t)] - assert all(type(t) is type and issubclass(t, ts.TypeSpec) for t in types) - return cast(tuple[type[ts.TypeSpec], ...], tuple(types)) # `cast` to break the recursion + member_types = [_type_conversion_helper(e) for e in get_args(t)] + assert all(type(m) is type and issubclass(m, ts.TypeSpec) for m in member_types) + # `cast` to break the recursion + return cast(tuple[type[ts.TypeSpec], ...], tuple(member_types)) elif t in named_collections.CUSTOM_NAMED_COLLECTION_TYPES: return ts.NamedCollectionType else: From 4b33952ea4463ac49c9dccad6ced1e09949f778e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 7 Aug 2026 15:31:34 +0200 Subject: [PATCH 10/24] fix[eve]: decompose containers in the 'frozen="strict"' immutability check '_is_strictly_immutable_type' only inspected the origin of a parametrized generic, so a field annotated 'Tuple[List[int], ...]' passed the check ('tuple' has a custom '__hash__') and produced instances that raise 'TypeError: unhashable type' on 'hash()' -- the exact invariant that 'frozen="strict"' exists to guarantee. Decompose composite annotations instead: for a generic alias both the origin and every type argument must be strictly immutable, and unions are matched explicitly so that both 'Union[A, B]' and 'A | B' are covered. An unresolvable forward reference now yields the regular 'EveTypeError' naming the field rather than escaping as a raw 'NameError'. --- src/gt4py/eve/datamodels/core.py | 49 +++++++++++++------ tests/eve_tests/unit_tests/test_datamodels.py | 34 +++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index c228f58efc..1eb43ada8f 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -1025,30 +1025,49 @@ def _is_strictly_immutable_type(type_annotation: TypeAnnotation) -> bool: """Check whether an annotation only admits strictly immutable (hashable) values. A datamodel qualifies only if it is itself defined with ``frozen="strict"``; - any other type qualifies if it defines a custom ``__hash__``. Annotations - standing for several types (unions, type variables, forward references) are - checked member by member with the same rules, so that wrapping a type in a - union does not weaken the check. + any other plain type qualifies if it defines a custom ``__hash__``. Composite + annotations are decomposed and every part is checked with the same rules, so + that neither wrapping a type in a union nor hiding it in a generic container + (whose hash folds in the hashes of its items) weakens the check. Annotations + that cannot be resolved to any concrete type (``Any``, unbound type variables, + unresolved forward references) are conservatively rejected. """ if is_datamodel(type_annotation): return getattr(type_annotation, MODEL_PARAM_DEFINITIONS_ATTR).strict_frozen is True - if xtyping.get_origin(type_annotation) is Literal: + origin_type = xtyping.get_origin(type_annotation) + type_args = xtyping.get_args(type_annotation) + + if origin_type is Literal: # 'Literal' arguments are values, not types. - return all( - xtyping.is_type_with_custom_hash(type(arg)) for arg in xtyping.get_args(type_annotation) + return all(xtyping.is_type_with_custom_hash(type(arg)) for arg in type_args) + + if origin_type is Union or origin_type is types.UnionType: + # 'Union[A, B]' and 'A | B' are different runtime objects before Python 3.14. + # A union is immutable only if every one of its members is. + return bool(type_args) and all(map(_is_strictly_immutable_type, type_args)) + + if origin_type is not None: + # Parametrized generic alias ('tuple[int, ...]', 'List[int]', ...). The alias + # itself is a hashable object, so it has to be decomposed: both the container + # type and every type argument must be strictly immutable, since the hash of + # a container folds in the hashes of the items it holds. + return _is_strictly_immutable_type(origin_type) and all( + _is_strictly_immutable_type(arg) for arg in type_args if arg is not Ellipsis ) - # Check the types the annotation actually stands for: a parametrized generic - # alias like 'List[int]' is itself a hashable object, so asking it directly - # would wrongly accept a mutable 'list' field. - represented_types = xtyping.get_represented_types(type_annotation) - if not represented_types: + if xtyping.is_actual_type(type_annotation): # plain type, already known not to be a datamodel + return xtyping.is_type_with_custom_hash(type_annotation) + + # Anything else (type variables, forward references, ...) is checked through the + # concrete types it stands for, if any. Forward references that cannot be resolved + # at this point do not prove anything, so they are rejected. + try: + represented_types = xtyping.get_represented_types(type_annotation) + except NameError: return False - if represented_types == (type_annotation,): # plain type, already known not to be a datamodel - return xtyping.is_type_with_custom_hash(represented_types[0]) - return all(map(_is_strictly_immutable_type, represented_types)) + return bool(represented_types) and all(map(_is_strictly_immutable_type, represented_types)) def _make_datamodel( diff --git a/tests/eve_tests/unit_tests/test_datamodels.py b/tests/eve_tests/unit_tests/test_datamodels.py index c46ca9926c..3aa824fc83 100644 --- a/tests/eve_tests/unit_tests/test_datamodels.py +++ b/tests/eve_tests/unit_tests/test_datamodels.py @@ -1127,6 +1127,31 @@ class OptionalNonStrictModel: class OptionalListModel: values: Optional[List[int]] = None + def test_strict_frozen_rejects_container_wrapped_unhashable_fields(self): + # A 'tuple' is hashable only if its items are, so the type arguments of a + # generic container must satisfy the same rules as a bare annotation. + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class TupleOfListsModel: + values: Tuple[List[int], ...] + + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class TupleOfNonStrictModels: + inners: Tuple[PlainFrozenInner, ...] + + def test_strict_frozen_rejects_unresolved_forward_reference(self): + # A self-reference cannot be resolved while the class is being created, so it + # cannot be proven immutable: the check must reject it instead of raising the + # bare 'NameError' coming from the annotation resolution. + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class RecursiveModel: + child: Optional[RecursiveModel] = None + def test_strict_frozen_accepts_optional_and_literal_fields(self): @datamodels.datamodel(frozen="strict") class StrictModel: @@ -1136,6 +1161,15 @@ class StrictModel: assert hash(StrictModel()) == hash(StrictModel()) + def test_strict_frozen_accepts_container_of_immutable_fields(self): + @datamodels.datamodel(frozen="strict") + class StrictModel: + values: Tuple[int, ...] + inners: Tuple[StrictFrozenInner, ...] = () + + model = StrictModel(values=(1, 2), inners=(StrictFrozenInner(value=1),)) + assert hash(model) == hash(StrictModel(values=(1, 2), inners=(StrictFrozenInner(value=1),))) + # Test module functions def test_info_functions(): From 8f5f24f21d15820dc1936ecbac53cc6b0ee952f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 7 Aug 2026 15:31:59 +0200 Subject: [PATCH 11/24] refactor[eve]: drop the dead Python 3.9 guard in 'extended_typing' The PEP 585 aliases sat inside 'if _sys.version_info >= (3, 9):' with no 'else' branch, which the 3.12 floor turns into a compile-time constant. De-indenting them needs care: the block deliberately rebinds names that the 'from typing import *' above it also defines, and the 'if' was incidentally keeping the import sorter away from it. Without a barrier, ruff hoists the block above the star import and silently undoes every rebinding, so an explicit 'isort: split' marker takes over that role. --- src/gt4py/eve/extended_typing.py | 98 +++++++++++++++++--------------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/src/gt4py/eve/extended_typing.py b/src/gt4py/eve/extended_typing.py index 6470befe1a..56c795912f 100644 --- a/src/gt4py/eve/extended_typing.py +++ b/src/gt4py/eve/extended_typing.py @@ -34,53 +34,57 @@ from typing_extensions import * # type: ignore[assignment,no-redef] # noqa: F403 [undefined-local-with-import-star] -if _sys.version_info >= (3, 9): - # Standard library already supports PEP 585 (Type Hinting Generics In Standard Collections) - from builtins import ( # type: ignore[assignment] - dict as Dict, - frozenset as FrozenSet, - list as List, - set as Set, - tuple as Tuple, - type as Type, - ) - from collections import ( - ChainMap as ChainMap, - Counter as Counter, - OrderedDict as OrderedDict, - defaultdict as defaultdict, - deque as deque, - ) - from collections.abc import ( - AsyncGenerator as AsyncGenerator, - AsyncIterable as AsyncIterable, - AsyncIterator as AsyncIterator, - Awaitable as Awaitable, - ByteString as ByteString, - Callable as Callable, - Collection as Collection, - Container as Container, - Coroutine as Coroutine, - Generator as Generator, - ItemsView as ItemsView, - Iterable as Iterable, - Iterator as Iterator, - KeysView as KeysView, - Mapping as Mapping, - MappingView as MappingView, - MutableMapping as MutableMapping, - MutableSequence as MutableSequence, - MutableSet as MutableSet, - Reversible as Reversible, - Sequence as Sequence, - Set as AbstractSet, - ValuesView as ValuesView, - ) - from contextlib import ( - AbstractAsyncContextManager as AsyncContextManager, - AbstractContextManager as ContextManager, - ) - from re import Match as Match, Pattern as Pattern +# The standard library has supported PEP 585 (Type Hinting Generics In Standard +# Collections) since Python 3.9, so the deprecated 'typing' aliases star-imported above +# are replaced here by the standard collection types. This block must stay *below* the +# star imports, since it deliberately rebinds names those imports also define; the +# 'isort: split' marker keeps the import sorter from hoisting it. +# isort: split +from builtins import ( # type: ignore[assignment] + dict as Dict, + frozenset as FrozenSet, + list as List, + set as Set, + tuple as Tuple, + type as Type, +) +from collections import ( + ChainMap as ChainMap, + Counter as Counter, + OrderedDict as OrderedDict, + defaultdict as defaultdict, + deque as deque, +) +from collections.abc import ( + AsyncGenerator as AsyncGenerator, + AsyncIterable as AsyncIterable, + AsyncIterator as AsyncIterator, + Awaitable as Awaitable, + ByteString as ByteString, + Callable as Callable, + Collection as Collection, + Container as Container, + Coroutine as Coroutine, + Generator as Generator, + ItemsView as ItemsView, + Iterable as Iterable, + Iterator as Iterator, + KeysView as KeysView, + Mapping as Mapping, + MappingView as MappingView, + MutableMapping as MutableMapping, + MutableSequence as MutableSequence, + MutableSet as MutableSet, + Reversible as Reversible, + Sequence as Sequence, + Set as AbstractSet, + ValuesView as ValuesView, +) +from contextlib import ( + AbstractAsyncContextManager as AsyncContextManager, + AbstractContextManager as ContextManager, +) +from re import Match as Match, Pattern as Pattern # These fallbacks are useful for public symbols not exported by default. From 50a74faa0f6e7122d26a2d1d634fa4212b68face Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 7 Aug 2026 15:32:17 +0200 Subject: [PATCH 12/24] fix[next]: keep the typing-export tests parametrized over Python versions 'test_typing_exports' is parametrized over 3.12/3.13/3.14, but its mypy runs were all type-checking as 3.12. pytest-mypy-plugins creates its execution directory under '--mypy-testing-base' and passes no '--config-file', so mypy discovers the project's own '[tool.mypy]' table by walking up out of that directory and picks up the 'python_version' floor pinned there. The 3.13 and 3.14 runs were therefore duplicates of the 3.12 one, and a client-visible typing regression specific to a newer interpreter would not have been caught. Point the session at a dedicated 'typing_tests/mypy.ini' instead. It deliberately declares no 'python_version', so each run checks the version it claims to. As these tests model *downstream client* code, the new file also keeps gt4py-internal strictness from applying to client snippets, while still enabling the mypy plugin that downstream users are told to enable (and whose false-positive fixes these tests exist to pin). --- noxfile.py | 7 +++++++ typing_tests/mypy.ini | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 typing_tests/mypy.ini diff --git a/noxfile.py b/noxfile.py index a3e7758d5a..94ec333558 100755 --- a/noxfile.py +++ b/noxfile.py @@ -321,11 +321,18 @@ def test_typing_exports(session: nox.Session) -> None: """Test GT4Py usability in a typed client context.""" install_session_venv(session, extras=["standard"], groups=["test", "typing_exports"]) + # Pass the config explicitly: with no '--config-file', mypy discovers one by + # walking up from the plugin's temporary execution directory and reaches the + # project's own '[tool.mypy]' table, which pins 'python_version' to the supported + # floor and would collapse this session's 3.13/3.14 runs into the 3.12 one. See + # the comments in 'typing_tests/mypy.ini'. session.run( "pytest", "-sv", "--mypy-testing-base", "typing_tests", + "--mypy-ini-file", + "typing_tests/mypy.ini", "typing_tests", *session.posargs, ) diff --git a/typing_tests/mypy.ini b/typing_tests/mypy.ini new file mode 100644 index 0000000000..90c26f6e75 --- /dev/null +++ b/typing_tests/mypy.ini @@ -0,0 +1,21 @@ +; Mypy configuration for the 'test_typing_exports' session. +; +; These tests check GT4Py's usability from *downstream client* code, so they must not +; inherit the project's own '[tool.mypy]' table from 'pyproject.toml'. Given no +; '--config-file', mypy discovers one by walking up from the plugin's temporary +; execution directory (created under this folder) and reaches that table, which both +; applies gt4py-internal strictness to client snippets and — because the table pins +; 'python_version' to the supported floor — makes the 3.13 and 3.14 runs of this nox +; session type-check as 3.12, silently collapsing the parametrization. The noxfile +; therefore points '--mypy-ini-file' here instead of relying on discovery order. +; +; Deliberately no 'python_version' here: it must follow the interpreter running the +; session, so each parametrized run checks the version it claims to check. + +[mypy] +; The plugin is what downstream users are told to enable; see the module docstring of +; 'gt4py.next.type_system.mypy_plugin'. +plugins = gt4py.next.type_system.mypy_plugin +ignore_missing_imports = True +show_column_numbers = True +show_error_codes = True From db6e0ac6b2efa82a57b9793c100d4ecfd8dbf959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 7 Aug 2026 15:32:44 +0200 Subject: [PATCH 13/24] docs[next]: record TODOs for the 'try' diagnostics and 'ExceptionGroup' Two known defects surfaced while reviewing this branch are left in place but documented where they are, rather than fixed here. The unsupported-construct catalogue entries for 'try'/'try*' are largely unreachable: naming an exception type makes it a closure variable, and 'visit_FunctionDef' types closure variables before visiting the body, so 'try: ... except ValueError: ...' fails with "Unexpected object 'ValueError' ..." instead. Only 'try/finally' and bare 'try/except:' reach the catalogue, and 'try*' never does. The existing test covers the 'try/finally' shape only, which is now stated so it is not mistaken for general 'try' coverage. Fixing this means reordering the unsupported-syntax scan ahead of closure-variable type deduction, which changes user-visible messages and belongs in its own change. 'wait_for_compilation' still flattens multiple compilation failures into a 'RuntimeError'. The 3.10 floor named in the original TODO is gone, so the note now records the real remaining blocker: it would change the documented 'Raises:' contract of a public function. Also re-wraps a ragged docstring line in 'fingerprinting' and moves an 'isinstance' precondition above the action it guards. --- src/gt4py/next/ffront/dialect_parser.py | 8 ++++++++ src/gt4py/next/ffront/func_to_foast.py | 6 ++++++ src/gt4py/next/fingerprinting.py | 7 +++---- src/gt4py/next/otf/compiled_program.py | 7 ++++++- .../unit_tests/ffront_tests/test_diagnostic_messages.py | 7 ++++++- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/gt4py/next/ffront/dialect_parser.py b/src/gt4py/next/ffront/dialect_parser.py index 8eb90a1826..c59f86012e 100644 --- a/src/gt4py/next/ffront/dialect_parser.py +++ b/src/gt4py/next/ffront/dialect_parser.py @@ -55,6 +55,14 @@ "'lambda' expression", ("Define a separate function decorated with '@field_operator' instead.",), ), + # TODO(egparedes): make these two entries reachable for the common 'except :' + # shape. Naming an exception type turns it into a closure variable, and + # 'func_to_foast.FieldOperatorParser.visit_FunctionDef' types closure variables + # *before* visiting the body, so 'try: ... except ValueError: ...' fails first with + # "Unexpected object 'ValueError' of type '' encountered." Only + # 'try/finally' and bare 'try/except:' reach this catalogue; 'try*' never does, + # since 'except*' always names a type. Fixing it means running the + # unsupported-syntax scan ahead of closure-variable type deduction. ast.Try: ("'try' statement", ("Exception handling is not available inside GT4Py functions.",)), ast.TryStar: ( "'try*' statement", diff --git a/src/gt4py/next/ffront/func_to_foast.py b/src/gt4py/next/ffront/func_to_foast.py index 0250f223d0..b492bae900 100644 --- a/src/gt4py/next/ffront/func_to_foast.py +++ b/src/gt4py/next/ffront/func_to_foast.py @@ -197,6 +197,12 @@ def _postprocess_dialect_ast( def visit_FunctionDef(self, node: ast.FunctionDef, **kwargs: Any) -> foast.FunctionDefinition: loc = self.get_location(node) self._check_not_a_reserved_name(node.name, loc) + # TODO(egparedes): run the unsupported-syntax scan before this loop. Typing the + # closure variables first means a name that only appears in unsupported syntax + # is reported as a bad closure variable instead of as the construct that + # introduced it -- e.g. 'try: ... except ValueError: ...' raises "Unexpected + # object 'ValueError' ..." spanning the whole signature, rather than the + # 'try'-statement diagnostic catalogued in 'dialect_parser'. closure_var_symbols: list[foast.Symbol] = [] for name in self.closure_vars.keys(): try: diff --git a/src/gt4py/next/fingerprinting.py b/src/gt4py/next/fingerprinting.py index 0c3ee09ad0..f0739a102b 100644 --- a/src/gt4py/next/fingerprinting.py +++ b/src/gt4py/next/fingerprinting.py @@ -572,10 +572,9 @@ def catabolize( The traversal scheme is fixed: an iterative post-order walk over the one-level deconstructions produced by `deconstructor`, so the structure depth is bounded neither by the Python recursion limit nor by the C stack. - The reduction logic is supplied as the - `aggregator` (the algebra of the catamorphism): it aggregates an - `EmptyDeconstruction` into a result and, for non-terminal objects, - a `Deconstruction` whose pieces have been replaced by the + The reduction logic is supplied as the `aggregator` (the algebra of the + catamorphism): it aggregates an `EmptyDeconstruction` into a result and, + for non-terminal objects, a `Deconstruction` whose pieces have been replaced by the already-aggregated results of the original pieces — in piece order, or in canonical sorted order for an `OrderInsensitiveDeconstruction` (which requires the results to be orderable). diff --git a/src/gt4py/next/otf/compiled_program.py b/src/gt4py/next/otf/compiled_program.py index 0784ca0f73..6438347c8f 100644 --- a/src/gt4py/next/otf/compiled_program.py +++ b/src/gt4py/next/otf/compiled_program.py @@ -190,7 +190,12 @@ def wait_for_compilation() -> None: if len(failures) == 1: raise failures[0][1] if failures: - # TODO(havogt): raise ExceptionGroup once Python 3.10 is dropped. + # TODO(havogt): raise an ExceptionGroup here. The 3.10 floor that originally + # blocked this is gone (PEP 654 is available on the 3.12 floor), so only the + # flattening below still loses information: failures 2..n survive as 'repr' + # text and '__cause__'/'__traceback__' carry the first failure alone. Left as + # is because it changes the documented 'Raises:' contract of this public + # function from 'RuntimeError' to 'ExceptionGroup', which callers may catch. raise RuntimeError( "Multiple compilations failed: " + "; ".join(f"'{label}': {error!r}" for label, error in failures) diff --git a/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py b/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py index 0ce41f4713..cbbb31b942 100644 --- a/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py +++ b/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py @@ -78,6 +78,11 @@ def with_while(a: gtx.Field[[IDim], float64]) -> gtx.Field[[IDim], float64]: def test_try_statement_names_construct(): + # TODO(egparedes): cover 'try: ... except : ...' here once it is diagnosed + # correctly. 'try/finally' below is one of the only two shapes that reach the + # catalogue; the far more common 'except ValueError:' form is intercepted by + # closure-variable type deduction first (see the TODO in 'func_to_foast'), so this + # test must not be read as covering 'try' statements in general. def with_try(a: gtx.Field[[IDim], float64]) -> gtx.Field[[IDim], float64]: try: a = a + 1.0 @@ -98,10 +103,10 @@ def test_try_star_statement_is_catalogued(): # closure variable before the AST is visited. Pin the catalogue entry itself, # so the construct is named correctly if it ever does surface. node = ast.parse("try:\n pass\nexcept* ValueError:\n pass").body[0] + assert isinstance(node, ast.TryStar) feature, hints = dialect_parser._describe_unsupported_feature(node) - assert isinstance(node, ast.TryStar) assert feature == "'try*' statement" assert any("Exception handling" in hint for hint in hints) From a86bfb928f49e071cbec6cd72271a205405123c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Mon, 10 Aug 2026 11:03:15 +0200 Subject: [PATCH 14/24] refactor[eve]: stop re-exporting the deprecated 'typing' builtin aliases 'extended_typing' re-exported 'Dict', 'FrozenSet', 'List', 'Set', 'Tuple' and 'Type'. Since PEP 585 these are deprecated spellings of the builtin generics, so drop them and move the 87 use sites to 'dict', 'frozenset', 'list', 'set', 'tuple' and 'type'. Removing the re-export is not enough on its own. The names are bound by the 'typing' / 'typing_extensions' star imports at the top of the module, and the module '__getattr__' forwards anything missing to those same modules, so simply deleting the block would leave all six names in place, silently rebound from the builtins to the deprecated 'typing' objects -- the opposite of the intent. They are therefore dropped from the namespace explicitly and rejected in '__getattr__' with a message naming the replacement. This is a runtime guarantee only: a type checker still resolves the names through the star imports. A bare 'typing.Type' means 'Type[Any]', but a bare builtin 'type' is stricter (it is not indexable and carries no arbitrary attributes), so the 37 unsubscripted occurrences become 'type[Any]' to keep their meaning. The other five aliases need no such care. 'eval_forward_ref' binds the name 'typing' to this module so that annotations resolve through it, which would make 'typing.List[int]' in a user-written forward reference hit the rejection above. That string names the real 'typing' module and stays valid, so forward-ref resolution now falls back to it for these six names; eve's type validation is tested against exactly those annotations. Only the builtin-generic aliases are affected. The re-exports from 'collections.abc', 'collections', 'contextlib' and 're' are unchanged: there the name is already the modern one and only its 'typing' home is deprecated. Direct 'from typing import List' imports elsewhere in the repo are untouched. --- src/gt4py/_core/definitions.py | 64 ++++--- src/gt4py/eve/codegen.py | 22 +-- src/gt4py/eve/concepts.py | 31 +--- src/gt4py/eve/datamodels/__init__.py | 2 +- src/gt4py/eve/datamodels/core.py | 92 +++++----- src/gt4py/eve/exceptions.py | 4 +- src/gt4py/eve/extended_typing.py | 159 +++++++++++------- src/gt4py/eve/pattern_matching.py | 10 +- src/gt4py/eve/traits.py | 20 +-- src/gt4py/eve/trees.py | 25 ++- src/gt4py/eve/type_definitions.py | 6 +- src/gt4py/eve/type_validation.py | 36 ++-- src/gt4py/eve/utils.py | 107 ++++++------ src/gt4py/storage/allocators.py | 14 +- .../unit_tests/test_extended_typing.py | 127 ++++++++++---- tests/eve_tests/unit_tests/test_traits.py | 8 +- .../unit_tests/test_type_validation.py | 24 ++- 17 files changed, 406 insertions(+), 345 deletions(-) diff --git a/src/gt4py/_core/definitions.py b/src/gt4py/_core/definitions.py index 45a29121fc..69357968ba 100644 --- a/src/gt4py/_core/definitions.py +++ b/src/gt4py/_core/definitions.py @@ -30,8 +30,6 @@ Protocol, Self, Sequence, - Tuple, - Type, TypeAlias, TypeGuard, TypeVar, @@ -62,8 +60,8 @@ BoolScalar: TypeAlias = Union[core_types.bool, bool] BoolT = TypeVar("BoolT", bound=BoolScalar) -BOOL_TYPES: Final[Tuple[type, ...]] = cast( - Tuple[type, ...], +BOOL_TYPES: Final[tuple[type, ...]] = cast( + tuple[type, ...], BoolScalar.__args__, # type: ignore[attr-defined] ) @@ -72,8 +70,8 @@ core_types.int8, core_types.int16, core_types.int32, core_types.int64, int ] IntT = TypeVar("IntT", bound=IntScalar) -INT_TYPES: Final[Tuple[type, ...]] = cast( - Tuple[type, ...], +INT_TYPES: Final[tuple[type, ...]] = cast( + tuple[type, ...], IntScalar.__args__, # type: ignore[attr-defined] ) @@ -82,21 +80,21 @@ core_types.uint8, core_types.uint16, core_types.uint32, core_types.uint64 ] UnsignedIntT = TypeVar("UnsignedIntT", bound=UnsignedIntScalar) -UINT_TYPES: Final[Tuple[type, ...]] = cast( - Tuple[type, ...], +UINT_TYPES: Final[tuple[type, ...]] = cast( + tuple[type, ...], UnsignedIntScalar.__args__, # type: ignore[attr-defined] ) IntegralScalar: TypeAlias = Union[IntScalar, UnsignedIntScalar] IntegralT = TypeVar("IntegralT", bound=IntegralScalar) -INTEGRAL_TYPES: Final[Tuple[type, ...]] = (*INT_TYPES, *UINT_TYPES) +INTEGRAL_TYPES: Final[tuple[type, ...]] = (*INT_TYPES, *UINT_TYPES) FloatingScalar: TypeAlias = Union[core_types.float32, core_types.float64, float] FloatingT = TypeVar("FloatingT", bound=FloatingScalar) -FLOAT_TYPES: Final[Tuple[type, ...]] = cast( - Tuple[type, ...], +FLOAT_TYPES: Final[tuple[type, ...]] = cast( + tuple[type, ...], FloatingScalar.__args__, # type: ignore[attr-defined] ) @@ -123,11 +121,11 @@ class PositiveIntegral(numbers.Integral): ... -def is_boolean_integral_type(integral_type: type) -> TypeGuard[Type[BooleanIntegral]]: +def is_boolean_integral_type(integral_type: type) -> TypeGuard[type[BooleanIntegral]]: return issubclass(integral_type, BOOL_TYPES) -def is_positive_integral_type(integral_type: type) -> TypeGuard[Type[PositiveIntegral]]: +def is_positive_integral_type(integral_type: type) -> TypeGuard[type[PositiveIntegral]]: return issubclass(integral_type, UINT_TYPES) @@ -161,23 +159,23 @@ class DTypeKind(eve.StrEnum): @overload def dtype_kind( - sc_type: Type[IntT] | Type[BoolT], # mypy doesn't distinguish IntT and BoolT + sc_type: type[IntT] | type[BoolT], # mypy doesn't distinguish IntT and BoolT ) -> Literal[DTypeKind.INT, DTypeKind.BOOL]: ... @overload -def dtype_kind(sc_type: Type[UnsignedIntT]) -> Literal[DTypeKind.UINT]: ... # type: ignore[overload-cannot-match] # precision blurring from mypy plugin seems to interfere +def dtype_kind(sc_type: type[UnsignedIntT]) -> Literal[DTypeKind.UINT]: ... # type: ignore[overload-cannot-match] # precision blurring from mypy plugin seems to interfere @overload -def dtype_kind(sc_type: Type[FloatingT]) -> Literal[DTypeKind.FLOAT]: ... +def dtype_kind(sc_type: type[FloatingT]) -> Literal[DTypeKind.FLOAT]: ... @overload -def dtype_kind(sc_type: Type[ScalarT]) -> DTypeKind: ... +def dtype_kind(sc_type: type[ScalarT]) -> DTypeKind: ... -def dtype_kind(sc_type: Type[ScalarT]) -> DTypeKind: +def dtype_kind(sc_type: type[ScalarT]) -> DTypeKind: """Return the data type kind of the given scalar type.""" if issubclass(sc_type, numbers.Integral): if is_boolean_integral_type(sc_type): @@ -209,7 +207,7 @@ class DType(Generic[ScalarT]): `dtype`s definitions due to the `.dtype` attribute. """ - scalar_type: Type[ScalarT] + scalar_type: type[ScalarT] tensor_shape: TensorShape = dataclasses.field(default=()) def __post_init__(self) -> None: @@ -266,28 +264,28 @@ class UnsignedIntDType(DType[UnsignedIntT]): @dataclasses.dataclass(frozen=True) class UInt8DType(UnsignedIntDType[core_types.uint8]): - scalar_type: Final[Type[core_types.uint8]] = dataclasses.field( + scalar_type: Final[type[core_types.uint8]] = dataclasses.field( default=core_types.uint8, init=False ) @dataclasses.dataclass(frozen=True) class UInt16DType(UnsignedIntDType[core_types.uint16]): - scalar_type: Final[Type[core_types.uint16]] = dataclasses.field( + scalar_type: Final[type[core_types.uint16]] = dataclasses.field( default=core_types.uint16, init=False ) @dataclasses.dataclass(frozen=True) class UInt32DType(UnsignedIntDType[core_types.uint32]): - scalar_type: Final[Type[core_types.uint32]] = dataclasses.field( + scalar_type: Final[type[core_types.uint32]] = dataclasses.field( default=core_types.uint32, init=False ) @dataclasses.dataclass(frozen=True) class UInt64DType(UnsignedIntDType[core_types.uint64]): - scalar_type: Final[Type[core_types.uint64]] = dataclasses.field( + scalar_type: Final[type[core_types.uint64]] = dataclasses.field( default=core_types.uint64, init=False ) @@ -299,28 +297,28 @@ class SignedIntDType(DType[IntT]): @dataclasses.dataclass(frozen=True) class Int8DType(SignedIntDType[core_types.int8]): - scalar_type: Final[Type[core_types.int8]] = dataclasses.field( + scalar_type: Final[type[core_types.int8]] = dataclasses.field( default=core_types.int8, init=False ) @dataclasses.dataclass(frozen=True) class Int16DType(SignedIntDType[core_types.int16]): - scalar_type: Final[Type[core_types.int16]] = dataclasses.field( + scalar_type: Final[type[core_types.int16]] = dataclasses.field( default=core_types.int16, init=False ) @dataclasses.dataclass(frozen=True) class Int32DType(SignedIntDType[core_types.int32]): - scalar_type: Final[Type[core_types.int32]] = dataclasses.field( + scalar_type: Final[type[core_types.int32]] = dataclasses.field( default=core_types.int32, init=False ) @dataclasses.dataclass(frozen=True) class Int64DType(SignedIntDType[core_types.int64]): - scalar_type: Final[Type[core_types.int64]] = dataclasses.field( + scalar_type: Final[type[core_types.int64]] = dataclasses.field( default=core_types.int64, init=False ) @@ -332,21 +330,21 @@ class FloatingDType(DType[FloatingT]): @dataclasses.dataclass(frozen=True) class Float32DType(FloatingDType[core_types.float32]): - scalar_type: Final[Type[core_types.float32]] = dataclasses.field( + scalar_type: Final[type[core_types.float32]] = dataclasses.field( default=core_types.float32, init=False ) @dataclasses.dataclass(frozen=True) class Float64DType(FloatingDType[core_types.float64]): - scalar_type: Final[Type[core_types.float64]] = dataclasses.field( + scalar_type: Final[type[core_types.float64]] = dataclasses.field( default=core_types.float64, init=False ) @dataclasses.dataclass(frozen=True) class BoolDType(DType[core_types.bool]): - scalar_type: Final[Type[core_types.bool]] = dataclasses.field( + scalar_type: Final[type[core_types.bool]] = dataclasses.field( default=core_types.bool, init=False ) @@ -370,7 +368,7 @@ class GTDimsInterface(Protocol): """ @property - def __gt_dims__(self) -> Tuple[str, ...]: ... + def __gt_dims__(self) -> tuple[str, ...]: ... class GTOriginInterface(Protocol): @@ -381,7 +379,7 @@ class GTOriginInterface(Protocol): """ @property - def __gt_origin__(self) -> Tuple[int, ...]: ... + def __gt_origin__(self) -> tuple[int, ...]: ... # -- Device representation -- @@ -447,7 +445,7 @@ def __iter__(self) -> Iterator[DeviceTypeT | int]: # -- NDArrays and slices -- -SliceLike = Union[int, Tuple[int, ...], None, slice, "NDArrayObject"] +SliceLike = Union[int, tuple[int, ...], None, slice, "NDArrayObject"] class NDArrayObject(Protocol): diff --git a/src/gt4py/eve/codegen.py b/src/gt4py/eve/codegen.py index 3869ff313b..11efe01bda 100644 --- a/src/gt4py/eve/codegen.py +++ b/src/gt4py/eve/codegen.py @@ -33,15 +33,11 @@ Callable, ClassVar, Collection, - Dict, Iterator, - List, Mapping, Optional, Protocol, Sequence, - Set, - Tuple, TypeVar, Union, overload, @@ -52,7 +48,7 @@ SourceFormatter = Callable[[str], str] -SOURCE_FORMATTERS: Dict[str, SourceFormatter] = {} +SOURCE_FORMATTERS: dict[str, SourceFormatter] = {} """Global dict storing registered formatters.""" @@ -100,7 +96,7 @@ def format_python_source( source: str, *, line_length: int = 100, - python_versions: Optional[Set[str]] = None, + python_versions: Optional[set[str]] = None, string_normalization: bool = True, ) -> str: """Format Python source code using black formatter.""" @@ -188,7 +184,7 @@ def format_source(language: str, source: str, *, skip_errors: bool = True, **kwa class Name: """Text formatter with different case styles for symbol names in source code.""" - words: List[str] + words: list[str] @classmethod def from_string(cls, name: str, case_style: utils.CaseStyleConverter.CASE_STYLE) -> Name: @@ -248,7 +244,7 @@ def __init__( self.indent_size = indent_size self.indent_char = indent_char self.end_line = end_line - self.lines: List[str] = [] + self.lines: list[str] = [] def append(self, new_line: str, *, update_indent: int = 0) -> TextBlock: if update_indent > 0: @@ -404,7 +400,7 @@ class BaseTemplate(Template): """Helper class to add source location info of template definitions.""" definition: Any - definition_loc: Optional[Tuple[str, int]] + definition_loc: Optional[tuple[str, int]] def __init__(self) -> None: self.definition_loc = None @@ -617,7 +613,7 @@ def __init_subclass__(cls, *, inherit_templates: bool = True, **kwargs: Any) -> if "__templates__" in cls.__dict__: raise TypeError(f"Invalid '__templates__' member in class {cls}") - templates: Dict[str, Template] = {} + templates: dict[str, Template] = {} if inherit_templates: for templated_gen_class in reversed(cls.__mro__[1:]): if ( @@ -719,7 +715,7 @@ def generic_visit(self, node: RootNode, **kwargs: Any) -> Union[str, Collection[ return self.generic_dump(node, **kwargs) - def get_template(self, node: RootNode) -> Tuple[Optional[Template], Optional[str]]: + def get_template(self, node: RootNode) -> tuple[Optional[Template], Optional[str]]: """Get a template for a node instance (see class documentation).""" template: Optional[Template] = None template_key = None @@ -750,8 +746,8 @@ def render_template( _this_module=sys.modules[type(self).__module__], ) - def transform_children(self, node: Node, **kwargs: Any) -> Dict[str, Any]: + def transform_children(self, node: Node, **kwargs: Any) -> dict[str, Any]: return {key: self.visit(value, **kwargs) for key, value in node.iter_children_items()} # type: ignore[misc] - def transform_annexed_items(self, node: Node, **kwargs: Any) -> Dict[str, Any]: + def transform_annexed_items(self, node: Node, **kwargs: Any) -> dict[str, Any]: return {key: self.visit(value, **kwargs) for key, value in node.annex.items()} diff --git a/src/gt4py/eve/concepts.py b/src/gt4py/eve/concepts.py index 9bd4e10ebf..2fd87ef6ac 100644 --- a/src/gt4py/eve/concepts.py +++ b/src/gt4py/eve/concepts.py @@ -16,20 +16,7 @@ from . import datamodels, exceptions, extended_typing as xtyping, trees, utils from .datamodels import validators as _validators -from .extended_typing import ( - Any, - ClassVar, - Dict, - Final, - Iterable, - List, - Optional, - Set, - Tuple, - Type, - TypeVar, - Union, -) +from .extended_typing import Any, ClassVar, Final, Iterable, Optional, TypeVar, Union from .type_definitions import ConstrainedStr, IntEnum, StrEnum @@ -92,11 +79,11 @@ def __str__(self) -> str: class SourceLocationGroup: """A group of merged source code locations (with optional info).""" - locations: Tuple[SourceLocation, ...] = datamodels.field(validator=_validators.non_empty()) - context: Optional[Union[str, Tuple[str, ...]]] + locations: tuple[SourceLocation, ...] = datamodels.field(validator=_validators.non_empty()) + context: Optional[Union[str, tuple[str, ...]]] def __init__( - self, *locations: SourceLocation, context: Optional[Union[str, Tuple[str, ...]]] = None + self, *locations: SourceLocation, context: Optional[Union[str, tuple[str, ...]]] = None ) -> None: self.__auto_init__(locations=locations, context=context) # type: ignore[attr-defined] # __auto_init__ added dynamically @@ -112,11 +99,11 @@ def __str__(self) -> str: class AnnexManager: - register: ClassVar[Dict[str, Any]] = {} + register: ClassVar[dict[str, Any]] = {} @classmethod def register_user( - cls: Type[AnnexManager], + cls: type[AnnexManager], key: str, type_hint: xtyping.TypeAnnotation, *, @@ -193,7 +180,7 @@ def iter_children_values(self) -> Iterable: for name in self.__datamodel_fields__.keys(): yield getattr(self, name) - def iter_children_items(self) -> Iterable[Tuple[trees.TreeKey, Any]]: + def iter_children_items(self) -> Iterable[tuple[trees.TreeKey, Any]]: for name in self.__datamodel_fields__.keys(): yield name, getattr(self, name) @@ -209,7 +196,7 @@ def iter_children_items(self) -> Iterable[Tuple[trees.TreeKey, Any]]: walk_items = trees.walk_items walk_values = trees.walk_values - def copy(self: _T, update: Dict[str, Any]) -> _T: + def copy(self: _T, update: dict[str, Any]) -> _T: new_node = copy.deepcopy(self) for k, v in update.items(): setattr(new_node, k, v) @@ -219,7 +206,7 @@ def copy(self: _T, update: Dict[str, Any]) -> _T: NodeT = TypeVar("NodeT", bound="Node") ValueNode = Union[bool, bytes, int, float, str, IntEnum, StrEnum] LeafNode = Union[NodeT, ValueNode] -CollectionNode = Union[List[LeafNode], Dict[Any, LeafNode], Set[LeafNode]] +CollectionNode = Union[list[LeafNode], dict[Any, LeafNode], set[LeafNode]] RootNode = Union[NodeT, CollectionNode] diff --git a/src/gt4py/eve/datamodels/__init__.py b/src/gt4py/eve/datamodels/__init__.py index 5f6806c5dd..178b8ff276 100644 --- a/src/gt4py/eve/datamodels/__init__.py +++ b/src/gt4py/eve/datamodels/__init__.py @@ -72,7 +72,7 @@ >>> class AnotherSampleModel(DataModel): ... name: str - ... friends: List[str] + ... friends: list[str] ... ... @root_validator ... def _root_validator(cls, instance): diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index 1eb43ada8f..1ecda8cc5e 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -37,18 +37,14 @@ Any, Callable, ClassVar, - Dict, Final, ForwardRef, Generator, - List, Literal, Mapping, Optional, Protocol, Sequence, - Tuple, - Type, TypeAlias, TypeAnnotation, TypeVar, @@ -73,7 +69,7 @@ class _AttrsClassTP(Protocol): - __attrs_attrs__: ClassVar[Tuple[attr.Attribute, ...]] = () + __attrs_attrs__: ClassVar[tuple[attr.Attribute, ...]] = () Attribute: TypeAlias = attr.Attribute @@ -89,7 +85,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: ... utils.FrozenNamespace[Attribute], None ) __datamodel_root_validators__: ClassVar[ - Tuple[xtyping.NonDataDescriptor[DataModelTP, BoundRootValidator], ...] + tuple[xtyping.NonDataDescriptor[DataModelTP, BoundRootValidator], ...] ] = () # Optional __auto_init__: ClassVar[Callable[..., None]] = cast(Callable[..., None], None) @@ -122,12 +118,12 @@ def __subclasshook__(cls, subclass: type) -> bool: class GenericDataModelTP(DataModelTP, Protocol): - __args__: ClassVar[Tuple[Union[Type, TypeVar], ...]] = () - __parameters__: ClassVar[Tuple[TypeVar, ...]] = () + __args__: ClassVar[tuple[Union[type[Any], TypeVar], ...]] = () + __parameters__: ClassVar[tuple[TypeVar, ...]] = () @classmethod def __class_getitem__( - cls: Type[GenericDataModelTP], args: Union[Type, Tuple[Type, ...]] + cls: type[GenericDataModelTP], args: Union[type[Any], tuple[type[Any], ...]] ) -> Union[DataModelTP, GenericDataModelTP]: ... @@ -139,7 +135,7 @@ def __class_getitem__( FieldValidator = Callable[[_DM, Attribute, _T], None] BoundFieldValidator = Callable[[Attribute, _T], None] -RootValidator = Callable[[Type[_DM], _DM], None] +RootValidator = Callable[[type[_DM], _DM], None] BoundRootValidator = Callable[[_DM], None] FieldTypeValidatorFactory = Callable[[TypeAnnotation, str], FieldValidator] @@ -282,12 +278,12 @@ def datamodel( coerce: bool = _COERCE_DEFAULT, generic: bool = _GENERIC_DEFAULT, type_validation_factory: Optional[FieldTypeValidatorFactory] = DefaultFieldTypeValidatorFactory, -) -> Callable[[Type[_T]], Type[_T]]: ... +) -> Callable[[type[_T]], type[_T]]: ... @overload def datamodel( # redefinition of unused symbol - cls: Type[_T], + cls: type[_T], /, *, repr: bool = _REPR_DEFAULT, @@ -301,12 +297,12 @@ def datamodel( # redefinition of unused symbol coerce: bool = _COERCE_DEFAULT, generic: bool = _GENERIC_DEFAULT, type_validation_factory: Optional[FieldTypeValidatorFactory] = DefaultFieldTypeValidatorFactory, -) -> Type[_T]: ... +) -> type[_T]: ... # TODO(egparedes): Use @dataclass_transform(eq_default=True, field_specifiers=("field",)) def datamodel( # redefinition of unused symbol - cls: Optional[Type[_T]] = None, + cls: Optional[type[_T]] = None, /, *, repr: bool = _REPR_DEFAULT, # noqa: A002 [builtin-argument-shadowing] @@ -320,7 +316,7 @@ def datamodel( # redefinition of unused symbol coerce: bool = _COERCE_DEFAULT, generic: bool = _GENERIC_DEFAULT, type_validation_factory: Optional[FieldTypeValidatorFactory] = DefaultFieldTypeValidatorFactory, -) -> Union[Type[_T], Callable[[Type[_T]], Type[_T]]]: +) -> Union[type[_T], Callable[[type[_T]], type[_T]]]: """Add generated special methods to classes according to the specified attributes (class decorator). It converts the class to an `attrs `_ with some extra features. @@ -397,7 +393,7 @@ def datamodel( # redefinition of unused symbol class _DataModelDecoratorTP(Protocol[_T]): def __call__( self, - cls: Optional[Type[_T]] = None, + cls: Optional[type[_T]] = None, /, *, repr: bool = _REPR_DEFAULT, # noqa: A002 [builtin-argument-shadowing] @@ -412,7 +408,7 @@ def __call__( type_validation_factory: Optional[ FieldTypeValidatorFactory ] = DefaultFieldTypeValidatorFactory, - ) -> Union[Type[_T], Callable[[Type[_T]], Type[_T]]]: ... + ) -> Union[type[_T], Callable[[type[_T]], type[_T]]]: ... frozenmodel: _DataModelDecoratorTP = functools.partial(datamodel, frozen=True) @@ -556,10 +552,9 @@ def field( Examples: - >>> from typing import List >>> @datamodel ... class C: - ... mylist: List[int] = field(default_factory=lambda: [1, 2, 3]) + ... mylist: list[int] = field(default_factory=lambda: [1, 2, 3]) >>> c = C() >>> c.mylist [1, 2, 3] @@ -638,25 +633,24 @@ def is_datamodel(obj: Any) -> bool: return hasattr(cls, MODEL_FIELD_DEFINITIONS_ATTR) -def is_generic_datamodel_class(cls: Type) -> bool: +def is_generic_datamodel_class(cls: type[Any]) -> bool: """Return ``True`` if `obj` is a generic Data Model class with type parameters.""" assert isinstance(cls, type) return is_datamodel(cls) and xtyping.has_type_parameters(cls) -def get_fields(model: Union[DataModel, Type[DataModel]]) -> utils.FrozenNamespace: +def get_fields(model: Union[DataModel, type[DataModel]]) -> utils.FrozenNamespace: """Return the field meta-information of a Data Model. Arguments: model: A Data Model class or instance. Examples: - >>> from typing import List >>> @datamodel ... class Model: ... name: str ... amount: int = 1 - ... numbers: List[float] = field(default_factory=list) + ... numbers: list[float] = field(default_factory=list) >>> fields(Model) # doctest:+ELLIPSIS FrozenNamespace(...name=Attribute(name='name', default=NOTHING, ... @@ -677,8 +671,8 @@ def get_fields(model: Union[DataModel, Type[DataModel]]) -> utils.FrozenNamespac def asdict( instance: DataModel, *, - value_serializer: Optional[Callable[[Type[DataModel], Attribute, Any], Any]] = None, -) -> Dict[str, Any]: + value_serializer: Optional[Callable[[type[DataModel], Attribute, Any], Any]] = None, +) -> dict[str, Any]: """Return the contents of a Data Model instance as a new mapping from field names to values. Arguments: @@ -701,7 +695,7 @@ def asdict( return attrs.asdict(instance, value_serializer=value_serializer) -def astuple(instance: DataModel) -> Tuple[Any, ...]: +def astuple(instance: DataModel) -> tuple[Any, ...]: """Return the contents of a Data Model instance as a new tuple of field values. Arguments: @@ -731,8 +725,8 @@ def astuple(instance: DataModel) -> Tuple[Any, ...]: def update_forward_refs( - model_cls: Type[_DataModelT], localns: Optional[Dict[str, Any]] = None -) -> Type[_DataModelT]: + model_cls: type[_DataModelT], localns: Optional[dict[str, Any]] = None +) -> type[_DataModelT]: """Update Data Model class meta-information replacing forwarded type annotations with actual types. Arguments: @@ -778,14 +772,14 @@ def update_forward_refs( def concretize( - datamodel_cls: Type[GenericDataModelT], + datamodel_cls: type[GenericDataModelT], /, - *type_args: Type, + *type_args: type[Any], class_name: Optional[str] = None, module: Optional[str] = None, support_pickling: bool = True, overwrite_definition: bool = True, -) -> Type[DataModelT]: +) -> type[DataModelT]: """Generate a new concrete subclass of a generic Data Model. Arguments: @@ -805,7 +799,7 @@ def concretize( the target module will be overwritten. """ - concrete_cls: Type[DataModelT] = _make_concrete_with_cache( + concrete_cls: type[DataModelT] = _make_concrete_with_cache( datamodel_cls, # type: ignore[arg-type] *type_args, class_name=class_name, @@ -832,7 +826,7 @@ def concretize( # -- Helpers -- -def _collect_field_validators(cls: Type) -> Dict[str, FieldValidator]: +def _collect_field_validators(cls: type[Any]) -> dict[str, FieldValidator]: result = {} for member in cls.__dict__.values(): if hasattr(member, _FIELD_VALIDATOR_TAG): @@ -843,7 +837,7 @@ def _collect_field_validators(cls: Type) -> Dict[str, FieldValidator]: return result -def _collect_root_validators(cls: Type) -> List[RootValidator]: +def _collect_root_validators(cls: type[Any]) -> list[RootValidator]: result = [] for base in reversed(cls.__mro__[1:]): for validator in getattr(base, MODEL_ROOT_VALIDATORS_ATTR, []): @@ -859,7 +853,7 @@ def _collect_root_validators(cls: Type) -> List[RootValidator]: def _get_attribute_from_bases( - name: str, mro: Tuple[Type, ...], annotations: Optional[Dict[str, Any]] = None + name: str, mro: tuple[type[Any], ...], annotations: Optional[dict[str, Any]] = None ) -> Optional[Attribute]: for base in mro: for base_field_attrib in getattr(base, "__attrs_attrs__", []): @@ -872,8 +866,8 @@ def _get_attribute_from_bases( def _substitute_typevars( - type_hint: Type, type_params_map: Mapping[TypeVar, Union[Type, TypeVar]] -) -> Tuple[Union[Type, TypeVar], bool]: + type_hint: type[Any], type_params_map: Mapping[TypeVar, Union[type[Any], TypeVar]] +) -> tuple[Union[type[Any], TypeVar], bool]: if isinstance(type_hint, typing.TypeVar): assert type_hint in type_params_map return type_params_map[type_hint], True @@ -958,14 +952,14 @@ def __pretty__( def _make_data_model_class_getitem() -> classmethod: def __class_getitem__( - cls: Type[GenericDataModelT], args: Union[Type, Tuple[Type]] - ) -> Type[DataModelT] | Type[GenericDataModelT]: + cls: type[GenericDataModelT], args: Union[type[Any], tuple[type[Any]]] + ) -> type[DataModelT] | type[GenericDataModelT]: """Return an instance compatible with aliases created by :class:`typing.Generic` classes. See :class:`GenericDataModelAlias` for further information. """ - type_args: Tuple[Type] = args if isinstance(args, tuple) else (args,) - concrete_cls: Type[DataModelT] = concretize(cls, *type_args) + type_args: tuple[type[Any]] = args if isinstance(args, tuple) else (args,) + concrete_cls: type[DataModelT] = concretize(cls, *type_args) return concrete_cls return classmethod(__class_getitem__) @@ -1048,7 +1042,7 @@ def _is_strictly_immutable_type(type_annotation: TypeAnnotation) -> bool: return bool(type_args) and all(map(_is_strictly_immutable_type, type_args)) if origin_type is not None: - # Parametrized generic alias ('tuple[int, ...]', 'List[int]', ...). The alias + # Parametrized generic alias ('tuple[int, ...]', 'list[int]', ...). The alias # itself is a hashable object, so it has to be decomposed: both the container # type and every type argument must be strictly immutable, since the hash of # a container folds in the hashes of the items it holds. @@ -1071,7 +1065,7 @@ def _is_strictly_immutable_type(type_annotation: TypeAnnotation) -> bool: def _make_datamodel( - cls: Type[_T], + cls: type[_T], *, repr: bool, # noqa: A002 [builtin-argument-shadowing] eq: bool, @@ -1085,13 +1079,13 @@ def _make_datamodel( generic: bool | Literal["True_no_checks"], type_validation_factory: Optional[FieldTypeValidatorFactory], _stacklevel_offset: int = 0, -) -> Type[_T]: +) -> type[_T]: """Actual implementation of the Data Model creation. See :func:`datamodel` for the description of the parameters. """ - mro_bases: Tuple[Type, ...] = cls.__mro__[1:] + mro_bases: tuple[type[Any], ...] = cls.__mro__[1:] if "__annotations__" not in cls.__dict__ and "__annotate_func__" not in cls.__dict__: cls.__annotations__ = {} @@ -1316,11 +1310,11 @@ def __get__(self, instance: Any, owner_class: type | None = None) -> str | None: @utils.optional_lru_cache(maxsize=None, typed=True) def _make_concrete_with_cache( - datamodel_cls: Type[GenericDataModelT], - *type_args: Type, + datamodel_cls: type[GenericDataModelT], + *type_args: type[Any], class_name: Optional[str] = None, module: Optional[str] = None, -) -> Type[DataModelT]: +) -> type[DataModelT]: if not is_generic_datamodel_class(datamodel_cls): raise TypeError(f"'{datamodel_cls.__name__}' is not a generic model class.") for t in type_args: @@ -1424,7 +1418,7 @@ class FrozenModel(DataModel, frozen=True): class GenericDataModel(GenericDataModelTP): @classmethod def __class_getitem__( - cls: Type[GenericDataModelTP], args: Union[Type, Tuple[Type, ...]] + cls: type[GenericDataModelTP], args: Union[type[Any], tuple[type[Any], ...]] ) -> Union[DataModelTP, GenericDataModelTP]: ... else: diff --git a/src/gt4py/eve/exceptions.py b/src/gt4py/eve/exceptions.py index d240f2ffe8..eea27d0f6a 100644 --- a/src/gt4py/eve/exceptions.py +++ b/src/gt4py/eve/exceptions.py @@ -10,7 +10,7 @@ from __future__ import annotations -from .extended_typing import Any, Dict, Optional +from .extended_typing import Any, Optional class EveError: @@ -25,7 +25,7 @@ class EveError: """ message_template = "Generic Eve error [{info}]" - info: Dict[str, Any] + info: dict[str, Any] def __init__(self, message: Optional[str] = None, **kwargs: Any) -> None: self.info = kwargs diff --git a/src/gt4py/eve/extended_typing.py b/src/gt4py/eve/extended_typing.py index 56c795912f..66b8f24ca5 100644 --- a/src/gt4py/eve/extended_typing.py +++ b/src/gt4py/eve/extended_typing.py @@ -34,20 +34,14 @@ from typing_extensions import * # type: ignore[assignment,no-redef] # noqa: F403 [undefined-local-with-import-star] -# The standard library has supported PEP 585 (Type Hinting Generics In Standard -# Collections) since Python 3.9, so the deprecated 'typing' aliases star-imported above -# are replaced here by the standard collection types. This block must stay *below* the -# star imports, since it deliberately rebinds names those imports also define; the -# 'isort: split' marker keeps the import sorter from hoisting it. +# Re-export the standard collection types under the names the star imports above bind to +# their deprecated 'typing' counterparts, so that e.g. 'Sequence' is +# 'collections.abc.Sequence' rather than 'typing.Sequence'. This block must stay *below* +# the star imports, since it deliberately rebinds names those imports also define; the +# 'isort: split' marker keeps the import sorter from hoisting it. The builtin generics +# are deliberately not re-exported under their old 'typing' spellings; see +# '_DEPRECATED_TYPING_ALIASES' below. # isort: split -from builtins import ( # type: ignore[assignment] - dict as Dict, - frozenset as FrozenSet, - list as List, - set as Set, - tuple as Tuple, - type as Type, -) from collections import ( ChainMap as ChainMap, Counter as Counter, @@ -87,6 +81,46 @@ from re import Match as Match, Pattern as Pattern +# The 'typing' aliases of the builtin generics are deprecated since PEP 585 and are no +# longer re-exported: use the builtin spelling instead. They are not simply absent from +# this module -- the star imports above bind them, and '__getattr__' below would happily +# forward them to 'typing' -- so they are dropped from the namespace here and rejected +# explicitly. Without this, removing them would silently downgrade every use site from +# the builtin to the deprecated 'typing' object. Note that this is a runtime guarantee +# only: a type checker still resolves the names through the star imports. +_DEPRECATED_TYPING_ALIASES: Final[Mapping[str, str]] = { + "Dict": "dict", + "FrozenSet": "frozenset", + "List": "list", + "Set": "set", + "Tuple": "tuple", + "Type": "type", +} + +for _alias in _DEPRECATED_TYPING_ALIASES: + globals().pop(_alias, None) +del _alias + + +class _ForwardRefTypingNamespace: + """Namespace bound to the name 'typing' while evaluating forward references. + + Annotations are resolved through this module, so that 'typing_extensions' + definitions take priority and the standard collection types are used. The + deprecated builtin aliases are not re-exported here, but they remain perfectly + valid in user-written annotations, so 'typing.List[int]' and friends fall back + to the real 'typing' module instead of raising. + """ + + def __getattr__(self, name: str) -> Any: + if name in _DEPRECATED_TYPING_ALIASES: + return getattr(_typing, name) + return getattr(_sys.modules[__name__], name) + + +_FORWARD_REF_TYPING_NS: Final = _ForwardRefTypingNamespace() + + # These fallbacks are useful for public symbols not exported by default. # Again, definitions in 'typing_extensions' take priority over those in 'typing' def __getattr__(name: str) -> Any: @@ -94,6 +128,12 @@ def __getattr__(name: str) -> Any: import typing_extensions + if (replacement := _DEPRECATED_TYPING_ALIASES.get(name)) is not None: + raise AttributeError( + f"'{name}' is a deprecated 'typing' alias (PEP 585) and is not exported by" + f" '{__name__}'. Use '{replacement}' instead." + ) + result = SENTINEL = object() if not (name.startswith("__") and name.endswith("__")): result = getattr(typing_extensions, name, SENTINEL) @@ -110,15 +150,17 @@ def __getattr__(name: str) -> Any: return result -def __dir__() -> List[str]: +def __dir__() -> list[str]: if not hasattr(self_func := (globals()["__dir__"]), "__cached_dir"): import typing import typing_extensions orig_dir = typing.__dir__() - self_func.__cached_dir = [*orig_dir] + [ - name for name in typing_extensions.__dir__() if name not in orig_dir + self_func.__cached_dir = [ + name + for name in [*orig_dir, *(n for n in typing_extensions.__dir__() if n not in orig_dir)] + if name not in _DEPRECATED_TYPING_ALIASES ] return self_func.__cached_dir @@ -137,8 +179,8 @@ def __call__(self, *args: _A) -> _R: ... _T_co = TypeVar("_T_co", covariant=True) NestedSequence = Sequence[Union[_T_co, "NestedSequence[_T_co]"]] -NestedList = List[Union[_T_co, "NestedList[_T_co]"]] -NestedTuple = Tuple[Union[_T_co, "NestedTuple[_T_co]"], ...] +NestedList = list[Union[_T_co, "NestedList[_T_co]"]] +NestedTuple = tuple[Union[_T_co, "NestedTuple[_T_co]"], ...] MaybeNested = Union[_T_co, NestedSequence[_T_co]] MaybeNestedInSequence = Union[_T_co, NestedSequence[_T_co]] @@ -163,7 +205,7 @@ def is_maybe_nested_in_tuple_of( # -- Typing annotations -- SingleTypeAnnotation = Union[ - Type, + type[Any], _types.GenericAlias, _typing._BaseGenericAlias, # type: ignore[name-defined] # _BaseGenericAlias is not exported in stub ] @@ -173,13 +215,13 @@ def is_maybe_nested_in_tuple_of( TypeAnnotation = Union[ForwardRef, SolvedTypeAnnotation] SourceTypeAnnotation = Union[str, TypeAnnotation] -StdGenericAliasType: Final[Type] = type(List[int]) +StdGenericAliasType: Final[type[Any]] = type(list[int]) if TYPE_CHECKING: StdGenericAlias: TypeAlias = _types.GenericAlias -_TypingSpecialFormType: Final[Type] = _typing._SpecialForm -_TypingGenericAliasType: Final[Type] = _typing._BaseGenericAlias # type: ignore[attr-defined] # _BaseGenericAlias / _GenericAlias are not exported in stub +_TypingSpecialFormType: Final[type[Any]] = _typing._SpecialForm +_TypingGenericAliasType: Final[type[Any]] = _typing._BaseGenericAlias # type: ignore[attr-defined] # _BaseGenericAlias / _GenericAlias are not exported in stub # -- Standard Python protocols -- @@ -195,14 +237,14 @@ class NonDataDescriptor(Protocol[_C, _V]): @overload def __get__( - self, _instance: Literal[None], _owner_type: Optional[Type[_C]] = None + self, _instance: Literal[None], _owner_type: Optional[type[_C]] = None ) -> NonDataDescriptor[_C, _V]: ... @overload - def __get__(self, _instance: _C, _owner_type: Optional[Type[_C]] = None) -> _V: ... + def __get__(self, _instance: _C, _owner_type: Optional[type[_C]] = None) -> _V: ... def __get__( - self, _instance: Optional[_C], _owner_type: Optional[Type[_C]] = None + self, _instance: Optional[_C], _owner_type: Optional[type[_C]] = None ) -> _V | NonDataDescriptor[_C, _V]: ... @@ -303,15 +345,15 @@ def supports_array(value: Any) -> TypeGuard[SupportsArray]: class ArrayInterface(Protocol): @property - def __array_interface__(self) -> Dict[str, Any]: ... + def __array_interface__(self) -> dict[str, Any]: ... class ArrayInterfaceTypedDict(TypedDict): - shape: Tuple[int, ...] + shape: tuple[int, ...] typestr: str - descr: NotRequired[List[Tuple]] - data: NotRequired[Tuple[int, bool]] - strides: NotRequired[Optional[Tuple[int, ...]]] + descr: NotRequired[list[tuple]] + data: NotRequired[tuple[int, bool]] + strides: NotRequired[Optional[tuple[int, ...]]] mask: NotRequired[Optional["StrictArrayInterface"]] offset: NotRequired[int] version: int @@ -328,16 +370,16 @@ def supports_array_interface(value: Any) -> TypeGuard[ArrayInterface]: class CUDAArrayInterface(Protocol): @property - def __cuda_array_interface__(self) -> Dict[str, Any]: ... + def __cuda_array_interface__(self) -> dict[str, Any]: ... class CUDAArrayInterfaceTypedDict(TypedDict): - shape: Tuple[int, ...] + shape: tuple[int, ...] typestr: str - data: Tuple[int, bool] + data: tuple[int, bool] version: int - strides: NotRequired[Optional[Tuple[int, ...]]] - descr: NotRequired[List[Tuple]] + strides: NotRequired[Optional[tuple[int, ...]]] + descr: NotRequired[list[tuple]] mask: NotRequired[Optional["StrictCUDAArrayInterface"]] stream: NotRequired[Optional[int]] @@ -352,7 +394,7 @@ def supports_cuda_array_interface(value: Any) -> TypeGuard[CUDAArrayInterface]: return hasattr(value, "__cuda_array_interface__") -DLPackDevice = Tuple[int, int] +DLPackDevice = tuple[int, int] class MultiStreamDLPackBuffer(Protocol): @@ -399,7 +441,7 @@ def __pretty__( _ArtefactTypes = (*_ArtefactTypes, typing_exts_any) -def is_actual_type(obj: Any) -> TypeGuard[Type]: +def is_actual_type(obj: Any) -> TypeGuard[type[Any]]: """Check if an object has an actual type and instead of a typing artefact like ``GenericAlias`` or ``Any``. This is needed because since Python 3.9: @@ -425,12 +467,12 @@ def is_Any(obj: Any) -> bool: return obj is _typing.Any -def has_type_parameters(cls: Type) -> bool: +def has_type_parameters(cls: type[Any]) -> bool: """Return ``True`` if obj is a generic class with type parameters.""" return issubclass(cls, Generic) and len(getattr(cls, "__parameters__", [])) > 0 # type: ignore[arg-type] # Generic not considered as a class -def get_actual_type(obj: _T) -> Type[_T]: +def get_actual_type(obj: _T) -> type[_T]: """Return type of an object (also working for GenericAlias instances which pretend to be an actual type).""" return StdGenericAliasType if isinstance(obj, StdGenericAliasType) else type(obj) @@ -438,8 +480,8 @@ def get_actual_type(obj: _T) -> Type[_T]: def get_represented_types( type_annotation: TypeAnnotation, *, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, ) -> tuple[type, ...]: """Return a tuple with all the actual types contained in a type annotation.""" @@ -478,7 +520,7 @@ def recurse_all(annotations: Iterable[TypeAnnotation]) -> tuple[type, ...]: return () -def is_type_with_custom_hash(type_: Type) -> bool: +def is_type_with_custom_hash(type_: type[Any]) -> bool: return type_.__hash__ not in (None, object.__hash__) @@ -629,10 +671,10 @@ def get_partial_type_hints( _types.MethodWrapperType, _types.MethodDescriptorType, ], - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, include_extras: bool = False, -) -> Dict[str, Union[Type, ForwardRef]]: +) -> dict[str, Union[type[Any], ForwardRef]]: """Return a dictionary with type hints (using forward refs for undefined names) for a function, method, module or class object. For each member type hint in the object a :class:`typing.ForwardRef` instance will be @@ -646,7 +688,7 @@ def get_partial_type_hints( obj, globalns=globalns, localns=localns, include_extras=include_extras ) - hints: Dict[str, Union[Type, ForwardRef]] = {} + hints: dict[str, Union[type[Any], ForwardRef]] = {} annotations = getattr(obj, "__annotations__", {}) for name, hint in annotations.items(): obj.__annotations__ = {name: hint} @@ -673,8 +715,8 @@ def get_partial_type_hints( def eval_forward_ref( ref: Union[str, ForwardRef], - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, *, include_extras: bool = False, ) -> SolvedTypeAnnotation: @@ -688,9 +730,8 @@ def eval_forward_ref( include_extras: if ``True``, ``Annotated`` hints will preserve the annotation. Examples: - >>> from typing import Dict, Tuple - >>> print("Result:", eval_forward_ref("Dict[str, Tuple[int, float]]")) - Result: ...ict[str, ...uple[int, float]] + >>> print("Result:", eval_forward_ref("dict[str, tuple[int, float]]")) + Result: dict[str, tuple[int, float]] """ @@ -699,7 +740,7 @@ def f() -> None: ... f.__annotations__ = {"return": ForwardRef(ref) if isinstance(ref, str) else ref} safe_localns = {**localns} if localns else {} - safe_localns.setdefault("typing", _sys.modules[__name__]) + safe_localns.setdefault("typing", _FORWARD_REF_TYPING_NS) safe_localns.setdefault("NoneType", type(None)) actual_type = get_type_hints(f, globalns, safe_localns, include_extras=include_extras)["return"] @@ -708,7 +749,7 @@ def f() -> None: ... return actual_type -def _collapse_type_args(*args: Any) -> Tuple[bool, Tuple]: +def _collapse_type_args(*args: Any) -> tuple[bool, tuple]: if args and all(args[0] == a for a in args[1:]): return (True, args) else: @@ -718,7 +759,7 @@ def _collapse_type_args(*args: Any) -> Tuple[bool, Tuple]: @final @_dataclasses.dataclass class CallableKwargsInfo: - data: Dict[str, Any] + data: dict[str, Any] def infer_type( @@ -763,7 +804,7 @@ def infer_type( >>> print("Result:", infer_type(f)) Result: ...Callable[..., int] - >>> print("Result:", infer_type(Dict[int, Union[int, float]])) + >>> print("Result:", infer_type(dict[int, Union[int, float]])) Result: ...ict[int, ...int...float...] For advanced cases, using :func:`functools.singledispatch` with custom hooks @@ -794,7 +835,7 @@ def infer_type( return type(None) if none_as_type else None if isinstance(value, type): - return Type[value] + return type[value] if isinstance(value, tuple) and not isinstance(value, TypedNamedTupleABC): # Special case for tuples, which can have multiple types. @@ -807,7 +848,7 @@ def infer_type( return StdGenericAliasType(tuple, (Any, ...)) if isinstance(value, (list, set, frozenset)): - t: Union[Type[List], Type[Set], Type[FrozenSet]] = type(value) + t: Union[type[list], type[set], type[frozenset]] = type(value) unique_type, args = _collapse_type_args(*(_infer(item) for item in value)) return StdGenericAliasType(t, args[0] if unique_type else Any) @@ -824,8 +865,8 @@ def infer_type( return_type = annotations.get("return", Any) sig = _inspect.signature(value) - arg_types: List = [] - kwonly_arg_types: Dict[str, Any] = {} + arg_types: list = [] + kwonly_arg_types: dict[str, Any] = {} for p in sig.parameters.values(): if p.kind in ( _inspect.Parameter.POSITIONAL_ONLY, diff --git a/src/gt4py/eve/pattern_matching.py b/src/gt4py/eve/pattern_matching.py index 35a6baccb2..783d99e29c 100644 --- a/src/gt4py/eve/pattern_matching.py +++ b/src/gt4py/eve/pattern_matching.py @@ -12,7 +12,7 @@ from functools import singledispatch -from .extended_typing import Any, Iterator, Tuple +from .extended_typing import Any, Iterator class ObjectPattern: @@ -60,7 +60,7 @@ def __str__(self) -> str: @singledispatch -def get_differences(a: Any, b: Any, path: str = "") -> Iterator[Tuple[str, str]]: +def get_differences(a: Any, b: Any, path: str = "") -> Iterator[tuple[str, str]]: """Compare two objects and return a list of differences. If the arguments are lists or dictionaries comparison is recursively per @@ -77,7 +77,7 @@ def get_differences(a: Any, b: Any, path: str = "") -> Iterator[Tuple[str, str]] @get_differences.register -def _(a: ObjectPattern, b: Any, path: str = "") -> Iterator[Tuple[str, str]]: +def _(a: ObjectPattern, b: Any, path: str = "") -> Iterator[tuple[str, str]]: if not isinstance(b, a.cls): yield (path, f"Expected an instance of class {a.cls.__name__}, but got {type(b).__name__}") else: @@ -89,7 +89,7 @@ def _(a: ObjectPattern, b: Any, path: str = "") -> Iterator[Tuple[str, str]]: @get_differences.register -def _(a: list, b: Any, path: str = "") -> Iterator[Tuple[str, str]]: +def _(a: list, b: Any, path: str = "") -> Iterator[tuple[str, str]]: if not isinstance(b, list): yield (path, f"Expected list, but got {type(b).__name__}") elif len(a) != len(b): @@ -100,7 +100,7 @@ def _(a: list, b: Any, path: str = "") -> Iterator[Tuple[str, str]]: @get_differences.register -def _(a: dict, b: Any, path: str = "") -> Iterator[Tuple[str, str]]: +def _(a: dict, b: Any, path: str = "") -> Iterator[tuple[str, str]]: if not isinstance(b, dict): yield (path, f"Expected dict, but got {type(b).__name__}") elif set(a.keys()) != set(b.keys()): diff --git a/src/gt4py/eve/traits.py b/src/gt4py/eve/traits.py index eb39a38152..73caee10fe 100644 --- a/src/gt4py/eve/traits.py +++ b/src/gt4py/eve/traits.py @@ -13,11 +13,11 @@ import collections from . import concepts, datamodels, exceptions, visitors -from .extended_typing import Any, Dict, Set, Type, no_type_check +from .extended_typing import Any, no_type_check # --- Node Traits --- -@concepts.register_annex_user("symtable", Dict[str, concepts.Node], shared=True) +@concepts.register_annex_user("symtable", dict[str, concepts.Node], shared=True) @datamodels.datamodel class SymbolTableTrait: """ @@ -33,13 +33,13 @@ class SymbolTableTrait: @no_type_check @datamodels.root_validator @classmethod - def _collect_symbol_names(cls: Type[SymbolTableTrait], instance: concepts.Node) -> None: + def _collect_symbol_names(cls: type[SymbolTableTrait], instance: concepts.Node) -> None: collected_symbols = cls.SymbolsCollector.apply(instance) instance.annex.symtable = collected_symbols class SymbolsCollector(visitors.NodeVisitor): def __init__(self) -> None: - self.collected_symbols: Dict[str, concepts.Node] = {} + self.collected_symbols: dict[str, concepts.Node] = {} def visit_Node(self, node: concepts.Node, /) -> None: for field_name, attribute in node.__datamodel_fields__.items(): @@ -70,7 +70,7 @@ def visit(self, node: concepts.RootNode, **kwargs: Any) -> Any: return super().visit(node, **kwargs) @classmethod - def apply(cls, node: concepts.Node) -> Dict[str, concepts.Node]: + def apply(cls, node: concepts.Node) -> dict[str, concepts.Node]: collector = cls() # If the passed root node already contains a symbol table, the check in `visit_Node()` # will automatically stop the traversal. To avoid this premature stop, we start the @@ -82,7 +82,7 @@ def apply(cls, node: concepts.Node) -> Dict[str, concepts.Node]: return collector.collected_symbols -@concepts.register_annex_user("symtable", Dict[str, concepts.Node], shared=True) +@concepts.register_annex_user("symtable", dict[str, concepts.Node], shared=True) @datamodels.datamodel class SymbolRefsValidatorTrait: """Node trait adding automatic validation of symbol references appearing the node tree. @@ -96,7 +96,7 @@ class SymbolRefsValidatorTrait: @no_type_check @datamodels.root_validator @classmethod - def _validate_symbol_refs(cls: Type[SymbolRefsValidatorTrait], instance: concepts.Node) -> None: + def _validate_symbol_refs(cls: type[SymbolRefsValidatorTrait], instance: concepts.Node) -> None: validator = cls.SymbolRefsValidator() symtable = instance.annex.symtable for child_node in instance.iter_children_values(): @@ -109,10 +109,10 @@ def _validate_symbol_refs(cls: Type[SymbolRefsValidatorTrait], instance: concept class SymbolRefsValidator(visitors.NodeVisitor): def __init__(self) -> None: - self.missing_symbols: Set[str] = set() + self.missing_symbols: set[str] = set() def visit_Node( - self, node: concepts.Node, *, symtable: Dict[str, Any], **kwargs: Any + self, node: concepts.Node, *, symtable: dict[str, Any], **kwargs: Any ) -> None: for field_name, attribute in node.__datamodel_fields__.items(): if isinstance(attribute.type, type) and issubclass( @@ -128,7 +128,7 @@ def visit_Node( self.generic_visit(node, symtable=symtable, **kwargs) @classmethod - def apply(cls, node: concepts.Node, *, symtable: Dict[str, Any]) -> Set[str]: + def apply(cls, node: concepts.Node, *, symtable: dict[str, Any]) -> set[str]: validator = cls() validator.visit(node, symtable=symtable) return validator.missing_symbols diff --git a/src/gt4py/eve/trees.py b/src/gt4py/eve/trees.py index bc938a0f85..6e0cce0872 100644 --- a/src/gt4py/eve/trees.py +++ b/src/gt4py/eve/trees.py @@ -20,11 +20,8 @@ Any, Callable, Iterable, - List, Optional, Protocol, - Tuple, - Type, TypeVar, Union, ) @@ -50,7 +47,7 @@ class Tree(Protocol): def iter_children_values(self) -> Iterable: ... @abc.abstractmethod - def iter_children_items(self) -> Iterable[Tuple[TreeKey, Any]]: ... + def iter_children_items(self) -> Iterable[tuple[TreeKey, Any]]: ... TreeLike.register(Tree) @@ -65,15 +62,15 @@ def iter_children_values(node: TreeLike) -> Iterable: @functools.singledispatch -def iter_children_items(node: TreeLike) -> Iterable[Tuple[TreeKey, Any]]: +def iter_children_items(node: TreeLike) -> Iterable[tuple[TreeKey, Any]]: """Create an iterator to traverse values as Eve tree nodes.""" return node.iter_children_items() if hasattr(node, "iter_children_items") else iter(()) def register_tree_like( - *types: Type[_T], + *types: type[_T], iter_values_fn: Callable[[_T], Iterable], - iter_items_fn: Callable[[_T], Iterable[Tuple[TreeKey, Any]]], + iter_items_fn: Callable[[_T], Iterable[tuple[TreeKey, Any]]], ) -> None: for t in types: TreeLike.register(t) @@ -109,7 +106,7 @@ class TraversalOrder(Enum): def _pre_walk_items( node: TreeLike, *, __key__: Optional[TreeKey] = None -) -> Iterable[Tuple[Optional[TreeKey], Any]]: +) -> Iterable[tuple[Optional[TreeKey], Any]]: """Create a pre-order tree traversal iterator of (key, value) pairs.""" yield __key__, node for key, child in iter_children_items(node): @@ -129,7 +126,7 @@ def _pre_walk_values(node: TreeLike) -> Iterable: def _post_walk_items( node: TreeLike, *, __key__: Optional[TreeKey] = None -) -> Iterable[Tuple[Optional[TreeKey], Any]]: +) -> Iterable[tuple[Optional[TreeKey], Any]]: """Create a post-order tree traversal iterator of (key, value) pairs.""" for key, child in iter_children_items(node): yield from _post_walk_items(child, __key__=key) @@ -149,8 +146,8 @@ def _post_walk_values(node: TreeLike) -> Iterable: def _bfs_walk_items( - node: TreeLike, *, __key__: Optional[TreeKey] = None, __queue__: Optional[List] = None -) -> Iterable[Tuple[Optional[TreeKey], Any]]: + node: TreeLike, *, __key__: Optional[TreeKey] = None, __queue__: Optional[list] = None +) -> Iterable[tuple[Optional[TreeKey], Any]]: """Create a tree traversal iterator of (key, value) pairs by tree levels (Breadth-First Search).""" __queue__ = __queue__ or [] yield __key__, node @@ -161,8 +158,8 @@ def _bfs_walk_items( def _bfs_walk_values( - node: TreeLike, *, __queue__: Optional[List] = None -) -> Iterable[Tuple[TreeKey, Any]]: + node: TreeLike, *, __queue__: Optional[list] = None +) -> Iterable[tuple[TreeKey, Any]]: """Create a tree traversal iterator of values by tree levels (Breadth-First Search).""" __queue__ = __queue__ or [] yield node @@ -179,7 +176,7 @@ def _bfs_walk_values( def walk_items( node: TreeLike, traversal_order: TraversalOrder = TraversalOrder.PRE_ORDER -) -> utils.XIterable[Tuple[Optional[TreeKey], Any]]: +) -> utils.XIterable[tuple[Optional[TreeKey], Any]]: """Create a tree traversal iterator of (key, value) pairs.""" if traversal_order is traversal_order.PRE_ORDER: return pre_walk_items(node=node) diff --git a/src/gt4py/eve/type_definitions.py b/src/gt4py/eve/type_definitions.py index e68ea7af55..542b7425c3 100644 --- a/src/gt4py/eve/type_definitions.py +++ b/src/gt4py/eve/type_definitions.py @@ -16,15 +16,15 @@ from boltons.typeutils import classproperty as classproperty -from .extended_typing import Any, ClassVar, NoReturn, Optional, Tuple, TypeVar, final +from .extended_typing import Any, ClassVar, NoReturn, Optional, TypeVar, final # -- Frozen collections -- _Tc = TypeVar("_Tc", covariant=True) -class FrozenList(Tuple[_Tc, ...], metaclass=abc.ABCMeta): - """Tuple subtype which works as an alias of ``Tuple[_Tc, ...]``.""" +class FrozenList(tuple[_Tc, ...], metaclass=abc.ABCMeta): + """Tuple subtype which works as an alias of ``tuple[_Tc, ...]``.""" __slots__ = () diff --git a/src/gt4py/eve/type_validation.py b/src/gt4py/eve/type_validation.py index ec66be2307..ae292b257d 100644 --- a/src/gt4py/eve/type_validation.py +++ b/src/gt4py/eve/type_validation.py @@ -20,14 +20,12 @@ from . import exceptions, extended_typing as xtyping, utils from .extended_typing import ( Any, - Dict, Final, ForwardRef, Literal, Optional, Protocol, Sequence, - Type, TypeAnnotation, Union, cast, @@ -46,8 +44,8 @@ def __call__( type_annotation: TypeAnnotation, name: Optional[str] = None, *, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, required: bool = True, **kwargs: Any, ) -> None: @@ -97,8 +95,8 @@ def __call__( name: Optional[str] = None, *, required: Literal[True] = True, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> FixedTypeValidator: ... @@ -109,8 +107,8 @@ def __call__( name: Optional[str] = None, *, required: bool = True, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> Optional[FixedTypeValidator]: ... @@ -121,8 +119,8 @@ def __call__( name: Optional[str] = None, *, required: bool = True, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> Optional[FixedTypeValidator]: """Protocol for :class:`FixedTypeValidator`s. @@ -154,8 +152,8 @@ def __call__( name: Optional[str] = None, *, required: Literal[True] = True, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> FixedTypeValidator: ... @@ -166,8 +164,8 @@ def __call__( name: Optional[str] = None, *, required: bool = True, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> Optional[FixedTypeValidator]: ... @@ -177,8 +175,8 @@ def __call__( name: Optional[str] = None, *, required: bool = True, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> Optional[FixedTypeValidator]: # TODO(egparedes): if a "typing tree" structure is implemented, refactor this code as a tree traversal. @@ -464,7 +462,7 @@ def _is_optional(value: Any, **kwargs: Any) -> None: @staticmethod def combine_validators_as_or( - name: str, *validators: FixedTypeValidator, error_type: Type[Exception] = TypeError + name: str, *validators: FixedTypeValidator, error_type: type[Exception] = TypeError ) -> FixedTypeValidator: def _combined_validator(value: Any, **kwargs: Any) -> None: for v in validators: @@ -492,8 +490,8 @@ def simple_type_validator( type_annotation: TypeAnnotation, name: Optional[str] = None, *, - globalns: Optional[Dict[str, Any]] = None, - localns: Optional[Dict[str, Any]] = None, + globalns: Optional[dict[str, Any]] = None, + localns: Optional[dict[str, Any]] = None, required: bool = True, **kwargs: Any, ) -> None: diff --git a/src/gt4py/eve/utils.py b/src/gt4py/eve/utils.py index 6f49c94108..014fbde6da 100644 --- a/src/gt4py/eve/utils.py +++ b/src/gt4py/eve/utils.py @@ -49,17 +49,12 @@ ArgsOnlyCallable, Callable, Collection, - Dict, Generic, Iterable, Iterator, - List, Literal, Optional, ParamSpec, - Set, - Tuple, - Type, TypeVar, Union, cast, @@ -88,7 +83,7 @@ def first(iterable: Iterable[T], *, default: Union[T, NothingType] = NOTHING) -> raise error -def isinstancechecker(type_info: Union[Type, Iterable[Type]]) -> Callable[[Any], bool]: +def isinstancechecker(type_info: Union[type[Any], Iterable[type[Any]]]) -> Callable[[Any], bool]: """Return a callable object that checks if operand is an instance of `type_info`. Examples: @@ -101,7 +96,7 @@ def isinstancechecker(type_info: Union[Type, Iterable[Type]]) -> Callable[[Any], False """ - types: Tuple[Type, ...] = tuple() + types: tuple[type[Any], ...] = tuple() if isinstance(type_info, type): types = (type_info,) elif not isinstance(type_info, tuple) and is_collection(type_info): @@ -264,7 +259,7 @@ class IndexerCallable(Generic[_S, _T]): func: ArgsOnlyCallable[_S, _T] - def __getitem__(self, key: _S | Tuple[_S, ...]) -> _T: + def __getitem__(self, key: _S | tuple[_S, ...]) -> _T: return self.func(*key) if isinstance(key, tuple) else self.func(key) @@ -590,7 +585,7 @@ def _decorator(func: Callable[..., Any]) -> Callable[..., Any]: return _decorator(func) if func is not None else _decorator -def register_subclasses(*subclasses: Type) -> Callable[[Type], Type]: +def register_subclasses(*subclasses: type[Any]) -> Callable[[type[Any]], type[Any]]: """Class decorator to automatically register virtual subclasses. Examples: @@ -609,7 +604,7 @@ def register_subclasses(*subclasses: Type) -> Callable[[Type], Type]: """ - def _decorator(base_cls: Type) -> Type: + def _decorator(base_cls: type[Any]) -> type[Any]: for s in subclasses: base_cls.register(s) return base_cls @@ -617,7 +612,7 @@ def _decorator(base_cls: Type) -> Type: return _decorator -def noninstantiable(cls: Type[_T]) -> Type[_T]: +def noninstantiable(cls: type[_T]) -> type[_T]: """Make a class without abstract method non-instantiable (subclasses should be instantiable).""" if not isinstance(cls, type): raise ValueError(f"Non-type value ({cls}) passed to 'noninstantiable()' class decorator.") @@ -636,7 +631,7 @@ def _noninstantiable_init(self: _T, *args: Any, **kwargs: Any) -> None: return cls -def is_noninstantiable(cls: Type[_T]) -> bool: +def is_noninstantiable(cls: type[_T]) -> bool: """Return True if `model` is a non-instantiable class.""" return "__noninstantiable__" in cls.__dict__ @@ -791,7 +786,7 @@ def dhash(obj: Any, **kwargs: Any) -> str: def pprint_ddiff( - old: Any, new: Any, *, pprint_opts: Optional[Dict[str, Any]] = None, **kwargs: Any + old: Any, new: Any, *, pprint_opts: Optional[dict[str, Any]] = None, **kwargs: Any ) -> None: """Pretty printing of deepdiff.diff.DeepDiff objects. @@ -822,14 +817,14 @@ class CASE_STYLE(enum.Enum): KEBAB = "kebab" @classmethod - def split(cls, name: str, case_style: Union[CASE_STYLE, str]) -> List[str]: + def split(cls, name: str, case_style: Union[CASE_STYLE, str]) -> list[str]: if isinstance(case_style, str): case_style = cls.CASE_STYLE(case_style) assert isinstance(case_style, cls.CASE_STYLE) if case_style == cls.CASE_STYLE.CONCATENATED: raise ValueError("Impossible to split a simply concatenated string") - splitter: Callable[[str], List[str]] = getattr(cls, f"split_{case_style.value}_case") + splitter: Callable[[str], list[str]] = getattr(cls, f"split_{case_style.value}_case") return splitter(name) @classmethod @@ -888,22 +883,22 @@ def join_kebab_case(words: AnyWordsIterable) -> str: # https://stackoverflow.com/a/29920015/7232525 # @staticmethod - def split_canonical_case(name: str) -> List[str]: + def split_canonical_case(name: str) -> list[str]: return name.split() @staticmethod - def split_camel_case(name: str) -> List[str]: + def split_camel_case(name: str) -> list[str]: matches = re.finditer(".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)", name) return [m.group(0) for m in matches] split_pascal_case = split_camel_case @staticmethod - def split_snake_case(name: str) -> List[str]: + def split_snake_case(name: str) -> list[str]: return name.split("_") @staticmethod - def split_kebab_case(name: str) -> List[str]: + def split_kebab_case(name: str) -> list[str]: return name.split("-") @@ -933,7 +928,7 @@ class Namespace(types.SimpleNamespace, Generic[T]): def __contains__(self, key: str) -> bool: return key in self.__dict__ - def items(self) -> Iterable[Tuple[str, T]]: + def items(self) -> Iterable[tuple[str, T]]: return self.__dict__.items() def keys(self) -> Iterable[str]: @@ -942,12 +937,12 @@ def keys(self) -> Iterable[str]: def values(self) -> Iterable[T]: return self.__dict__.values() - def reset(self, data: Optional[Dict[str, Any]] = None) -> None: + def reset(self, data: Optional[dict[str, Any]] = None) -> None: self.__dict__.clear() if data: self.__dict__.update(data) - def as_dict(self) -> Dict[str, T]: + def as_dict(self) -> dict[str, T]: return {**self.__dict__} asdict = as_dict @@ -1117,7 +1112,7 @@ def filter(self, func: Callable[..., bool]) -> XIterable[T]: # A003: shadowing raise TypeError(f"Invalid function or callable: '{func}'.") return XIterable(filter(func, self.iterator)) - def if_isinstance(self, *types: Type) -> XIterable[T]: + def if_isinstance(self, *types: type[Any]) -> XIterable[T]: """Filter elements using :func:`isinstance` checks. Equivalent to ``xiter(item for item in self if isinstance(item, types))``. @@ -1130,7 +1125,7 @@ def if_isinstance(self, *types: Type) -> XIterable[T]: """ return XIterable(filter(isinstancechecker([*types]), self.iterator)) - def if_not_isinstance(self, *types: Type) -> XIterable[T]: + def if_not_isinstance(self, *types: type[Any]) -> XIterable[T]: """Filter elements using negated :func:`isinstance` checks. Equivalent to ``xiter(item for item in self if not isinstance(item, types))``. @@ -1229,7 +1224,7 @@ def if_contains(self, *values: Any) -> XIterable[T]: """ - def _contains(a: Any, collection: Tuple) -> bool: + def _contains(a: Any, collection: tuple) -> bool: try: return all(operator.contains(a, v) for v in collection) except Exception: @@ -1340,7 +1335,7 @@ def chain(self, *others: Iterable) -> XIterable[Union[T, S]]: def diff( self, *others: Iterable, default: Any = NOTHING, key: Union[NOTHING, Callable] = NOTHING - ) -> XIterable[Tuple[T, S]]: + ) -> XIterable[tuple[T, S]]: """Diff iterators. Equivalent to ``toolz.itertoolz.diff(self, *others)``. @@ -1373,7 +1368,7 @@ def diff( [('Bananas', 'oranges')] """ - kwargs: Dict[str, Any] = {} + kwargs: dict[str, Any] = {} if default is not NOTHING: kwargs["default"] = default if key is not NOTHING: @@ -1384,7 +1379,7 @@ def diff( def product( self, other: Union[Iterable[S], int] - ) -> Union[XIterable[Tuple[T, S]], XIterable[Tuple[T, T]]]: + ) -> Union[XIterable[tuple[T, S]], XIterable[tuple[T, T]]]: """Product of iterators. Equivalent to ``itertools.product(it_a, it_b)``. @@ -1416,7 +1411,7 @@ def product( def partition( self, n: int, *, exact: bool = False, fill: Any = NOTHING - ) -> XIterable[Tuple[T, ...]]: + ) -> XIterable[tuple[T, ...]]: """Partition iterator into tuples of length `n` (``exact=True``) or at most `n` (``exact=False``). Equivalent to ``toolz.itertoolz.partition(n, self)`` or @@ -1477,7 +1472,7 @@ def take_nth(self, n: int) -> XIterable[T]: def zip( # A003: shadowing a python builtin self, *others: Iterable, fill: Any = NOTHING - ) -> XIterable[Tuple[T, S]]: + ) -> XIterable[tuple[T, S]]: """Zip iterators. Equivalent to ``zip(self, *others)`` or ``itertools.zip_longest(self, *others, fillvalue=fill)``. @@ -1508,7 +1503,7 @@ def zip( # A003: shadowing a python builtin else: return XIterable(itertools.zip_longest(self.iterator, *iterators, fillvalue=fill)) - def unzip(self) -> XIterable[Tuple[Any, ...]]: + def unzip(self) -> XIterable[tuple[Any, ...]]: """Unzip iterator. Equivalent to ``zip(*self)``. @@ -1609,21 +1604,21 @@ def unique(self, *, key: Union[NOTHING, Callable] = NOTHING) -> XIterable[T]: @typing.overload def groupby( self, key: str, *other_keys: str, as_dict: bool = False - ) -> XIterable[Tuple[Any, List[T]]]: ... + ) -> XIterable[tuple[Any, list[T]]]: ... @typing.overload def groupby( - self, key: List[Any], *, as_dict: bool = False - ) -> XIterable[Tuple[Any, List[T]]]: ... + self, key: list[Any], *, as_dict: bool = False + ) -> XIterable[tuple[Any, list[T]]]: ... @typing.overload def groupby( self, key: Callable[[T], Any], *, as_dict: bool = False - ) -> XIterable[Tuple[Any, List[T]]]: ... + ) -> XIterable[tuple[Any, list[T]]]: ... def groupby( - self, key: Union[str, List[Any], Callable[[T], Any]], *attr_keys: str, as_dict: bool = False - ) -> Union[XIterable[Tuple[Any, List[T]]], Dict]: + self, key: Union[str, list[Any], Callable[[T], Any]], *attr_keys: str, as_dict: bool = False + ) -> Union[XIterable[tuple[Any, list[T]]], dict]: """Group a sequence by a given key. More or less equivalent to ``toolz.itertoolz.groupby(key, self)`` with some caveats. @@ -1748,7 +1743,7 @@ def reduceby( *, as_dict: Literal[False], init: Union[S, NothingType], - ) -> XIterable[Tuple[str, S]]: ... + ) -> XIterable[tuple[str, S]]: ... @typing.overload def reduceby( @@ -1759,7 +1754,7 @@ def reduceby( *attr_keys: str, as_dict: Literal[False], init: Union[S, NothingType], - ) -> XIterable[Tuple[Tuple[str, ...], S]]: ... + ) -> XIterable[tuple[tuple[str, ...], S]]: ... @typing.overload def reduceby( @@ -1769,7 +1764,7 @@ def reduceby( *, as_dict: Literal[True], init: Union[S, NothingType], - ) -> Dict[str, S]: ... + ) -> dict[str, S]: ... @typing.overload def reduceby( @@ -1780,27 +1775,27 @@ def reduceby( *attr_keys: str, as_dict: Literal[True], init: Union[S, NothingType], - ) -> Dict[Tuple[str, ...], S]: ... + ) -> dict[tuple[str, ...], S]: ... @typing.overload def reduceby( self, bin_op_func: Callable[[S, T], S], - key: List[K], + key: list[K], *, as_dict: Literal[False], init: Union[S, NothingType], - ) -> XIterable[Tuple[K, S]]: ... + ) -> XIterable[tuple[K, S]]: ... @typing.overload def reduceby( self, bin_op_func: Callable[[S, T], S], - key: List[K], + key: list[K], *, as_dict: Literal[True], init: Union[S, NothingType], - ) -> Dict[K, S]: ... + ) -> dict[K, S]: ... @typing.overload def reduceby( @@ -1810,7 +1805,7 @@ def reduceby( *, as_dict: Literal[False], init: Union[S, NothingType], - ) -> XIterable[Tuple[K, S]]: ... + ) -> XIterable[tuple[K, S]]: ... @typing.overload def reduceby( @@ -1820,22 +1815,22 @@ def reduceby( *, as_dict: Literal[True], init: Union[S, NothingType], - ) -> Dict[K, S]: ... + ) -> dict[K, S]: ... def reduceby( self, bin_op_func: Callable[[S, T], S], - key: Union[str, List[K], Callable[[T], K]], + key: Union[str, list[K], Callable[[T], K]], *attr_keys: str, as_dict: bool = False, init: Union[S, NothingType] = NOTHING, ) -> Union[ - XIterable[Tuple[str, S]], - Dict[str, S], - XIterable[Tuple[Tuple[str, ...], S]], - Dict[Tuple[str, ...], S], - XIterable[Tuple[K, S]], - Dict[K, S], + XIterable[tuple[str, S]], + dict[str, S], + XIterable[tuple[tuple[str, ...], S]], + dict[tuple[str, ...], S], + XIterable[tuple[K, S]], + dict[K, S], ]: """Group a sequence by a given key and simultaneously perform a reduction inside the groups. @@ -1908,7 +1903,7 @@ def reduceby( groups = toolz.itertoolz.reduceby(groupby_key, bin_op_func, self.iterator) return groups if as_dict else xiter(groups.items()) - def to_list(self) -> List[T]: + def to_list(self) -> list[T]: """Expand iterator into a ``list``. Equivalent to ``list(self)``. @@ -1921,7 +1916,7 @@ def to_list(self) -> List[T]: """ return list(self.iterator) - def to_set(self) -> Set[T]: + def to_set(self) -> set[T]: """Expand iterator into a ``set``. Equivalent to ``set(self)``. diff --git a/src/gt4py/storage/allocators.py b/src/gt4py/storage/allocators.py index 394374c2a4..b206067d0e 100644 --- a/src/gt4py/storage/allocators.py +++ b/src/gt4py/storage/allocators.py @@ -30,8 +30,6 @@ Optional, Protocol, Sequence, - Tuple, - Type, TypeAlias, TypeGuard, Union, @@ -96,11 +94,11 @@ class TensorBuffer(Generic[core_defs.DeviceTypeT, core_defs.ScalarT]): device: core_defs.Device[core_defs.DeviceTypeT] dtype: core_defs.DType[core_defs.ScalarT] shape: core_defs.TensorShape - strides: Tuple[int, ...] + strides: tuple[int, ...] layout_map: BufferLayoutMap byte_offset: int byte_alignment: int - aligned_index: Tuple[int, ...] + aligned_index: tuple[int, ...] ndarray: core_defs.NDArrayObject = dataclasses.field(hash=False) @property @@ -141,9 +139,9 @@ def __dlpack_device__(self) -> xtyping.DLPackDevice: if TYPE_CHECKING: # TensorBuffer should be compatible with all the expected buffer interfaces - __TensorBufferAsArrayInterfaceT: Type[xtyping.ArrayInterface] = TensorBuffer - __TensorBufferAsCUDAArrayInterfaceT: Type[xtyping.CUDAArrayInterface] = TensorBuffer - __TensorBufferAsDLPackBufferT: Type[xtyping.DLPackBuffer] = TensorBuffer + __TensorBufferAsArrayInterfaceT: type[xtyping.ArrayInterface] = TensorBuffer + __TensorBufferAsCUDAArrayInterfaceT: type[xtyping.CUDAArrayInterface] = TensorBuffer + __TensorBufferAsDLPackBufferT: type[xtyping.DLPackBuffer] = TensorBuffer class BufferAllocator(Protocol[core_defs.DeviceTypeT]): @@ -302,7 +300,7 @@ def tensorize( class ArrayUtils: array_ns: types.ModuleType empty: Callable[..., _NDBuffer] - byte_bounds: Callable[[_NDBuffer], Tuple[int, int]] + byte_bounds: Callable[[_NDBuffer], tuple[int, int]] as_strided: Callable[..., core_defs.NDArrayObject] diff --git a/tests/eve_tests/unit_tests/test_extended_typing.py b/tests/eve_tests/unit_tests/test_extended_typing.py index b6e4896866..1f97a2634f 100644 --- a/tests/eve_tests/unit_tests/test_extended_typing.py +++ b/tests/eve_tests/unit_tests/test_extended_typing.py @@ -8,7 +8,11 @@ from __future__ import annotations +import builtins +import collections import collections.abc +import contextlib +import re import sys import types import typing @@ -20,15 +24,9 @@ Annotated, Any, Callable, - Dict, ForwardRef, - FrozenSet, - List, Mapping, Sequence, - Set, - Tuple, - Type, TypeVar, ) @@ -169,6 +167,67 @@ def __dlpack__(self): assert not supports_dlpack(DLPackBufferWithWrongDevice()) +DEPRECATED_TYPING_ALIASES = [ + ("Dict", "dict"), + ("FrozenSet", "frozenset"), + ("List", "list"), + ("Set", "set"), + ("Tuple", "tuple"), + ("Type", "type"), +] + + +@pytest.mark.parametrize(["name", "replacement"], DEPRECATED_TYPING_ALIASES) +def test_deprecated_typing_alias_is_not_exported(name, replacement): + # These names are still bound by the 'typing' / 'typing_extensions' star imports in + # 'extended_typing', and its module '__getattr__' would otherwise forward them. Pin + # the rejection: dropping the guard would not make the names disappear, it would + # silently resolve them to the deprecated 'typing' objects instead of the builtins. + with pytest.raises(AttributeError, match=f"'{name}' is a deprecated 'typing' alias"): + getattr(xtyping, name) + + assert replacement in str(pytest.raises(AttributeError, lambda: getattr(xtyping, name)).value) + + with pytest.raises(ImportError): + exec(f"from gt4py.eve.extended_typing import {name}") + + assert name not in dir(xtyping) + + +@pytest.mark.parametrize( + ["name", "expected"], + [ + ("Sequence", collections.abc.Sequence), + ("Callable", collections.abc.Callable), + ("AbstractSet", collections.abc.Set), + ("Match", re.Match), + ("ContextManager", contextlib.AbstractContextManager), + ("deque", collections.deque), + ], +) +def test_non_deprecated_aliases_are_still_re_exported(name, expected): + # Unlike the builtin generics above, these names are not deprecated -- only their + # 'typing' home is -- so 'extended_typing' keeps pointing them at the modern object. + assert getattr(xtyping, name) is expected + + +@pytest.mark.parametrize( + ["name", "replacement", "args"], + [ + (name, replacement, "int, str" if name == "Dict" else "int") + for name, replacement in DEPRECATED_TYPING_ALIASES + ], +) +def test_deprecated_typing_alias_still_resolves_in_forward_refs(name, replacement, args): + # 'eval_forward_ref' binds the name 'typing' to 'extended_typing', but a user writing + # 'typing.List[int]' means the real (deprecated but valid) 'typing' alias, so it must + # keep resolving rather than hitting the guard above. + resolved = xtyping.eval_forward_ref(f"typing.{name}[{args}]") + + assert xtyping.get_origin(resolved) is getattr(builtins, replacement) + assert xtyping.get_args(resolved) == tuple(eval(a) for a in args.split(", ")) + + @pytest.mark.parametrize("t", (int, float, dict, tuple, frozenset, collections.abc.Mapping)) def test_is_actual_valid_type(t): assert xtyping.is_actual_type(t) @@ -177,11 +236,11 @@ def test_is_actual_valid_type(t): @pytest.mark.parametrize( "t", ( - Tuple[int], - Tuple[int, ...], - Tuple[int, int], - Dict[str, Any], - Dict[str, float], + tuple[int], + tuple[int, ...], + tuple[int, int], + dict[str, Any], + dict[str, float], Mapping[int, float], ), ) @@ -196,8 +255,10 @@ def test_is_actual_wrong_type(t): (int, type), (tuple, type), (list, type), - (Tuple[int, float], type(Tuple[int, float])), - (List[int], type(List[int])), + # The deprecated 'typing' aliases and the builtin generics are distinct objects with + # distinct alias types, so both spellings are covered. + (typing.Tuple[int, float], type(typing.Tuple[int, float])), + (typing.List[int], type(typing.List[int])), (tuple[int, float], types.GenericAlias), (list[int], types.GenericAlias), ] @@ -281,15 +342,15 @@ def f_partial(a: int) -> MissingRef: ... "return": int, } - def f_nested_partial(a: int) -> Dict[str, MissingRef]: ... + def f_nested_partial(a: int) -> dict[str, MissingRef]: ... assert xtyping.get_partial_type_hints(f_nested_partial) == { "a": int, - "return": ForwardRef("Dict[str, MissingRef]"), + "return": ForwardRef("dict[str, MissingRef]"), } assert xtyping.get_partial_type_hints(f_nested_partial, localns={"MissingRef": MissingRef}) == { "a": int, - "return": Dict[str, MissingRef], + "return": dict[str, MissingRef], } def f_annotated(a: Annotated[int, "Foo"]) -> float: # type: ignore[name-defined] # used to work, now mypy is going berserk for unknown reasons @@ -307,10 +368,10 @@ def f_annotated(a: Annotated[int, "Foo"]) -> float: # type: ignore[name-defined def test_eval_forward_ref(): - assert xtyping.eval_forward_ref("Dict[str, Tuple[int, float]]") == Dict[str, Tuple[int, float]] + assert xtyping.eval_forward_ref("dict[str, tuple[int, float]]") == dict[str, tuple[int, float]] assert ( - xtyping.eval_forward_ref(ForwardRef("Dict[str, Tuple[int, float]]")) - == Dict[str, Tuple[int, float]] + xtyping.eval_forward_ref(ForwardRef("dict[str, tuple[int, float]]")) + == dict[str, tuple[int, float]] ) class MissingRef: ... @@ -356,19 +417,19 @@ def test_infer_type(): assert xtyping.infer_type(None, none_as_type=False) is None assert xtyping.infer_type(type(None), none_as_type=False) is None - assert xtyping.infer_type(Dict[str, int]) == Dict[str, int] + assert xtyping.infer_type(dict[str, int]) == dict[str, int] - assert xtyping.infer_type({1, 2, 3}) == Set[int] - assert xtyping.infer_type(frozenset({"1", "2", "3"})) == FrozenSet[str] + assert xtyping.infer_type({1, 2, 3}) == set[int] + assert xtyping.infer_type(frozenset({"1", "2", "3"})) == frozenset[str] - assert xtyping.infer_type({"a": [0], "b": [1]}) == Dict[str, List[int]] + assert xtyping.infer_type({"a": [0], "b": [1]}) == dict[str, list[int]] - assert xtyping.infer_type(str) == Type[str] + assert xtyping.infer_type(str) == type[str] class A: ... assert xtyping.infer_type(A()) == A - assert xtyping.infer_type(A) == Type[A] + assert xtyping.infer_type(A) == type[A] def f1(): ... @@ -379,30 +440,30 @@ def f2(a: int, b: float) -> None: ... assert xtyping.infer_type(f2) == Callable[[int, float], type(None)] def f3( - a: Dict[Tuple[str, ...], List[int]], - b: List[Callable[[List[int]], Set[Set[int]]]], - c: Type[List[int]], + a: dict[tuple[str, ...], list[int]], + b: list[Callable[[list[int]], set[set[int]]]], + c: type[list[int]], ) -> Any: ... assert ( xtyping.infer_type(f3) == Callable[ [ - Dict[Tuple[str, ...], List[int]], - List[Callable[[List[int]], Set[Set[int]]]], - Type[List[int]], + dict[tuple[str, ...], list[int]], + list[Callable[[list[int]], set[set[int]]]], + type[list[int]], ], Any, ] ) - def f4(a: int, b: float, *, foo: Tuple[str, ...] = ()) -> None: ... + def f4(a: int, b: float, *, foo: tuple[str, ...] = ()) -> None: ... assert xtyping.infer_type(f4) == Callable[[int, float], type(None)] assert ( xtyping.infer_type(f4, annotate_callable_kwargs=True) == Annotated[ - Callable[[int, float], type(None)], xtyping.CallableKwargsInfo({"foo": Tuple[str, ...]}) + Callable[[int, float], type(None)], xtyping.CallableKwargsInfo({"foo": tuple[str, ...]}) ] ) diff --git a/tests/eve_tests/unit_tests/test_traits.py b/tests/eve_tests/unit_tests/test_traits.py index a8f1e7cc27..d277de33f1 100644 --- a/tests/eve_tests/unit_tests/test_traits.py +++ b/tests/eve_tests/unit_tests/test_traits.py @@ -11,7 +11,7 @@ import pytest from gt4py import eve -from gt4py.eve.extended_typing import Any, ClassVar, List +from gt4py.eve.extended_typing import Any, ClassVar from .. import definitions @@ -34,7 +34,7 @@ class _NodeWithSymbolName(eve.Node): class _NodeWithSymbolTable(eve.Node, eve.SymbolTableTrait): - symbols: List[_NodeWithSymbolName] + symbols: list[_NodeWithSymbolName] @pytest.fixture @@ -75,9 +75,9 @@ class NodeWithRef(eve.Node): ref_name: eve.Coerced[eve.SymbolRef] class NodeWithSymbolTable(eve.Node, eve.traits.ValidatedSymbolTableTrait): - symbols: List[NodeWithRef] + symbols: list[NodeWithRef] - _NODE_SYMBOLS_: ClassVar[List] = [] + _NODE_SYMBOLS_: ClassVar[list] = [] NodeWithSymbolTable.update_forward_refs(locals()) diff --git a/tests/eve_tests/unit_tests/test_type_validation.py b/tests/eve_tests/unit_tests/test_type_validation.py index 6b119a5252..cf78de9f21 100644 --- a/tests/eve_tests/unit_tests/test_type_validation.py +++ b/tests/eve_tests/unit_tests/test_type_validation.py @@ -22,15 +22,11 @@ from gt4py.eve.extended_typing import ( Any, Callable, - Dict, Final, ForwardRef, - List, Optional, Sequence, - Set, SourceTypeAnnotation, - Tuple, Union, ) @@ -56,8 +52,8 @@ class SampleDataClass: # 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]] ) -SAMPLE_TYPE_DEFINITIONS: List[ - Tuple[Any, Sequence, Sequence, Optional[Dict[str, Any]], Optional[Dict[str, Any]]] +SAMPLE_TYPE_DEFINITIONS: list[ + tuple[Any, Sequence, Sequence, Optional[dict[str, Any]], Optional[dict[str, Any]]] ] = [ (bool, [True, False], [1, "True"], None, None), (int, [1, -1], [1.0, "1"], None, None), @@ -96,7 +92,7 @@ class SampleDataClass: (typing.Union[int, float, str], [1, 3.0, "one"], [[1], [], 1j], None, None), (typing.Optional[int], [1, None], [[1], [], 1j], None, None), ( - typing.Dict[Union[int, float, str], Union[Tuple[int, Optional[float]], Set[int]]], + typing.Dict[Union[int, float, str], Union[tuple[int, Optional[float]], set[int]]], [{1: (2, 3.0)}, {1.0: (2, None)}, {"1": {1, 2}}], [{(1, 1.0, "1"): set()}, {1: [1]}, {"1": (1,)}], None, @@ -166,8 +162,8 @@ def test_validators( type_hint: SourceTypeAnnotation, valid_values: Sequence, wrong_values: Sequence, - globalns: Optional[Dict[str, Any]], - localns: Optional[Dict[str, Any]], + globalns: Optional[dict[str, Any]], + localns: Optional[dict[str, Any]], ): for value in valid_values: validator(value, type_hint, "", globalns=globalns, localns=localns) @@ -186,8 +182,8 @@ def test_validator_factories( type_hint: SourceTypeAnnotation, valid_values: Sequence, wrong_values: Sequence, - globalns: Optional[Dict[str, Any]], - localns: Optional[Dict[str, Any]], + globalns: Optional[dict[str, Any]], + localns: Optional[dict[str, Any]], ): validator = factory(type_hint, name="", globalns=globalns, localns=localns) for value in valid_values: @@ -215,8 +211,8 @@ def test_validator_factories_with_invalid_hints( SampleEmptyClass, SampleDataClass, SampleEnum, - List[int], - Dict[Tuple[int, ...], List[Set[complex]]], + list[int], + dict[tuple[int, ...], list[set[complex]]], ], ) def test_simple_validation_cache(type_hint): @@ -225,7 +221,7 @@ def test_simple_validation_cache(type_hint): assert type_val.simple_type_validator_factory(type_hint, "value_2") is not validator assert type_val.simple_type_validator_factory(Optional[float], "value") is not validator - assert type_val.simple_type_validator_factory(List[float], "value") is not validator + assert type_val.simple_type_validator_factory(list[float], "value") is not validator opt_validator = type_val.simple_type_validator_factory(type_hint, "value", required=False) assert opt_validator not in (validator, None) From 203d6102dba642d1cccdb228d79138cf91f3f093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Mon, 10 Aug 2026 13:26:15 +0200 Subject: [PATCH 15/24] fix[eve]: tighten 'frozen="strict"' for bare containers and type variables Two annotations still slipped through the immutability check. An unparametrized hashable container was accepted, so '@datamodel(frozen="strict") class M: values: tuple' built instances whose 'hash()' raises 'TypeError: unhashable type'. Used bare, 'tuple' and 'frozenset' say nothing about the items whose hashes they fold in, which is the same reason 'tuple[Any, ...]' is rejected -- the check now turns on what the annotation proves, not on whether type arguments were spelled out. An alias with no arguments ('typing.Tuple') is treated as its origin. The 'get_represented_types' fallback flattened an annotation to its origins and dropped the type arguments, so a mutable payload behind a 'TypeVar' bound survived: a bound of 'tuple[list[int], ...]' came back as a plain, hashable 'tuple'. Type variables are now resolved through their bound, constraints or default, and forward references through the annotation itself, so both keep being decomposed. Handling forward references here also widens the previous over-narrow 'except NameError': a reference that resolves to something non-subscriptable, is malformed, or resolves to another 'ForwardRef' is now rejected as the docstring promises, rather than escaping as a raw exception. --- src/gt4py/eve/datamodels/core.py | 59 ++++++++++++++++--- tests/eve_tests/unit_tests/test_datamodels.py | 33 +++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index 1ecda8cc5e..63ce461e4f 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -1014,8 +1014,15 @@ def _type_converter(value: Any) -> _T: _KNOWN_MUTABLE_TYPES: Final = (list, dict, set) +#: Hashable container types folding the hashes of the items they hold into their own. +#: Used unparametrized they say nothing about those items, so they are only accepted +#: as the origin of a parametrized alias (``tuple[int, ...]``), never on their own. +_ITEM_HASHING_CONTAINER_TYPES: Final = (tuple, frozenset) -def _is_strictly_immutable_type(type_annotation: TypeAnnotation) -> bool: + +def _is_strictly_immutable_type( + type_annotation: TypeAnnotation, *, _as_container_origin: bool = False +) -> bool: """Check whether an annotation only admits strictly immutable (hashable) values. A datamodel qualifies only if it is itself defined with ``frozen="strict"``; @@ -1025,6 +1032,16 @@ def _is_strictly_immutable_type(type_annotation: TypeAnnotation) -> bool: (whose hash folds in the hashes of its items) weakens the check. Annotations that cannot be resolved to any concrete type (``Any``, unbound type variables, unresolved forward references) are conservatively rejected. + + Arguments: + type_annotation: The annotation to check. + + Keyword Arguments: + _as_container_origin: Internal flag set when checking the origin of a + parametrized alias, whose type arguments are checked separately. + + Returns: + ``True`` if every value admitted by the annotation is strictly immutable. """ if is_datamodel(type_annotation): return getattr(type_annotation, MODEL_PARAM_DEFINITIONS_ATTR).strict_frozen is True @@ -1042,26 +1059,50 @@ def _is_strictly_immutable_type(type_annotation: TypeAnnotation) -> bool: return bool(type_args) and all(map(_is_strictly_immutable_type, type_args)) if origin_type is not None: + if not type_args: + # Unparametrized alias ('typing.Tuple', 'typing.List', ...): it says nothing + # more than the bare origin type does. + return _is_strictly_immutable_type(origin_type) + # Parametrized generic alias ('tuple[int, ...]', 'list[int]', ...). The alias # itself is a hashable object, so it has to be decomposed: both the container # type and every type argument must be strictly immutable, since the hash of # a container folds in the hashes of the items it holds. - return _is_strictly_immutable_type(origin_type) and all( + return _is_strictly_immutable_type(origin_type, _as_container_origin=True) and all( _is_strictly_immutable_type(arg) for arg in type_args if arg is not Ellipsis ) if xtyping.is_actual_type(type_annotation): # plain type, already known not to be a datamodel + if not _as_container_origin and issubclass(type_annotation, _ITEM_HASHING_CONTAINER_TYPES): + # Unparametrized container ('tuple', 'frozenset', a 'NamedTuple', ...): its + # hash folds in the hashes of items which nothing here proves immutable, the + # same reason why 'tuple[Any, ...]' is rejected. + return False return xtyping.is_type_with_custom_hash(type_annotation) - # Anything else (type variables, forward references, ...) is checked through the - # concrete types it stands for, if any. Forward references that cannot be resolved - # at this point do not prove anything, so they are rejected. - try: - represented_types = xtyping.get_represented_types(type_annotation) - except NameError: + if isinstance(type_annotation, TypeVar): + # Check the concrete annotations the type variable can stand for. Note that the + # bound/constraints are checked as annotations, not flattened to their origins, + # so that e.g. a 'tuple[list[int], ...]' bound is still decomposed. + if type_annotation.__bound__ is not None: + return _is_strictly_immutable_type(type_annotation.__bound__) + if type_annotation.__constraints__: + return all(map(_is_strictly_immutable_type, type_annotation.__constraints__)) + if (typevar_default := getattr(type_annotation, "__default__", None)) is not None: + return _is_strictly_immutable_type(typevar_default) return False - return bool(represented_types) and all(map(_is_strictly_immutable_type, represented_types)) + if isinstance(type_annotation, (ForwardRef, typing.ForwardRef)): + # Forward references that cannot be resolved at this point do not prove + # anything, so they are rejected instead of propagating the resolution error. + try: + resolved_annotation = xtyping.eval_forward_ref(type_annotation) + except Exception: + return False + return _is_strictly_immutable_type(resolved_annotation) + + # Anything else ('Any', special forms, ...) proves nothing. + return False def _make_datamodel( diff --git a/tests/eve_tests/unit_tests/test_datamodels.py b/tests/eve_tests/unit_tests/test_datamodels.py index 3aa824fc83..8ed9d173ce 100644 --- a/tests/eve_tests/unit_tests/test_datamodels.py +++ b/tests/eve_tests/unit_tests/test_datamodels.py @@ -1020,6 +1020,9 @@ class PlainFrozenInner: values: List[int] +MutableBoundT = TypeVar("MutableBoundT", bound=Tuple[List[int], ...]) + + # Test datamodel options class TestDatamodelOptions: def test_frozen(self): @@ -1142,6 +1145,36 @@ class TupleOfListsModel: class TupleOfNonStrictModels: inners: Tuple[PlainFrozenInner, ...] + def test_strict_frozen_rejects_unparametrized_container_fields(self): + # A bare 'tuple' hashes its items, which nothing proves immutable: it must be + # rejected for exactly the same reason as the explicit 'Tuple[Any, ...]' below. + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class BareTupleModel: + values: tuple + + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class BareTypingTupleModel: + values: Tuple + + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class AnyItemTupleModel: + values: Tuple[Any, ...] + + def test_strict_frozen_rejects_type_var_with_mutable_bound(self): + # The bound has to be checked as an annotation, not flattened to its origin, + # otherwise 'tuple[list[int], ...]' would come back as a plain (hashable) 'tuple'. + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class TypeVarModel: + value: MutableBoundT + def test_strict_frozen_rejects_unresolved_forward_reference(self): # A self-reference cannot be resolved while the class is being created, so it # cannot be proven immutable: the check must reject it instead of raising the From 6ca810006a76242c51a860e78d74f6d9c0b8b05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Mon, 10 Aug 2026 13:26:33 +0200 Subject: [PATCH 16/24] fix[eve]: keep deprecated 'typing' aliases resolvable in forward references Dropping 'Dict', 'List' and friends from 'extended_typing' also changed how forward references resolve, which was not intended: these names stay valid in user-written annotations even though gt4py no longer uses them. Resolving a reference through this module has always normalized them to the builtin generic. That broke in both directions: a bare 'List[int]' raised 'NameError' (the names were popped from the module namespace that serves as the default 'globalns'), and 'typing.List[int]' came back as the deprecated 'typing._GenericAlias' object -- reintroducing exactly what the module rejects elsewhere -- instead of 'list[int]'. Both paths now yield the builtin again, matching the pre-removal behaviour on every spelling, while an explicit 'globalns' is still left untouched. Also fixes two unrelated defects in the same module: 'dir()' listed only the 'typing' / 'typing_extensions' names and so hid the module's own API ('is_actual_type', 'eval_forward_ref', ...), and the 'isinstance( _typing.Any, type)' guard around '_ArtefactTypes' is unconditionally true on the 3.12 floor. --- src/gt4py/eve/extended_typing.py | 48 ++++++++++++------- .../unit_tests/test_extended_typing.py | 24 +++++++--- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/gt4py/eve/extended_typing.py b/src/gt4py/eve/extended_typing.py index 66b8f24ca5..d8b30ac6ff 100644 --- a/src/gt4py/eve/extended_typing.py +++ b/src/gt4py/eve/extended_typing.py @@ -17,6 +17,7 @@ # ruff: noqa: F401, F405 import abc as _abc import array as _array +import builtins as _builtins import collections.abc as _collections_abc import dataclasses as _dataclasses import functools as _functools @@ -101,20 +102,29 @@ globals().pop(_alias, None) del _alias +# The names are still valid in user-written annotations, and resolving a forward +# reference through this module has always normalized them to the builtin generic +# ('typing.List[int]' -> 'list[int]'). Keep that mapping available to the forward-ref +# machinery below, which would otherwise either raise or hand back the deprecated +# 'typing' object. +_DEPRECATED_ALIAS_REPLACEMENTS: Final[Mapping[str, Any]] = { + name: getattr(_builtins, replacement) + for name, replacement in _DEPRECATED_TYPING_ALIASES.items() +} + class _ForwardRefTypingNamespace: """Namespace bound to the name 'typing' while evaluating forward references. - Annotations are resolved through this module, so that 'typing_extensions' - definitions take priority and the standard collection types are used. The - deprecated builtin aliases are not re-exported here, but they remain perfectly - valid in user-written annotations, so 'typing.List[int]' and friends fall back - to the real 'typing' module instead of raising. + Annotations resolve through this module, so that 'typing_extensions' definitions + take priority and the standard collection types are used. The deprecated builtin + aliases are not re-exported here, but they stay valid in user-written annotations, + so 'typing.List[int]' resolves to 'list[int]' rather than raising. """ def __getattr__(self, name: str) -> Any: - if name in _DEPRECATED_TYPING_ALIASES: - return getattr(_typing, name) + if (replacement := _DEPRECATED_ALIAS_REPLACEMENTS.get(name)) is not None: + return replacement return getattr(_sys.modules[__name__], name) @@ -156,12 +166,10 @@ def __dir__() -> list[str]: import typing_extensions - orig_dir = typing.__dir__() - self_func.__cached_dir = [ - name - for name in [*orig_dir, *(n for n in typing_extensions.__dir__() if n not in orig_dir)] - if name not in _DEPRECATED_TYPING_ALIASES - ] + # Everything reachable through '__getattr__' plus this module's own definitions, + # minus the aliases '__getattr__' explicitly rejects. + names = {*typing.__dir__(), *typing_extensions.__dir__(), *globals()} + self_func.__cached_dir = sorted(names - _DEPRECATED_TYPING_ALIASES.keys()) return self_func.__cached_dir @@ -428,11 +436,8 @@ def __pretty__( # -- Added functionality -- -_ArtefactTypes: tuple[type, ...] = (_types.GenericAlias,) - -# `Any` is a class since Python 3.11 -if isinstance(_typing.Any, type): # Python >= 3.11 - _ArtefactTypes = (*_ArtefactTypes, _typing.Any) +# `Any` is a class since Python 3.11, which is below the supported floor. +_ArtefactTypes: tuple[type, ...] = (_types.GenericAlias, _typing.Any) # `Any` is a class since typing_extensions >= 4.4 and Python 3.11 if (typing_exts_any := getattr(_typing_extensions, "Any", None)) is not _typing.Any and isinstance( @@ -743,6 +748,13 @@ def f() -> None: ... safe_localns.setdefault("typing", _FORWARD_REF_TYPING_NS) safe_localns.setdefault("NoneType", type(None)) + if globalns is None: + # Without an explicit 'globalns' the reference is resolved in this module's + # namespace, which used to spell the deprecated aliases as the builtin generics. + # They are no longer defined here, so re-add them for this evaluation only; a + # caller-provided 'globalns' is left untouched, exactly as before. + globalns = {**globals(), **_DEPRECATED_ALIAS_REPLACEMENTS} + actual_type = get_type_hints(f, globalns, safe_localns, include_extras=include_extras)["return"] assert not isinstance(actual_type, ForwardRef) diff --git a/tests/eve_tests/unit_tests/test_extended_typing.py b/tests/eve_tests/unit_tests/test_extended_typing.py index 1f97a2634f..daad124ea2 100644 --- a/tests/eve_tests/unit_tests/test_extended_typing.py +++ b/tests/eve_tests/unit_tests/test_extended_typing.py @@ -219,13 +219,23 @@ def test_non_deprecated_aliases_are_still_re_exported(name, expected): ], ) def test_deprecated_typing_alias_still_resolves_in_forward_refs(name, replacement, args): - # 'eval_forward_ref' binds the name 'typing' to 'extended_typing', but a user writing - # 'typing.List[int]' means the real (deprecated but valid) 'typing' alias, so it must - # keep resolving rather than hitting the guard above. - resolved = xtyping.eval_forward_ref(f"typing.{name}[{args}]") - - assert xtyping.get_origin(resolved) is getattr(builtins, replacement) - assert xtyping.get_args(resolved) == tuple(eval(a) for a in args.split(", ")) + # These names stay valid in user-written annotations, so resolving a forward + # reference must not hit the rejection above. Both the bare and the 'typing.'- + # qualified spelling normalize to the *builtin* generic, which is what resolving + # through this module has always produced. + expected_args = tuple(eval(a) for a in args.split(", ")) + + for ref in (f"{name}[{args}]", f"typing.{name}[{args}]"): + resolved = xtyping.eval_forward_ref(ref) + + assert xtyping.get_origin(resolved) is getattr(builtins, replacement) + assert xtyping.get_args(resolved) == expected_args + # A builtin generic alias, not the deprecated 'typing._GenericAlias' object. + assert type(resolved) is types.GenericAlias + + # An explicit 'globalns' is left alone, so the bare name is not injected there. + with pytest.raises(NameError): + xtyping.eval_forward_ref(f"{name}[{args}]", globalns={}) @pytest.mark.parametrize("t", (int, float, dict, tuple, frozenset, collections.abc.Mapping)) From cdefb90b4ff0d4c99e3faa962b198a301322a178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Mon, 10 Aug 2026 13:26:54 +0200 Subject: [PATCH 17/24] fix[next]: keep 'implicit_reexport' in the typing-export mypy config Giving 'test_typing_exports' its own config stopped it inheriting the project's '[tool.mypy]' table, which was the point -- but that table also supplied 'implicit_reexport = false', and that setting is what makes these tests check *exports* at all: without it, a name that 'gt4py.next.__init__' merely imports instead of explicitly re-exporting no longer produces "does not explicitly export attribute", and the session goes green on exactly the regression it exists to catch. Carry that one setting over. The remaining gt4py-internal strictness stays out, since it would apply to the client snippets themselves. --- typing_tests/mypy.ini | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/typing_tests/mypy.ini b/typing_tests/mypy.ini index 90c26f6e75..307a97b27a 100644 --- a/typing_tests/mypy.ini +++ b/typing_tests/mypy.ini @@ -19,3 +19,8 @@ plugins = gt4py.next.type_system.mypy_plugin ignore_missing_imports = True show_column_numbers = True show_error_codes = True +; Kept from the project table (which this file no longer inherits): it is what makes +; these tests check *exports*, by rejecting names GT4Py only imports but does not +; explicitly re-export. The remaining gt4py-internal strictness is deliberately not +; carried over, since it would apply to the client snippets themselves. +implicit_reexport = False From 7e7d399edaf6e9ef2c898162b4c7203146c358c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Mon, 10 Aug 2026 13:40:05 +0200 Subject: [PATCH 18/24] docs[eve]: add an ADR for dropping the deprecated 'typing' aliases Removing 'Dict', 'FrozenSet', 'List', 'Set', 'Tuple' and 'Type' from 'extended_typing' changes the shape of a public import surface, which AGENTS.md asks to record in an ADR. Until now the rationale lived only in a module comment and a commit message. Records in particular the part that is not obvious from the diff: the names are not created by the re-export block but by the 'typing' / 'typing_extensions' star imports above it, and the module '__getattr__' forwards anything missing, so deleting the block alone would have left all six in place silently rebound to the deprecated 'typing' objects. It also draws the line against the second group of re-exports ('Sequence', 'Callable', 'Match', ...), whose names are already the modern ones and are deliberately kept. 'gt4py.eve' had no ADR directory, so this adds one with the same index README the 'next' and 'cartesian' subsystems use. --- .../eve/0001-Deprecated_Typing_Aliases.md | 119 ++++++++++++++++++ docs/development/ADRs/eve/README.md | 19 +++ 2 files changed, 138 insertions(+) create mode 100644 docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md create mode 100644 docs/development/ADRs/eve/README.md diff --git a/docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md b/docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md new file mode 100644 index 0000000000..63f8928eab --- /dev/null +++ b/docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md @@ -0,0 +1,119 @@ +--- +tags: [typing] +--- + +# Deprecated `typing` Aliases in `extended_typing` + +- **Status**: valid +- **Authors**: Enrique G. Paredes (@egparedes) +- **Created**: 2026-08-10 +- **Updated**: 2026-08-10 + +Why-statement: In the context of `gt4py.eve.extended_typing` acting as the single typing +import for the whole project, facing the fact that PEP 585 made `typing.List` and friends +deprecated spellings of the builtin generics, we decided to stop re-exporting the six +builtin-generic aliases and to reject them explicitly at runtime, to achieve a single +obvious spelling for these types. We considered simply deleting the re-exports and accept +that the guarantee is runtime-only, since a type checker still resolves the names through +the module's star imports. + +## Context + +`extended_typing` is a drop-in replacement for `typing`: it star-imports `typing` and then +`typing_extensions` (so the latter takes priority), and re-exports a number of names from +their non-deprecated homes. Downstream code does `from gt4py.eve.extended_typing import ...` +rather than importing `typing` directly. + +Two groups of re-exports were historically bundled together, but they are not the same kind +of thing: + +- **Builtin generics** — `Dict`, `FrozenSet`, `List`, `Set`, `Tuple`, `Type`. Since PEP 585 + the modern spelling is a *different* name: the builtin (`dict`, `list`, ...). The + PascalCase names are deprecated. +- **Everything else** — `Sequence`, `Callable`, `Mapping`, `Match`, `deque`, ... Here the + name is already the modern one; only its `typing` home is soft-deprecated. Re-exporting + `collections.abc.Sequence` as `Sequence` is precisely what this module is for. + +Drivers: + +- The supported Python floor is now 3.12, so the builtin generics are available + unconditionally and there is no compatibility reason to keep the aliases. +- Two spellings for the same type invite inconsistency, and the deprecated one is what new + code tends to copy from its neighbours. +- Ruff's `UP006` cannot help: it rewrites `typing.List` but leaves both `xtyping.List` and + `from gt4py.eve.extended_typing import List` untouched, even with `typing-modules` + configured. Enabling the `UP` ruleset would therefore not migrate these use sites. + +The decisive constraint is a mechanism that makes "just delete the re-export" ineffective. +The names are *not created* by the re-export block: `from typing import *` and +`from typing_extensions import *` already bind all six, and the block below merely shadows +them with the builtins. On top of that, the module defines a `__getattr__` that forwards any +missing name to `typing_extensions` and then `typing`. Deleting the block alone would leave +all six names in place, silently rebound from the builtins to the deprecated `typing` +objects — the opposite of the intent, and invisible at every use site. + +## Decision + +Remove the six builtin-generic aliases only, and make the removal effective: + +1. Drop them from the module namespace after the star imports, and reject them in + `__getattr__` with a message naming the replacement. Both `xtyping.List` and + `from gt4py.eve.extended_typing import List` now fail loudly instead of resolving to + `typing.List`. +2. Migrate the use sites to the builtins. A bare `typing.Type` means `type[Any]`, whereas a + bare builtin `type` is stricter (it is not indexable and carries no arbitrary + attributes), so unsubscripted occurrences become `type[Any]`. The other five aliases need + no such care. +3. Keep forward-reference resolution unchanged. `eval_forward_ref` binds the name `typing` + to this module, and resolving a reference through it has always normalized the deprecated + spellings to the builtin generic. These names remain valid in *user-written* annotations, + so both `List[int]` and `typing.List[int]` still resolve to `list[int]`. + +The second group of re-exports is deliberately left alone. + +## Consequences + +- There is one spelling for these types in the codebase, and a wrong one fails immediately + with a message that names the replacement, rather than degrading silently. +- The guarantee is runtime-only. A type checker still sees the names through the star + imports, so `xtyping.List` type-checks and fails at import time. Closing that gap means + replacing the star imports with an explicit `__all__`, which is a much larger change and + is not attempted here. +- Downstream code importing these names from `extended_typing` breaks and must switch to the + builtins. Code importing them from `typing` directly is unaffected. +- The distinction between the two groups of re-exports is now explicit, so future cleanups + do not have to re-derive it. + +## Alternatives considered + +### Delete the re-export block only + +- Good, because it is a one-line-per-name change with no use-site churn. +- Bad, because it does not remove anything: the star imports and `__getattr__` keep all six + names alive, silently rebound from the builtins to the deprecated `typing` objects. It is + a downgrade disguised as a cleanup. + +### Keep the aliases and wait for a ruff rule to migrate the use sites + +- Good, because it costs nothing now. +- Bad, because `UP006` does not fire on names sourced from `extended_typing` (verified), so + the wait would be indefinite. + +### Also drop the `collections.abc` / `collections` / `re` / `contextlib` re-exports + +- Good, because it would shrink the module to a thinner shim over `typing`. +- Bad, because those names are not deprecated — only their `typing` home is — and providing + them from their modern home is the module's purpose. It would also force every use site to + import from two places instead of one, for no correctness gain. + +### Replace the star imports with an explicit `__all__` + +- Good, because it would make the removal visible to type checkers too, and would end this + whole class of problem. +- Bad, because it is a large, risky change to the module's contract that is orthogonal to + the deprecation at hand. Left as possible future work. + +## References + +- [PEP 585 - Type Hinting Generics In Standard Collections](https://peps.python.org/pep-0585/) +- [PEP 562 - Module `__getattr__` and `__dir__`](https://peps.python.org/pep-0562/) diff --git a/docs/development/ADRs/eve/README.md b/docs/development/ADRs/eve/README.md new file mode 100644 index 0000000000..e9b9f69bde --- /dev/null +++ b/docs/development/ADRs/eve/README.md @@ -0,0 +1,19 @@ +# Architecture Decision Records Index (`gt4py.eve`) + +This document contains links to all _Architecture Decision Record_ (ADR) documents written in the `gt4py.eve` project. The [top-level README](../README.md) explains when and why we write ADRs. + +## How to write ADRs + +See [top-level README](../README.md) on when and why we write ADRs. + +Writing a new ADR is simple: + +1. Use the existing [Template](../Template.md) as an ice-breaker to start a new ADR file, but modify it and simplify it as much as possible to fit the type of decision being documented. If extra files (e.g. images) are needed for whatever reason, add them to the `_static/` folder. +2. Add a link to the new ADR file to the fitting topic in the index section below. +3. Open a PR to merge the changes into the main branch and let the team know about the new ADR. + +## Index by Topic + +### Typing #typing + +- [0001 - Deprecated `typing` Aliases in `extended_typing`](0001-Deprecated_Typing_Aliases.md) From 4350c055f05f3223911f6e9a08c5c41bcee072f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Mon, 10 Aug 2026 13:54:37 +0200 Subject: [PATCH 19/24] Revert "docs[eve]: add an ADR for dropping the deprecated 'typing' aliases" This reverts commit 7e7d399edaf6e9ef2c898162b4c7203146c358c0. --- .../eve/0001-Deprecated_Typing_Aliases.md | 119 ------------------ docs/development/ADRs/eve/README.md | 19 --- 2 files changed, 138 deletions(-) delete mode 100644 docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md delete mode 100644 docs/development/ADRs/eve/README.md diff --git a/docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md b/docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md deleted file mode 100644 index 63f8928eab..0000000000 --- a/docs/development/ADRs/eve/0001-Deprecated_Typing_Aliases.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -tags: [typing] ---- - -# Deprecated `typing` Aliases in `extended_typing` - -- **Status**: valid -- **Authors**: Enrique G. Paredes (@egparedes) -- **Created**: 2026-08-10 -- **Updated**: 2026-08-10 - -Why-statement: In the context of `gt4py.eve.extended_typing` acting as the single typing -import for the whole project, facing the fact that PEP 585 made `typing.List` and friends -deprecated spellings of the builtin generics, we decided to stop re-exporting the six -builtin-generic aliases and to reject them explicitly at runtime, to achieve a single -obvious spelling for these types. We considered simply deleting the re-exports and accept -that the guarantee is runtime-only, since a type checker still resolves the names through -the module's star imports. - -## Context - -`extended_typing` is a drop-in replacement for `typing`: it star-imports `typing` and then -`typing_extensions` (so the latter takes priority), and re-exports a number of names from -their non-deprecated homes. Downstream code does `from gt4py.eve.extended_typing import ...` -rather than importing `typing` directly. - -Two groups of re-exports were historically bundled together, but they are not the same kind -of thing: - -- **Builtin generics** — `Dict`, `FrozenSet`, `List`, `Set`, `Tuple`, `Type`. Since PEP 585 - the modern spelling is a *different* name: the builtin (`dict`, `list`, ...). The - PascalCase names are deprecated. -- **Everything else** — `Sequence`, `Callable`, `Mapping`, `Match`, `deque`, ... Here the - name is already the modern one; only its `typing` home is soft-deprecated. Re-exporting - `collections.abc.Sequence` as `Sequence` is precisely what this module is for. - -Drivers: - -- The supported Python floor is now 3.12, so the builtin generics are available - unconditionally and there is no compatibility reason to keep the aliases. -- Two spellings for the same type invite inconsistency, and the deprecated one is what new - code tends to copy from its neighbours. -- Ruff's `UP006` cannot help: it rewrites `typing.List` but leaves both `xtyping.List` and - `from gt4py.eve.extended_typing import List` untouched, even with `typing-modules` - configured. Enabling the `UP` ruleset would therefore not migrate these use sites. - -The decisive constraint is a mechanism that makes "just delete the re-export" ineffective. -The names are *not created* by the re-export block: `from typing import *` and -`from typing_extensions import *` already bind all six, and the block below merely shadows -them with the builtins. On top of that, the module defines a `__getattr__` that forwards any -missing name to `typing_extensions` and then `typing`. Deleting the block alone would leave -all six names in place, silently rebound from the builtins to the deprecated `typing` -objects — the opposite of the intent, and invisible at every use site. - -## Decision - -Remove the six builtin-generic aliases only, and make the removal effective: - -1. Drop them from the module namespace after the star imports, and reject them in - `__getattr__` with a message naming the replacement. Both `xtyping.List` and - `from gt4py.eve.extended_typing import List` now fail loudly instead of resolving to - `typing.List`. -2. Migrate the use sites to the builtins. A bare `typing.Type` means `type[Any]`, whereas a - bare builtin `type` is stricter (it is not indexable and carries no arbitrary - attributes), so unsubscripted occurrences become `type[Any]`. The other five aliases need - no such care. -3. Keep forward-reference resolution unchanged. `eval_forward_ref` binds the name `typing` - to this module, and resolving a reference through it has always normalized the deprecated - spellings to the builtin generic. These names remain valid in *user-written* annotations, - so both `List[int]` and `typing.List[int]` still resolve to `list[int]`. - -The second group of re-exports is deliberately left alone. - -## Consequences - -- There is one spelling for these types in the codebase, and a wrong one fails immediately - with a message that names the replacement, rather than degrading silently. -- The guarantee is runtime-only. A type checker still sees the names through the star - imports, so `xtyping.List` type-checks and fails at import time. Closing that gap means - replacing the star imports with an explicit `__all__`, which is a much larger change and - is not attempted here. -- Downstream code importing these names from `extended_typing` breaks and must switch to the - builtins. Code importing them from `typing` directly is unaffected. -- The distinction between the two groups of re-exports is now explicit, so future cleanups - do not have to re-derive it. - -## Alternatives considered - -### Delete the re-export block only - -- Good, because it is a one-line-per-name change with no use-site churn. -- Bad, because it does not remove anything: the star imports and `__getattr__` keep all six - names alive, silently rebound from the builtins to the deprecated `typing` objects. It is - a downgrade disguised as a cleanup. - -### Keep the aliases and wait for a ruff rule to migrate the use sites - -- Good, because it costs nothing now. -- Bad, because `UP006` does not fire on names sourced from `extended_typing` (verified), so - the wait would be indefinite. - -### Also drop the `collections.abc` / `collections` / `re` / `contextlib` re-exports - -- Good, because it would shrink the module to a thinner shim over `typing`. -- Bad, because those names are not deprecated — only their `typing` home is — and providing - them from their modern home is the module's purpose. It would also force every use site to - import from two places instead of one, for no correctness gain. - -### Replace the star imports with an explicit `__all__` - -- Good, because it would make the removal visible to type checkers too, and would end this - whole class of problem. -- Bad, because it is a large, risky change to the module's contract that is orthogonal to - the deprecation at hand. Left as possible future work. - -## References - -- [PEP 585 - Type Hinting Generics In Standard Collections](https://peps.python.org/pep-0585/) -- [PEP 562 - Module `__getattr__` and `__dir__`](https://peps.python.org/pep-0562/) diff --git a/docs/development/ADRs/eve/README.md b/docs/development/ADRs/eve/README.md deleted file mode 100644 index e9b9f69bde..0000000000 --- a/docs/development/ADRs/eve/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Architecture Decision Records Index (`gt4py.eve`) - -This document contains links to all _Architecture Decision Record_ (ADR) documents written in the `gt4py.eve` project. The [top-level README](../README.md) explains when and why we write ADRs. - -## How to write ADRs - -See [top-level README](../README.md) on when and why we write ADRs. - -Writing a new ADR is simple: - -1. Use the existing [Template](../Template.md) as an ice-breaker to start a new ADR file, but modify it and simplify it as much as possible to fit the type of decision being documented. If extra files (e.g. images) are needed for whatever reason, add them to the `_static/` folder. -2. Add a link to the new ADR file to the fitting topic in the index section below. -3. Open a PR to merge the changes into the main branch and let the team know about the new ADR. - -## Index by Topic - -### Typing #typing - -- [0001 - Deprecated `typing` Aliases in `extended_typing`](0001-Deprecated_Typing_Aliases.md) From ac1dd6c699f9014349178a918e1d6dca83708f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Thu, 13 Aug 2026 12:00:23 +0200 Subject: [PATCH 20/24] fix[eve]: resolve PEP 695 aliases in the 'frozen="strict"' check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '_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. --- src/gt4py/eve/datamodels/core.py | 14 +++++++ tests/eve_tests/unit_tests/test_datamodels.py | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index 43b4cba249..e4c8f69405 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -1081,6 +1081,20 @@ def _is_strictly_immutable_type( Returns: ``True`` if every value admitted by the annotation is strictly immutable. """ + if xtyping.is_type_alias(type_annotation) or xtyping.get_origin(type_annotation) is not None: + # A PEP 695 alias stands for the annotation it resolves to, so check that + # instead; an alias whose value cannot be evaluated (undefined name, recursive, + # ...) proves nothing and is rejected below like any other unresolved + # annotation. Non-aliases are returned unchanged, as the identical object. + try: + resolved_alias = xtyping.eval_type_alias(type_annotation) + except (NameError, TypeError): + return False + if resolved_alias is not type_annotation: + return _is_strictly_immutable_type( + resolved_alias, _as_container_origin=_as_container_origin + ) + if is_datamodel(type_annotation): return getattr(type_annotation, MODEL_PARAM_DEFINITIONS_ATTR).strict_frozen is True diff --git a/tests/eve_tests/unit_tests/test_datamodels.py b/tests/eve_tests/unit_tests/test_datamodels.py index 129d2c08df..a9f6872353 100644 --- a/tests/eve_tests/unit_tests/test_datamodels.py +++ b/tests/eve_tests/unit_tests/test_datamodels.py @@ -1022,6 +1022,14 @@ class PlainFrozenInner: MutableBoundT = TypeVar("MutableBoundT", bound=Tuple[List[int], ...]) +# Module level, since this file uses PEP 563: a function-local alias would only ever be +# seen as an unresolvable forward reference and the tests below would pass for the wrong +# reason. +type ImmutableAlias = tuple[int, int] +type MutableAlias = tuple[list[int], ...] +type PairAlias[T] = tuple[T, T] +type BrokenAlias = _undefined_alias_target # noqa: F821 [undefined-name] + # Test datamodel options class TestDatamodelOptions: @@ -1175,6 +1183,38 @@ def test_strict_frozen_rejects_type_var_with_mutable_bound(self): class TypeVarModel: value: MutableBoundT + def test_strict_frozen_resolves_pep695_type_aliases(self): + # A PEP 695 alias stands for the annotation it resolves to, so it has to be + # checked through that: otherwise an alias for an immutable type would be + # rejected just for being an alias, and one hiding a mutable type would only be + # rejected by accident. + @datamodels.datamodel(frozen="strict") + class ImmutableAliasModel: + value: ImmutableAlias + + @datamodels.datamodel(frozen="strict") + class ParametrizedAliasModel: + value: PairAlias[int] + + assert hash(ImmutableAliasModel(value=(1, 2))) is not None + assert hash(ParametrizedAliasModel(value=(1, 2))) is not None + + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class MutableAliasModel: + value: MutableAlias + + def test_strict_frozen_rejects_unevaluable_pep695_type_alias(self): + # Alias values are evaluated lazily, so a broken one only fails here. It proves + # nothing about immutability and must be rejected rather than escaping as the + # raw 'NameError' / 'TypeError' from the alias evaluation. + with pytest.raises(exceptions.EveTypeError, match="strictly immutable"): + + @datamodels.datamodel(frozen="strict") + class BrokenAliasModel: + value: BrokenAlias + def test_strict_frozen_rejects_unresolved_forward_reference(self): # A self-reference cannot be resolved while the class is being created, so it # cannot be proven immutable: the check must reject it instead of raising the From 4163a518cd4c5de1a5be6c457dd51c010ff07124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 14 Aug 2026 14:41:06 +0200 Subject: [PATCH 21/24] docs[cartesian]: trim the '_is_ellipsis_node' docstring to what it does Per review: the paragraph explaining why 'ast.Ellipsis' and 'types.EllipsisType' are both unusable is context for this PR, not something a future reader of the function needs. The PR and its history carry that; the docstring just says what the function checks. --- src/gt4py/cartesian/frontend/gtscript_frontend.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/gt4py/cartesian/frontend/gtscript_frontend.py b/src/gt4py/cartesian/frontend/gtscript_frontend.py index 13989580be..a624600afa 100644 --- a/src/gt4py/cartesian/frontend/gtscript_frontend.py +++ b/src/gt4py/cartesian/frontend/gtscript_frontend.py @@ -53,12 +53,7 @@ def _is_ellipsis_node(node: ast.AST) -> bool: - """Check whether an AST node is the '...' literal. - - 'ast.Ellipsis' is a deprecated alias scheduled for removal in Python 3.14, and - 'types.EllipsisType' is the type of the '...' object itself, not of its AST node, - so neither is usable as an 'isinstance()' target here. - """ + """Check whether an AST node is the '...' literal.""" return isinstance(node, ast.Constant) and node.value is Ellipsis From 3bd102d72896257321603fe2dda92f9437f48840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 14 Aug 2026 14:41:23 +0200 Subject: [PATCH 22/24] fix[next]: group multiple compilation failures in an ExceptionGroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. 'wait_for_compilation' flattened several failures into one 'RuntimeError', which left failures 2..n as 'repr' text in the message and gave only the first one a '__cause__' and a traceback. They are now raised as a group, so each keeps its own. 'BaseExceptionGroup' is the constructor because 'Future.exception()' is typed as 'BaseException'; it returns a plain 'ExceptionGroup' whenever every member is an 'Exception', so 'except*' on 'Exception' still catches it. The 'Raises:' contract is updated accordingly, and the multi-failure path — which nothing covered before — gets a test. Also registers 'ast' nodes newer than the supported floor by name ('_NEWER_UNSUPPORTED_FEATURE_HINTS'), so the catalogue can name them without breaking the import on 3.12/3.13: PEP 750 t-strings now get a proper "Unsupported Python syntax: t-string" on 3.14 instead of the generic 'ast' class-name fallback. --- src/gt4py/next/ffront/dialect_parser.py | 18 +++++++++++ src/gt4py/next/otf/compiled_program.py | 32 +++++++++---------- .../ffront_tests/test_diagnostic_messages.py | 14 ++++++++ .../unit_tests/otf_tests/test_runners.py | 25 +++++++++++++++ 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/gt4py/next/ffront/dialect_parser.py b/src/gt4py/next/ffront/dialect_parser.py index c04e402f51..a7c27a3181 100644 --- a/src/gt4py/next/ffront/dialect_parser.py +++ b/src/gt4py/next/ffront/dialect_parser.py @@ -84,6 +84,24 @@ ast.Match: ("'match' statement", ("Use 'if'/'elif' chains or 'where' instead.",)), } +#: Same as above, but keyed by node *name*, for constructs introduced after the +#: supported Python floor: naming e.g. 'ast.TemplateStr' (3.14) directly in the +#: catalogue above would raise 'AttributeError' on 3.12 and 3.13. Entries whose node +#: type the running interpreter does not have are skipped, which is harmless: the +#: construct cannot be parsed there in the first place. +_NEWER_UNSUPPORTED_FEATURE_HINTS: dict[str, tuple[str, tuple[str, ...]]] = { + # PEP 750 t-strings (3.14). + "TemplateStr": ("t-string", ("Strings cannot be computed inside GT4Py functions.",)) +} + +_UNSUPPORTED_FEATURE_HINTS.update( + { + node_type: entry + for name, entry in _NEWER_UNSUPPORTED_FEATURE_HINTS.items() + if (node_type := getattr(ast, name, None)) is not None + } +) + def _describe_unsupported_feature(node: ast.AST) -> tuple[str, tuple[str, ...]]: if (entry := _UNSUPPORTED_FEATURE_HINTS.get(type(node))) is not None: diff --git a/src/gt4py/next/otf/compiled_program.py b/src/gt4py/next/otf/compiled_program.py index 85a7f688bd..d56e302ca4 100644 --- a/src/gt4py/next/otf/compiled_program.py +++ b/src/gt4py/next/otf/compiled_program.py @@ -170,12 +170,12 @@ def wait_for_compilation() -> None: Raises: Exception: The exception of the failed compilation. If several - compilations failed, a `RuntimeError` summarizing all of them - (chaining the first). Each failure is raised only once; the - original exception is raised again when the failed program - variant is called. Failures of programs that have been garbage - collected in the meantime are not reported (they could never - raise at call time either). + compilations failed, an `ExceptionGroup` (PEP 654) holding all of + them, so that every failure keeps its own traceback. Each failure + is raised only once; the original exception is raised again when + the failed program variant is called. Failures of programs that + have been garbage collected in the meantime are not reported + (they could never raise at call time either). """ # TODO(havogt): reconsider tearing down the default runner here: a pure wait # on the tracked futures would keep the workers warm between compilation @@ -190,16 +190,16 @@ def wait_for_compilation() -> None: if len(failures) == 1: raise failures[0][1] if failures: - # TODO(havogt): raise an ExceptionGroup here. The 3.10 floor that originally - # blocked this is gone (PEP 654 is available on the 3.12 floor), so only the - # flattening below still loses information: failures 2..n survive as 'repr' - # text and '__cause__'/'__traceback__' carry the first failure alone. Left as - # is because it changes the documented 'Raises:' contract of this public - # function from 'RuntimeError' to 'ExceptionGroup', which callers may catch. - raise RuntimeError( - "Multiple compilations failed: " - + "; ".join(f"'{label}': {error!r}" for label, error in failures) - ) from failures[0][1] + # A group keeps every failure with its own traceback; flattening them into a + # single error would reduce failures 2..n to 'repr' text and let only the first + # one carry a '__cause__'. 'BaseExceptionGroup' is the constructor to use since + # 'Future.exception()' is typed as 'BaseException'; it returns a plain + # 'ExceptionGroup' whenever every member is an 'Exception', which is the case + # for anything a compilation realistically raises. + raise BaseExceptionGroup( + "Multiple compilations failed: " + ", ".join(f"'{label}'" for label, _ in failures), + [error for _, error in failures], + ) def _make_tuple_expr(el_exprs: list[str]) -> str: diff --git a/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py b/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py index 43dab7cdb0..62072cab18 100644 --- a/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py +++ b/tests/next_tests/unit_tests/ffront_tests/test_diagnostic_messages.py @@ -117,6 +117,20 @@ def test_try_star_statement_is_catalogued(): assert any("Exception handling" in hint for hint in hints) +@pytest.mark.skipif( + not hasattr(ast, "TemplateStr"), reason="PEP 750 t-strings require Python >= 3.14." +) +def test_post_floor_construct_is_catalogued(): + # Constructs newer than the supported floor are registered by name, so the + # catalogue can name them without breaking the import on older interpreters. + node = ast.parse('t"{a}"', mode="eval").body + + feature, hints = dialect_parser._describe_unsupported_feature(node) + + assert feature == "t-string" + assert any("cannot be computed" in hint for hint in hints) + + def test_unlisted_construct_falls_back_to_ast_name(): def with_string(a: gtx.Field[[IDim], float64]) -> gtx.Field[[IDim], float64]: f"{a}" diff --git a/tests/next_tests/unit_tests/otf_tests/test_runners.py b/tests/next_tests/unit_tests/otf_tests/test_runners.py index ecaf91408a..75e713d51b 100644 --- a/tests/next_tests/unit_tests/otf_tests/test_runners.py +++ b/tests/next_tests/unit_tests/otf_tests/test_runners.py @@ -210,6 +210,31 @@ def test_wait_for_compilation_untracks_successful_futures(): assert future not in compiled_program._ongoing_compilations +def test_wait_for_compilation_groups_multiple_failures(): + errors = [ValueError("first boom"), TypeError("second boom")] + # The futures have to stay referenced: tracking is weak, so a collected future + # is not reported and this would degrade to the single-failure path. + futures = [concurrent.futures.Future() for _ in errors] + for i, (future, error) in enumerate(zip(futures, errors)): + future.set_exception(error) + compiled_program._ongoing_compilations[future] = f"testee_{i} (backend)" + + with pytest.raises(ExceptionGroup) as exc_info: + compiled_program.wait_for_compilation() + + # Every failure keeps its own traceback instead of being flattened into the + # message of a single error, and each program is named. It is a plain + # 'ExceptionGroup', not just a 'BaseExceptionGroup', so 'except*' on 'Exception' + # catches it. + assert type(exc_info.value) is ExceptionGroup + assert exc_info.value.exceptions == tuple(errors) + assert "testee_0 (backend)" in str(exc_info.value) + assert "testee_1 (backend)" in str(exc_info.value) + + # each failure is reported only once + compiled_program.wait_for_compilation() + + def test_detect_cuda_archs_prefers_cudaarchs_env(): with ( mock.patch.dict(os.environ, {"CUDAARCHS": "80;90"}), From 069b9422e74a932ad30ef8b607cc600a15fc6f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 14 Aug 2026 14:41:41 +0200 Subject: [PATCH 23/24] refactor[eve]: drop the dead 'typing_extensions.Any' branches Review follow-up. Two guards existed for the case where 'typing_extensions.Any' is a distinct object from 'typing.Any', which was only true below the 3.11 floor -- as the second one's own comment said. 'typing_extensions' re-exports 'typing.Any' on every supported version (checked on 3.12, 3.13 and 3.14), so '_ArtefactTypes' never grew a third entry and 'is_Any' always took its 'else' branch. Both collapse to the branch that actually ran. Also rewords the '__hash__' sentence in the 'frozen="strict"' docstring, which stated the rule without saying why it holds: a type defining its own '__hash__' is claiming its values hash by content, the mutable builtins opt out with '__hash__ = None', and the inherited 'object.__hash__' hashes by identity and says nothing about the value. --- src/gt4py/eve/datamodels/core.py | 20 +++++++++++++------- src/gt4py/eve/extended_typing.py | 25 ++++++------------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/gt4py/eve/datamodels/core.py b/src/gt4py/eve/datamodels/core.py index e4c8f69405..b67efdddcf 100644 --- a/src/gt4py/eve/datamodels/core.py +++ b/src/gt4py/eve/datamodels/core.py @@ -1063,13 +1063,19 @@ def _is_strictly_immutable_type( ) -> bool: """Check whether an annotation only admits strictly immutable (hashable) values. - A datamodel qualifies only if it is itself defined with ``frozen="strict"``; - any other plain type qualifies if it defines a custom ``__hash__``. Composite - annotations are decomposed and every part is checked with the same rules, so - that neither wrapping a type in a union nor hiding it in a generic container - (whose hash folds in the hashes of its items) weakens the check. Annotations - that cannot be resolved to any concrete type (``Any``, unbound type variables, - unresolved forward references) are conservatively rejected. + A datamodel qualifies only if it is itself defined with ``frozen="strict"``. + Any other plain type is judged by its ``__hash__``, which is the only signal + the language gives here: a type that defines its own is claiming its values + hash by content, which they can only do if they do not change, while the + mutable builtins opt out by setting ``__hash__ = None`` and the inherited + ``object.__hash__`` just hashes by identity and says nothing about the value. + So neither of those two counts, and everything else does. + + Composite annotations are decomposed and every part is checked with the same + rules, so that neither wrapping a type in a union nor hiding it in a generic + container (whose hash folds in the hashes of its items) weakens the check. + Annotations that cannot be resolved to any concrete type (``Any``, unbound + type variables, unresolved forward references) are conservatively rejected. Arguments: type_annotation: The annotation to check. diff --git a/src/gt4py/eve/extended_typing.py b/src/gt4py/eve/extended_typing.py index adfa6987f6..17fb667132 100644 --- a/src/gt4py/eve/extended_typing.py +++ b/src/gt4py/eve/extended_typing.py @@ -439,14 +439,7 @@ def __pretty__( # -- Added functionality -- -# `Any` is a class since Python 3.11, which is below the supported floor. -_ArtefactTypes: tuple[type, ...] = (_types.GenericAlias, _typing.Any) - -# `Any` is a class since typing_extensions >= 4.4 and Python 3.11 -if (typing_exts_any := getattr(_typing_extensions, "Any", None)) is not _typing.Any and isinstance( - typing_exts_any, type -): - _ArtefactTypes = (*_ArtefactTypes, typing_exts_any) +_ArtefactTypes: Final[tuple[type, ...]] = (_types.GenericAlias, _typing.Any) def is_actual_type(obj: Any) -> TypeGuard[type[Any]]: @@ -569,17 +562,11 @@ def eval_type_alias(annotation: Any) -> Any: ) -if hasattr(_typing_extensions, "Any") and _typing.Any is not _typing_extensions.Any: # type: ignore[attr-defined] # _typing_extensions.Any only from >= 4.4 - # When using Python < 3.11 and typing_extensions >= 4.4 there are - # two different implementations of `Any` - - def is_Any(obj: Any) -> bool: - return obj is _typing.Any or obj is _typing_extensions.Any # type: ignore[attr-defined] # _typing_extensions.Any only from >= 4.4 - -else: - - def is_Any(obj: Any) -> bool: - return obj is _typing.Any +def is_Any(obj: Any) -> bool: + """Check if an object is the ``Any`` special form.""" + # 'typing_extensions' re-exports 'typing.Any' on every supported version, so the + # two implementations that used to exist below the 3.11 floor are now one object. + return obj is _typing.Any def has_type_parameters(cls: type[Any]) -> bool: From 532fdb2b3db44814725f447b859169759b929f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Gonz=C3=A1lez=20Paredes?= Date: Fri, 14 Aug 2026 14:41:41 +0200 Subject: [PATCH 24/24] docs[next]: clarify the 'add_note' rendering split and post-floor 'ast' nodes Review follow-up. The 'add_note' paragraph opened with "This is the stock 'BaseException.add_note'", which read as a contrast with the 3.10 override this PR removes, and so looked like leftover history. It is not: the '__notes__' vs structured 'notes' split is current, version-independent behaviour -- 'DSLError.__str__' emits only the structured parts and 'errors/excepthook.py' appends '__notes__' itself. Reworded to state that directly. Drops the "supported floor is 3.12" / "carries no version shims" paragraph as meta-commentary that goes stale rather than guidance, and turns the remaining caveat into what to actually do about post-floor 'ast' nodes. --- docs/development/next/error-messages.md | 27 +++++++++++-------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/docs/development/next/error-messages.md b/docs/development/next/error-messages.md index dc2c81ddf1..a77c99ad04 100644 --- a/docs/development/next/error-messages.md +++ b/docs/development/next/error-messages.md @@ -119,15 +119,15 @@ except errors.DSLError as err: raise ``` -This is the stock `BaseException.add_note`, so the breadcrumb lands in -`__notes__`, not in the structured `notes` field — `notes` is reserved for -content authored at the raise site. The two have different renderers: +`add_note` puts the breadcrumb in `__notes__`, which is *not* the structured +`notes` field: `notes` is reserved for content authored at the raise site. The +two are rendered by different code, so do not expect one to show the other — `DSLError.__str__` emits only the structured parts, while `__notes__` is printed by the traceback machinery (and therefore by pytest and -IPython/Jupyter). The excepthook in `errors/excepthook.py` replaces that -machinery, so it appends `__notes__` itself. The seam is wired at -`func_to_foast` (`ffront/func_to_foast.py`); add it at later stages as they -gain useful context. +IPython/Jupyter). `errors/excepthook.py` replaces that machinery, so it +appends `__notes__` itself. The seam is wired at `func_to_foast` +(`ffront/func_to_foast.py`); add it at later stages as they gain useful +context. ### 5. Always: a test @@ -203,11 +203,8 @@ Unsupported operand type(s) for +: 'Field[[IDim], float64]' and 'Field[[IDim], b ## Python-version caveat -The supported floor is Python 3.12, so `Self`, `BaseException.add_note` (PEP -678\) and every `ast` node up to 3.12 can be used directly. - -The diagnostics code carries no version shims left over from the old 3.10 -floor; do not add new ones. - -Nodes introduced *after* 3.12 (for example `ast.TemplateStr` for PEP 750 -t-strings, 3.14) still cannot be referenced unconditionally in the catalogue. +`ast` nodes introduced *after* the supported floor cannot be named directly in +the catalogue — `ast.TemplateStr` (PEP 750 t-strings, 3.14) does not exist on +3.12, so the module would fail to import there. Register those by name in +`_NEWER_UNSUPPORTED_FEATURE_HINTS` instead; entries the running interpreter +does not have are skipped.