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/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..a77c99ad04 100644 --- a/docs/development/next/error-messages.md +++ b/docs/development/next/error-messages.md @@ -119,11 +119,13 @@ 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` +`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). `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. @@ -201,14 +203,8 @@ 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: - -- 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. - -These spots are flagged with `TODO(havogt)`. +`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. diff --git a/noxfile.py b/noxfile.py index e3163edbaa..94ec333558 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"] # /// @@ -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/pyproject.toml b/pyproject.toml index f128e45fd0..c42ef74133 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', 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/cartesian/frontend/gtscript_frontend.py b/src/gt4py/cartesian/frontend/gtscript_frontend.py index 61ae384068..a624600afa 100644 --- a/src/gt4py/cartesian/frontend/gtscript_frontend.py +++ b/src/gt4py/cartesian/frontend/gtscript_frontend.py @@ -52,6 +52,11 @@ PYTHON_AST_VERSION: Final = (3, 12) +def _is_ellipsis_node(node: ast.AST) -> bool: + """Check whether an AST node is the '...' literal.""" + return isinstance(node, ast.Constant) and node.value is Ellipsis + + class AssertionChecker(ast.NodeTransformer): """Check assertions and remove from the AST for further parsing.""" @@ -298,7 +303,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. """ @@ -344,7 +349,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 @@ -1372,7 +1377,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, types.EllipsisType) 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/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 c83da1a9e3..b67efdddcf 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] @@ -288,12 +284,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, @@ -307,12 +303,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] @@ -326,7 +322,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. @@ -357,10 +353,12 @@ 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 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 @@ -401,7 +399,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] @@ -416,7 +414,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) @@ -560,10 +558,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] @@ -642,25 +639,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, ... @@ -681,8 +677,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: @@ -705,7 +701,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: @@ -735,8 +731,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: @@ -782,14 +778,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: @@ -809,7 +805,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, @@ -836,7 +832,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): @@ -847,7 +843,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, []): @@ -863,7 +859,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__", []): @@ -876,8 +872,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 @@ -962,14 +958,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__) @@ -1056,9 +1052,119 @@ 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, *, _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"``. + 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. + + 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 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 + + 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 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: + 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, _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) + + 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 + + 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( - cls: Type[_T], + cls: type[_T], *, repr: bool, # noqa: A002 [builtin-argument-shadowing] eq: bool, @@ -1072,13 +1178,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__ = {} @@ -1251,18 +1357,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 - elif xtyping.is_hashable_type(f_attr.type): - 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__: @@ -1305,11 +1409,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: @@ -1413,7 +1517,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 4c0debe45a..17fb667132 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 @@ -34,53 +35,100 @@ 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 +# 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 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 '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 + +# 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 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 (replacement := _DEPRECATED_ALIAS_REPLACEMENTS.get(name)) is not None: + return replacement + return getattr(_sys.modules[__name__], name) + + +_FORWARD_REF_TYPING_NS: Final = _ForwardRefTypingNamespace() # These fallbacks are useful for public symbols not exported by default. @@ -90,6 +138,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) @@ -106,16 +160,16 @@ 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 - ] + # 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 @@ -133,8 +187,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]] @@ -159,7 +213,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 # Both PEP 695 type alias implementations, for the same reason as in `_TypeAliasTypes` @@ -172,13 +226,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 -- @@ -194,14 +248,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]: ... @@ -302,15 +356,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 @@ -327,16 +381,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]] @@ -351,7 +405,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): @@ -385,20 +439,10 @@ 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 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]: +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: @@ -518,25 +562,19 @@ 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: + """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 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 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) @@ -544,8 +582,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.""" recurse = _functools.partial(get_represented_types, globalns=globalns, localns=localns) @@ -588,7 +626,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__) @@ -739,10 +777,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 @@ -756,7 +794,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} @@ -783,8 +821,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: @@ -798,9 +836,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]] """ @@ -809,16 +846,23 @@ 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)) + 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) 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: @@ -828,7 +872,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( @@ -873,7 +917,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 @@ -904,7 +948,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. @@ -917,7 +961,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) @@ -934,8 +978,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 71c90a01f1..2f881fc657 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 456add5ebc..e1b95f12b5 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. @@ -474,7 +472,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: @@ -502,8 +500,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/next/errors/exceptions.py b/src/gt4py/next/errors/exceptions.py index 3533dd1fe8..1405cc714c 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 ab51613ac1..a7c27a3181 100644 --- a/src/gt4py/next/ffront/dialect_parser.py +++ b/src/gt4py/next/ffront/dialect_parser.py @@ -29,8 +29,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", @@ -62,7 +60,19 @@ "'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", + ("Exception handling is not available inside GT4Py functions.",), + ), ast.Raise: ( "'raise' statement", ("Exception handling is not available inside GT4Py functions.",), @@ -74,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/ffront/fbuiltins.py b/src/gt4py/next/ffront/fbuiltins.py index e77f090f99..602221fbcc 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,12 +141,15 @@ 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] - 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 + # '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): + 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: diff --git a/src/gt4py/next/ffront/func_to_foast.py b/src/gt4py/next/ffront/func_to_foast.py index b762da8d9b..14dceb25d1 100644 --- a/src/gt4py/next/ffront/func_to_foast.py +++ b/src/gt4py/next/ffront/func_to_foast.py @@ -232,6 +232,12 @@ def _reject_invalid_return_annotation(self, node: ast.FunctionDef) -> None: 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 3539165e6a..f0739a102b 100644 --- a/src/gt4py/next/fingerprinting.py +++ b/src/gt4py/next/fingerprinting.py @@ -571,11 +571,10 @@ 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 - `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 + 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 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 bba32e16ed..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,11 +190,16 @@ 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. - 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/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/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py b/tests/cartesian_tests/unit_tests/frontend_tests/test_gtscript_frontend.py index ee4a65d3f2..1baeefecee 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 from enum import IntEnum, StrEnum, Enum import inspect import functools @@ -2479,6 +2480,25 @@ def stencil(in_field: gtscript.Field[float], out_field: gtscript.Field[float]): ) +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): + 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__) + + @gtscript.enum class LocalEnum(IntEnum): A = 42 diff --git a/tests/eve_tests/unit_tests/test_datamodels.py b/tests/eve_tests/unit_tests/test_datamodels.py index 44dc7aa4a8..a9f6872353 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,10 +1007,34 @@ 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] + + +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: def test_frozen(self): - import attr # Missing library stubs for Python 3.10) + import attr @datamodels.datamodel(frozen=True) class FrozenModel: @@ -1066,6 +1090,159 @@ 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 + + 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_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_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_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 + # 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: + value: Optional[int] = None + mode: Literal["a", "b"] = "a" + inner: Optional[StrictFrozenInner] = None + + 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(): diff --git a/tests/eve_tests/unit_tests/test_extended_typing.py b/tests/eve_tests/unit_tests/test_extended_typing.py index 1909ce7f68..1b105d8a64 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 @@ -21,16 +25,10 @@ Annotated, Any, Callable, - Dict, ForwardRef, - FrozenSet, - List, Mapping, Optional, Sequence, - Set, - Tuple, - Type, TypeVar, Union, ) @@ -172,6 +170,77 @@ 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): + # 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)) def test_is_actual_valid_type(t): assert xtyping.is_actual_type(t) @@ -180,11 +249,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], ), ) @@ -199,8 +268,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), ] @@ -284,15 +355,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 @@ -310,10 +381,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: ... @@ -359,19 +430,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(): ... @@ -382,30 +453,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, ...]}) ] ) @@ -424,11 +495,11 @@ def test_is_single_dispatch_callable(): # -- PEP 695 type aliases -- type SampleIntAlias = int type SampleChainedAlias = SampleIntAlias -type SampleGenericAlias[T] = Tuple[T, T] +type SampleGenericAlias[T] = tuple[T, T] type SampleRecursiveAlias = SampleRecursiveAlias type SampleMutualAliasA = SampleMutualAliasB type SampleMutualAliasB = SampleMutualAliasA -type SampleDivergingGenericAlias[T] = SampleDivergingGenericAlias[Tuple[T]] +type SampleDivergingGenericAlias[T] = SampleDivergingGenericAlias[tuple[T]] def test_is_type_alias(): @@ -439,18 +510,18 @@ def test_is_type_alias(): assert xtyping.is_type_alias(typing_extensions.TypeAliasType("Backported", str)) assert not xtyping.is_type_alias(int) - assert not xtyping.is_type_alias(List[int]) + assert not xtyping.is_type_alias(list[int]) assert not xtyping.is_type_alias(SampleGenericAlias[int]) def test_eval_type_alias(): assert xtyping.eval_type_alias(SampleIntAlias) is int assert xtyping.eval_type_alias(SampleChainedAlias) is int - assert xtyping.eval_type_alias(SampleGenericAlias[int]) == Tuple[int, int] + assert xtyping.eval_type_alias(SampleGenericAlias[int]) == tuple[int, int] def test_eval_type_alias_passes_through_non_aliases(): - for annotation in (int, List[int], xtyping.Any, None): + for annotation in (int, list[int], xtyping.Any, None): assert xtyping.eval_type_alias(annotation) is annotation @@ -523,7 +594,7 @@ def test_get_represented_types(): SampleReprA, SampleReprB, ) - assert xtyping.get_represented_types(List[int]) == (list,) + assert xtyping.get_represented_types(list[int]) == (list,) def test_get_represented_types_resolves_type_aliases(): 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 023388b7a9..3736e001e8 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, @@ -160,8 +156,8 @@ class SampleSlottedDataClass: # -- PEP 695 type aliases -- type SampleIntAlias = int type SampleChainedAlias = SampleIntAlias -type SampleListAlias = List[SampleIntAlias] -type SamplePairAlias[T] = Tuple[T, T] +type SampleListAlias = list[SampleIntAlias] +type SamplePairAlias[T] = tuple[T, T] SAMPLE_TYPE_DEFINITIONS.extend( @@ -169,7 +165,7 @@ class SampleSlottedDataClass: (SampleIntAlias, [1, -1], [1.0, "1"], None, None), (SampleChainedAlias, [1, -1], [1.0, "1"], None, None), (SampleListAlias, ([1, 2, 3], []), (1, [1.0]), None, None), - (List[SampleIntAlias], ([1, 2, 3], []), (1, [1.0]), None, None), + (list[SampleIntAlias], ([1, 2, 3], []), (1, [1.0]), None, None), (Optional[SampleIntAlias], [1, None], ["1"], None, None), (SamplePairAlias[int], [(1, 2)], [(1, "2"), (1,)], None, None), ] @@ -185,8 +181,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) @@ -205,8 +201,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: @@ -234,8 +230,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): @@ -244,7 +240,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) 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 321e57e8d5..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 @@ -15,14 +15,15 @@ regress. When changing a message, update the expectation here alongside. """ +import ast import re -import sys import types 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 @@ -82,6 +83,54 @@ 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(): + # 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 + 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] + assert isinstance(node, ast.TryStar) + + feature, hints = dialect_parser._describe_unsupported_feature(node) + + assert feature == "'try*' statement" + 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}" @@ -157,17 +206,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 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 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"}), diff --git a/typing_tests/mypy.ini b/typing_tests/mypy.ini new file mode 100644 index 0000000000..307a97b27a --- /dev/null +++ b/typing_tests/mypy.ini @@ -0,0 +1,26 @@ +; 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 +; 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