-
Notifications
You must be signed in to change notification settings - Fork 60
fix[eve]: validate 'type[X]' annotations by subclass, not by 'is a class' #2843
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6791489
32ec0a6
93f8d87
99e76ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -302,6 +302,49 @@ def __call__( | |
| ) | ||
| return self.combine_optional(name, validator) if has_none else validator | ||
|
|
||
| if origin_type is type: | ||
| # `type[X]`. Without this case the annotation falls through to the | ||
| # generic-collection branch below and degrades to `isinstance(value, type)`, | ||
| # i.e. "is any class at all". | ||
| if len(type_args) != 1: | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Regression: shapes that validated fine on Three annotation shapes reach this branch and fall through to the
Verified by running all three against The PEP 695 case is the one that stings: alias resolution happens once at the top of the factory for the whole annotation, so an alias nested inside Fix: handle bare
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Regression tests added for |
||
| # bare `type` / `typing.Type` | ||
| return self.make_is_instance_of(name, type) | ||
|
|
||
| # A PEP 695 alias nested inside `type[...]` is not resolved by the | ||
| # whole-annotation pass at the top of this function, so resolve it here. | ||
| try: | ||
| arg = xtyping.eval_type_alias(type_args[0]) | ||
| except TypeError: | ||
| return self.make_is_instance_of(name, type) | ||
|
|
||
| if isinstance(arg, types.UnionType): # `type[A | B]` | ||
| arg = typing.Union[arg.__args__] | ||
|
|
||
| # `issubclass()` is not generally usable with protocol classes: it is | ||
| # rejected outright unless the protocol is `@runtime_checkable`, and also | ||
| # for `@runtime_checkable` protocols with non-method members. Protocols | ||
| # therefore keep the loose check, like any other unsupported shape. | ||
| def is_strict_arg(a: Any) -> xtyping.TypeGuard[type]: | ||
| return xtyping.is_actual_type(a) and not xtyping.is_protocol(a) | ||
|
|
||
| if isinstance(arg, typing.TypeVar): | ||
| # Mirror the plain-`TypeVar` branch above, which honours the bound. | ||
| if is_strict_arg(arg.__bound__): | ||
| return self.make_is_subclass_of(name, arg.__bound__) | ||
| return self.make_is_instance_of(name, type) | ||
|
|
||
| if is_strict_arg(arg): | ||
| return self.make_is_subclass_of(name, arg) | ||
|
|
||
| if xtyping.get_origin(arg) is Union: # `type[A | B]`, `type[Union[A, B]]` | ||
| members = xtyping.get_args(arg) | ||
| if members and all(is_strict_arg(m) for m in members): | ||
| return self.combine_validators_as_or( | ||
| name, *(self.make_is_subclass_of(name, m) for m in members) | ||
| ) | ||
|
|
||
| return self.make_is_instance_of(name, type) | ||
|
|
||
| if isinstance(origin_type, type): | ||
| # Deal with generic collections | ||
| if issubclass(origin_type, tuple): | ||
|
|
@@ -406,6 +449,18 @@ def _is_instance_of(value: Any, **kwargs: Any) -> None: | |
|
|
||
| return _is_instance_of | ||
|
|
||
| @staticmethod | ||
| def make_is_subclass_of(name: str, type_: type) -> FixedTypeValidator: | ||
| """Create a ``FixedTypeValidator`` validator for ``type[type_]`` annotations.""" | ||
|
|
||
| def _is_subclass_of(value: Any, **kwargs: Any) -> None: | ||
| if not (isinstance(value, type) and issubclass(value, type_)): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That is inside the letter of "never reject a shape that used to validate" — it fails at validation time rather than class-creation time — but not its spirit, and the message points at Nothing breaks today: no Cheapest fix: treat a Protocol as an unrecognized shape in the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, and confirmed — this was a real bug. Reproduced both variants against both raised for a conforming class, i.e. the field was unusable for every value. Fixed in def is_strict_arg(a: Any) -> xtyping.TypeGuard[type]:
return xtyping.is_actual_type(a) and not xtyping.is_protocol(a)so a protocol falls back to the loose check like any other unsupported shape. One judgement call worth your sign-off: this also loosens the case where |
||
| raise TypeError( | ||
| f"'{name}' must be a subclass of {type_} (got '{value}' which is a {type(value)})." | ||
| ) | ||
|
|
||
| return _is_subclass_of | ||
|
|
||
| @staticmethod | ||
| def make_is_instance_of_int(name: str) -> FixedTypeValidator: | ||
| """Create an ``FixedTypeValidator`` validator for ``int`` values which fails with ``bool`` values.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,23 @@ class SampleDataClass: | |
| a: int | ||
|
|
||
|
|
||
| type SampleTypeAlias = SampleEmptyClass # PEP 695, nested inside `type[...]` | ||
|
|
||
| type SampleRecursiveTypeAlias = SampleRecursiveTypeAlias # cannot be resolved | ||
|
|
||
| SampleBoundTypeVar = typing.TypeVar("SampleBoundTypeVar", bound=SampleEmptyClass) | ||
| SampleUnboundTypeVar = typing.TypeVar("SampleUnboundTypeVar") | ||
|
|
||
|
|
||
| class SampleProtocol(typing.Protocol): | ||
| def method(self) -> None: ... | ||
|
|
||
|
|
||
| @typing.runtime_checkable | ||
| class SampleRuntimeCheckableProtocol(typing.Protocol): | ||
| attribute: int | ||
|
|
||
|
|
||
| # Each item should be a tuple like: | ||
| # ( annotation: Any, valid_values: Sequence, wrong_values: Sequence, | ||
| # globalns: Optional[Dict[str, Any]], localns: Optional[Dict[str, Any]] ) | ||
|
|
@@ -75,6 +92,39 @@ class SampleDataClass: | |
| (typing.List[int], ([1, 2, 3], []), (1, [1.0]), None, None), | ||
| (typing.Set[int], ({1, 2, 3}, set()), (1, [1], (1,), {1: None}), None, None), | ||
| (typing.Dict[int, str], ({}, {3: "three"}), ([(3, "three")], 3, "three", []), None, None), | ||
| (type[SampleEmptyClass], [SampleEmptyClass], [SampleEmptyClass(), int, 3], None, None), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The table covers the recognized shapes well. Three gaps worth closing, the first one most:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All three closed in
|
||
| (type[SampleDataClass], [SampleDataClass], [SampleDataClass(a=1), str], None, None), | ||
| (type[Any], [SampleEmptyClass, int, str], [3, "int", SampleEmptyClass()], None, None), | ||
| # `type[X]` shapes that are not a plain class must stay *usable*: they validated | ||
| # loosely before the `type[X]` case existed, so they fall back to "is a class" | ||
| # rather than being rejected at class-creation time. | ||
| (type, [SampleEmptyClass, int], [3, "int"], None, None), | ||
| (typing.Type, [SampleEmptyClass, int], [3, "int"], None, None), | ||
| ( | ||
| type[SampleEmptyClass | SampleDataClass], | ||
| [SampleEmptyClass, SampleDataClass], | ||
| [int, SampleEmptyClass(), 3], | ||
| None, | ||
| None, | ||
| ), | ||
| ( | ||
| type[typing.Union[SampleEmptyClass, SampleDataClass]], | ||
| [SampleEmptyClass, SampleDataClass], | ||
| [int, SampleEmptyClass(), 3], | ||
| None, | ||
| None, | ||
| ), | ||
| (type[SampleTypeAlias], [SampleEmptyClass], [int, SampleEmptyClass(), 3], None, None), | ||
| # An alias which cannot be resolved keeps the loose check instead of propagating | ||
| # the resolution error out of the class definition. | ||
| (type[SampleRecursiveTypeAlias], [SampleEmptyClass, int], [3, "int"], None, None), | ||
| # Protocols do not support 'issubclass()' in general, so they keep the loose check | ||
| # too, independently of whether they are '@runtime_checkable'. | ||
| (type[SampleProtocol], [SampleEmptyClass, int], [3, "int"], None, None), | ||
| (type[SampleRuntimeCheckableProtocol], [SampleEmptyClass, int], [3, "int"], None, None), | ||
| # a bounded TypeVar is honoured, mirroring the plain-TypeVar branch | ||
| (type[SampleBoundTypeVar], [SampleEmptyClass], [int, SampleEmptyClass()], None, None), | ||
| (type[SampleUnboundTypeVar], [SampleEmptyClass, int], [3, "int"], None, None), | ||
| ( | ||
| frozendict[int, str], | ||
| ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # GT4Py - GridTools Framework | ||
| # | ||
| # Copyright (c) 2014-2024, ETH Zurich | ||
| # All rights reserved. | ||
| # | ||
| # Please, refer to the LICENSE file in the root directory. | ||
| # SPDX-License-Identifier: BSD-3-Clause | ||
|
|
||
| import pytest | ||
|
|
||
| from gt4py.next.type_system import type_specifications as ts | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "constraint", | ||
| [ | ||
| None, | ||
| ts.ScalarType, | ||
| ts.TypeSpec, | ||
| (ts.ScalarType,), | ||
| (ts.ScalarType, ts.FieldType), | ||
| ], | ||
| ) | ||
| def test_deferred_type_accepts_type_spec_constraints(constraint): | ||
| assert ts.DeferredType(constraint=constraint).constraint is constraint | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("constraint", [int, ts.ScalarType(kind=ts.ScalarKind.INT32)]) | ||
| def test_deferred_type_rejects_non_type_spec_constraint(constraint): | ||
| # 'constraint' is annotated as 'type[TypeSpec] | tuple[type[TypeSpec], ...] | None', | ||
| # which is checked with 'issubclass()' and not just with "is a class at all". | ||
| with pytest.raises(TypeError, match="constraint"): | ||
| ts.DeferredType(constraint=constraint) | ||
|
|
||
|
|
||
| def test_deferred_type_rejects_non_type_spec_constraint_in_tuple(): | ||
| with pytest.raises(TypeError, match="constraint"): | ||
| ts.DeferredType(constraint=(ts.ScalarType, int)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR description correction — the body says two fields become strictly validated,
ts.DeferredType.constraintandffront.stages.node_class. Only the first one does.DSLFieldOperatorDefis a plain@dataclasses.dataclass(frozen=True)(stages.py:35,74), not aneve.datamodels.DataModel, andeve.type_validationis imported by exactly one module —eve/datamodels/core.py. A plain dataclass field never reaches this factory, sonode_classis unaffected.I imported all of
gt4py(0 import failures) and walked everyDataModelsubclass's__datamodel_fields__looking for atype[...]annotation. There is exactly one in the tree:That one behaves as the body claims, verified end to end:
and every in-tree
constraint=call site passes ats.*Typeclass,None, or a tuple of them, so nothing regresses. Only thenode_classhalf of the sentence needs dropping.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, and thank you for the receipt. I reproduced it independently: walking
__datamodel_fields__over everyis_datamodelclass ineve/_core/storage/cartesian/next(322 classes, recursing intoget_argsso nestedtype[...]counts) turns up exactly one field —DeferredType.constraint. Andgrep -rn "type_validation" src/gt4py --include=*.pyhas a single importer,eve/datamodels/core.py, so a plain frozen dataclass likeDSLFieldOperatorDefnever reaches the factory.PR description corrected to name only
DeferredType.constraint.