diff --git a/assertpy2/_engine/_builder_check_typing.py b/assertpy2/_engine/_builder_check_typing.py index 378e70e2..68bad84c 100644 --- a/assertpy2/_engine/_builder_check_typing.py +++ b/assertpy2/_engine/_builder_check_typing.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Any, Protocol, SupportsFloat, TypeVar, overload + from .._matcher_impls import ClassInfo from ..matchers import Matcher from ..outcome import AssertionOutcome from ._capable_typing import _Callable, _Orderable, _PathLike @@ -44,6 +45,8 @@ _V = TypeVar("_V") _R = TypeVar("_R") _B_co = TypeVar("_B_co", bytes, bytearray, covariant=True) + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _Number = SupportsFloat # the rungs below restrict `self` with the annotations `assert_that()` overloads are written with @@ -1170,11 +1173,28 @@ def is_instance_of(self: _CheckAnyValue[_T], some_class: type[bytes]) -> Asserti @overload def is_instance_of(self: _CheckAnyValue[_T], some_class: type[bytearray]) -> AssertionOutcome: ... @overload + def is_instance_of(self: _CheckAnyValue[_T], some_class: tuple[type[_U], type[_U2]]) -> AssertionOutcome: ... + @overload + def is_instance_of( + self: _CheckAnyValue[_T], some_class: tuple[type[_U], type[_U2], type[_U3]] + ) -> AssertionOutcome: ... + @overload def is_instance_of(self: _CheckAnyValue[_T], some_class: type[_U]) -> AssertionOutcome: ... @overload - def is_instance_of(self: _CheckAnyValue[_T], some_class: type) -> AssertionOutcome: ... + def is_instance_of(self: _CheckAnyValue[_T], some_class: ClassInfo) -> AssertionOutcome: ... @overload - def is_instance_of(self, some_class: type) -> AssertionOutcome: ... + def is_instance_of(self, some_class: ClassInfo) -> AssertionOutcome: ... + + @overload + def is_instance_of_any(self: _CheckAnyValue[_T], first: type[_U], second: type[_U2], /) -> AssertionOutcome: ... + @overload + def is_instance_of_any( + self: _CheckAnyValue[_T], first: type[_U], second: type[_U2], third: type[_U3], / + ) -> AssertionOutcome: ... + @overload + def is_instance_of_any(self: _CheckAnyValue[_T], *some_classes: ClassInfo) -> AssertionOutcome: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> AssertionOutcome: ... def is_equal_to( self, @@ -1202,8 +1222,6 @@ def is_none(self) -> AssertionOutcome: ... def is_type_of(self, some_type: type) -> AssertionOutcome: ... - def is_instance_of_any(self, *some_classes: type) -> AssertionOutcome: ... - def is_subclass_of(self, some_class: type) -> AssertionOutcome: ... def is_length(self, length: int) -> AssertionOutcome: ... diff --git a/assertpy2/_engine/_capable_typing.py b/assertpy2/_engine/_capable_typing.py index 8e71dd99..de3f81d1 100644 --- a/assertpy2/_engine/_capable_typing.py +++ b/assertpy2/_engine/_capable_typing.py @@ -30,6 +30,7 @@ from typing_extensions import TypeIs + from .._matcher_impls import ClassInfo from ..assertpy import AssertionBuilder from ..matchers import Matcher from ._builder_check_typing import _CheckAnyValue @@ -39,6 +40,8 @@ # covariant: the façade only ever hands the subject back, through `value` _CapableT_co = TypeVar("_CapableT_co", covariant=True) + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _E_co = TypeVar("_E_co", covariant=True) class _Orderable(Protocol): @@ -88,9 +91,23 @@ def is_not_none(self: _CapableAssertion[_U | None]) -> _CapableAssertion[_U]: .. @overload def is_not_none(self) -> Self: ... @overload + def is_instance_of(self, some_class: tuple[type[_U], type[_U2]]) -> AssertionBuilder[_U | _U2]: ... + @overload + def is_instance_of( + self, some_class: tuple[type[_U], type[_U2], type[_U3]] + ) -> AssertionBuilder[_U | _U2 | _U3]: ... + @overload def is_instance_of(self, some_class: type[_U]) -> AssertionBuilder[_U]: ... @overload - def is_instance_of(self, some_class: type) -> Self: ... + def is_instance_of(self, some_class: ClassInfo) -> Self: ... + @overload + def is_instance_of_any(self, first: type[_U], second: type[_U2], /) -> AssertionBuilder[_U | _U2]: ... + @overload + def is_instance_of_any( + self, first: type[_U], second: type[_U2], third: type[_U3], / + ) -> AssertionBuilder[_U | _U2 | _U3]: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> Self: ... @overload def first(self: _CapableAssertion[Mapping[_K, _V]]) -> AssertionBuilder[_K]: ... @overload @@ -294,7 +311,6 @@ def is_hex_equal_to( ) -> _CapableAssertion[_CapableT_co]: ... def is_in(self, *items: object) -> Self: ... def is_inf(self) -> Self: ... - def is_instance_of_any(self, *some_classes: type) -> Self: ... def is_iterable(self) -> Self: ... def is_length(self, length: int) -> Self: ... def is_length_between(self, low: int, high: int) -> Self: ... diff --git a/assertpy2/_engine/_check_typing.py b/assertpy2/_engine/_check_typing.py index 2f70a57b..c2c68297 100644 --- a/assertpy2/_engine/_check_typing.py +++ b/assertpy2/_engine/_check_typing.py @@ -20,6 +20,7 @@ from typing_extensions import TypeIs + from .._matcher_impls import ClassInfo from ..errors import AssertionOutcome from ..matchers import Matcher from ._compat import Self @@ -45,6 +46,8 @@ _K = TypeVar("_K") _V = TypeVar("_V") _B_co = TypeVar("_B_co", bytes, bytearray, covariant=True) + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _Number = SupportsFloat class _CheckMembershipAssertion(Protocol): @@ -178,8 +181,8 @@ def is_false(self) -> AssertionOutcome: ... def is_none(self) -> AssertionOutcome: ... def is_not_none(self) -> AssertionOutcome: ... def is_type_of(self, some_type: type) -> AssertionOutcome: ... - def is_instance_of(self, some_class: type) -> AssertionOutcome: ... - def is_instance_of_any(self, *some_classes: type) -> AssertionOutcome: ... + def is_instance_of(self, some_class: ClassInfo) -> AssertionOutcome: ... + def is_instance_of_any(self, *some_classes: ClassInfo) -> AssertionOutcome: ... def is_subclass_of(self, some_class: type) -> AssertionOutcome: ... def is_length(self, length: int) -> AssertionOutcome: ... def is_length_between(self, low: int, high: int) -> AssertionOutcome: ... @@ -321,9 +324,19 @@ def is_instance_of(self, some_class: type[bytes]) -> AssertionOutcome: ... @overload def is_instance_of(self, some_class: type[bytearray]) -> AssertionOutcome: ... @overload + def is_instance_of(self, some_class: tuple[type[_U], type[_U2]]) -> AssertionOutcome: ... + @overload + def is_instance_of(self, some_class: tuple[type[_U], type[_U2], type[_U3]]) -> AssertionOutcome: ... + @overload def is_instance_of(self, some_class: type[_U]) -> AssertionOutcome: ... @overload - def is_instance_of(self, some_class: type) -> AssertionOutcome: ... + def is_instance_of(self, some_class: ClassInfo) -> AssertionOutcome: ... + @overload + def is_instance_of_any(self, first: type[_U], second: type[_U2], /) -> AssertionOutcome: ... + @overload + def is_instance_of_any(self, first: type[_U], second: type[_U2], third: type[_U3], /) -> AssertionOutcome: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> AssertionOutcome: ... @property def not_(self) -> Self: ... diff --git a/assertpy2/_engine/_poll_typing.py b/assertpy2/_engine/_poll_typing.py index 7c78c24f..48e75f51 100644 --- a/assertpy2/_engine/_poll_typing.py +++ b/assertpy2/_engine/_poll_typing.py @@ -31,6 +31,7 @@ from pathlib import Path from typing import Any, Protocol, SupportsFloat, TypeVar, overload + from .._matcher_impls import ClassInfo from ..assertpy import AssertionBuilder from ..matchers import Matcher from ._capable_typing import _Callable, _Orderable, _PathLike @@ -52,6 +53,8 @@ _V = TypeVar("_V") _R = TypeVar("_R") _B_co = TypeVar("_B_co", bytes, bytearray, covariant=True) + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _Number = SupportsFloat # the rungs below restrict `self` with the annotations `assert_that()` overloads are written with @@ -1256,11 +1259,28 @@ def is_instance_of(self: _SyncPoll[_T], some_class: type[bytes]) -> _SyncPoll[by @overload def is_instance_of(self: _SyncPoll[_T], some_class: type[bytearray]) -> _SyncPoll[bytearray]: ... @overload + def is_instance_of(self: _SyncPoll[_T], some_class: tuple[type[_U], type[_U2]]) -> _SyncPoll[_U | _U2]: ... + @overload + def is_instance_of( + self: _SyncPoll[_T], some_class: tuple[type[_U], type[_U2], type[_U3]] + ) -> _SyncPoll[_U | _U2 | _U3]: ... + @overload def is_instance_of(self: _SyncPoll[_T], some_class: type[_U]) -> _SyncPoll[_U]: ... @overload - def is_instance_of(self: _SyncPoll[_T], some_class: type) -> _SyncPoll[_P_co]: ... + def is_instance_of(self: _SyncPoll[_T], some_class: ClassInfo) -> _SyncPoll[_P_co]: ... @overload - def is_instance_of(self, some_class: type) -> _SyncPoll[_P_co]: ... + def is_instance_of(self, some_class: ClassInfo) -> _SyncPoll[_P_co]: ... + + @overload + def is_instance_of_any(self: _SyncPoll[_T], first: type[_U], second: type[_U2], /) -> _SyncPoll[_U | _U2]: ... + @overload + def is_instance_of_any( + self: _SyncPoll[_T], first: type[_U], second: type[_U2], third: type[_U3], / + ) -> _SyncPoll[_U | _U2 | _U3]: ... + @overload + def is_instance_of_any(self: _SyncPoll[_T], *some_classes: ClassInfo) -> _SyncPoll[_P_co]: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> _SyncPoll[_P_co]: ... def described_as(self, description: str) -> _SyncPoll[_P_co]: ... @@ -1290,8 +1310,6 @@ def is_none(self) -> _SyncPoll[_P_co]: ... def is_type_of(self, some_type: type) -> _SyncPoll[_P_co]: ... - def is_instance_of_any(self, *some_classes: type) -> _SyncPoll[_P_co]: ... - def is_subclass_of(self, some_class: type) -> _SyncPoll[_P_co]: ... def is_length(self, length: int) -> _SyncPoll[_P_co]: ... @@ -2563,11 +2581,28 @@ def is_instance_of(self: _AsyncPoll[_T], some_class: type[bytes]) -> _AsyncPoll[ @overload def is_instance_of(self: _AsyncPoll[_T], some_class: type[bytearray]) -> _AsyncPoll[bytearray]: ... @overload + def is_instance_of(self: _AsyncPoll[_T], some_class: tuple[type[_U], type[_U2]]) -> _AsyncPoll[_U | _U2]: ... + @overload + def is_instance_of( + self: _AsyncPoll[_T], some_class: tuple[type[_U], type[_U2], type[_U3]] + ) -> _AsyncPoll[_U | _U2 | _U3]: ... + @overload def is_instance_of(self: _AsyncPoll[_T], some_class: type[_U]) -> _AsyncPoll[_U]: ... @overload - def is_instance_of(self: _AsyncPoll[_T], some_class: type) -> _AsyncPoll[_P_co]: ... + def is_instance_of(self: _AsyncPoll[_T], some_class: ClassInfo) -> _AsyncPoll[_P_co]: ... @overload - def is_instance_of(self, some_class: type) -> _AsyncPoll[_P_co]: ... + def is_instance_of(self, some_class: ClassInfo) -> _AsyncPoll[_P_co]: ... + + @overload + def is_instance_of_any(self: _AsyncPoll[_T], first: type[_U], second: type[_U2], /) -> _AsyncPoll[_U | _U2]: ... + @overload + def is_instance_of_any( + self: _AsyncPoll[_T], first: type[_U], second: type[_U2], third: type[_U3], / + ) -> _AsyncPoll[_U | _U2 | _U3]: ... + @overload + def is_instance_of_any(self: _AsyncPoll[_T], *some_classes: ClassInfo) -> _AsyncPoll[_P_co]: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> _AsyncPoll[_P_co]: ... def described_as(self, description: str) -> _AsyncPoll[_P_co]: ... @@ -2597,8 +2632,6 @@ def is_none(self) -> _AsyncPoll[_P_co]: ... def is_type_of(self, some_type: type) -> _AsyncPoll[_P_co]: ... - def is_instance_of_any(self, *some_classes: type) -> _AsyncPoll[_P_co]: ... - def is_subclass_of(self, some_class: type) -> _AsyncPoll[_P_co]: ... def is_length(self, length: int) -> _AsyncPoll[_P_co]: ... diff --git a/assertpy2/_engine/_typing.py b/assertpy2/_engine/_typing.py index a323024e..c3c555db 100644 --- a/assertpy2/_engine/_typing.py +++ b/assertpy2/_engine/_typing.py @@ -11,6 +11,7 @@ from typing_extensions import TypeIs from .._engine._introspection import MappingLike + from .._matcher_impls import ClassInfo from ..assertpy import AssertionBuilder from ..matchers import Matcher from ._check_typing import ( @@ -44,7 +45,9 @@ _K = TypeVar("_K") # tracked dict key type _V = TypeVar("_V") # tracked dict value type _B_co = TypeVar("_B_co", bytes, bytearray, covariant=True) # tracked bytes type (output-only -> covariant) - _U = TypeVar("_U") # the type a TypeIs predicate refines the tracked value to + _U = TypeVar("_U") + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") # the type a TypeIs predicate refines the tracked value to _Other = TypeVar("_Other") # an element of the sequence a pairwise quantifier walks alongside _T_co = TypeVar("_T_co", covariant=True) # the subject of a value no overload recognises _P_co = TypeVar("_P_co", covariant=True) # what a polled probe hands back, which is what its chain asserts on @@ -234,8 +237,8 @@ def is_false(self) -> Self: ... def is_none(self) -> Self: ... def is_not_none(self) -> Self: ... def is_type_of(self, some_type: type) -> Self: ... - def is_instance_of(self, some_class: type) -> Self: ... - def is_instance_of_any(self, *some_classes: type) -> Self: ... + def is_instance_of(self, some_class: ClassInfo) -> Self: ... + def is_instance_of_any(self, *some_classes: ClassInfo) -> Self: ... def is_subclass_of(self, some_class: type) -> Self: ... def is_length(self, length: int) -> Self: ... def is_length_between(self, low: int, high: int) -> Self: ... @@ -413,9 +416,24 @@ def is_instance_of(self, some_class: type[bytes]) -> _BytesAssertion[bytes]: ... @overload def is_instance_of(self, some_class: type[bytearray]) -> _BytesAssertion[bytearray]: ... @overload + def is_instance_of(self, some_class: tuple[type[_U], type[_U2]]) -> _ObjectAssertion[_U | _U2]: ... + @overload + def is_instance_of( + self, some_class: tuple[type[_U], type[_U2], type[_U3]] + ) -> _ObjectAssertion[_U | _U2 | _U3]: ... + @overload def is_instance_of(self, some_class: type[_U]) -> _ObjectAssertion[_U]: ... @overload - def is_instance_of(self, some_class: type) -> Self: ... + def is_instance_of(self, some_class: ClassInfo) -> Self: ... + + @overload + def is_instance_of_any(self, first: type[_U], second: type[_U2], /) -> _ObjectAssertion[_U | _U2]: ... + @overload + def is_instance_of_any( + self, first: type[_U], second: type[_U2], third: type[_U3], / + ) -> _ObjectAssertion[_U | _U2 | _U3]: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> Self: ... class _TextAssertion(_MembershipAssertion, _RepeatableAssertion[str], _SizedAssertion, _CoreAssertion, Protocol): """What a piece of text can be asked, whether a caller passed it in or the library caught it. diff --git a/assertpy2/_matcher_impls.py b/assertpy2/_matcher_impls.py index 2a560231..781b86ca 100644 --- a/assertpy2/_matcher_impls.py +++ b/assertpy2/_matcher_impls.py @@ -49,9 +49,13 @@ from ._engine._compare import _CompareConfig from .errors import DiffResult - # recursive because `isinstance()` accepts tuples nested to any depth, and a declaration that refuses what - # the runtime takes is the defect this alias exists to avoid - ClassInfo: TypeAlias = "type | UnionType | tuple[ClassInfo, ...]" + # nested because `isinstance()` accepts tuples nested to any depth, and a declaration that refuses what the + # runtime takes is the defect this alias exists to avoid. Only the recursive reference is quoted, and the + # first level is written out: ty ignores an alias whose whole right-hand side is a string, so the parameter + # it annotates accepted anything and `is_instance_of("int")` stopped being a type error. This way pyright + # and mypy still check a member at any depth while ty checks the outermost one. A PEP 695 `type` statement + # is read correctly by all of them and is a SyntaxError on 3.10, even under `TYPE_CHECKING` + ClassInfo: TypeAlias = type | UnionType | tuple[type | UnionType | tuple["ClassInfo", ...], ...] _M_contra = TypeVar("_M_contra", contravariant=True) diff --git a/assertpy2/assertpy.py b/assertpy2/assertpy.py index 09376d17..92065272 100644 --- a/assertpy2/assertpy.py +++ b/assertpy2/assertpy.py @@ -40,6 +40,7 @@ _PathAssertion, _StringAssertion, ) + from ._matcher_impls import ClassInfo from .errors import PollTrace from .matchers import Matcher @@ -88,6 +89,8 @@ _S = TypeVar("_S") if TYPE_CHECKING: _U = TypeVar("_U") + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _E = TypeVar("_E") # element type of a collection, so first()/element()/... narrow to it _R = TypeVar("_R") # result element type after a mapping pivot _K = TypeVar("_K") # dict key type, so .value keeps dict[K, V] @@ -1305,13 +1308,29 @@ def is_not_none(self: AssertionBuilder[_U | None]) -> AssertionBuilder[_U]: ... def is_not_none(self) -> Self: ... def is_not_none(self) -> Any: ... - # never picked by a call, and it keeps the class conformant with the protocols' `(type) -> Self`; pyright - # reports the overlap and that is intended + # the second rung is what a tuple lands on, and what a union lands on under the checkers that read it + # as `UnionType`. It also keeps the class conformant with the protocols' `(ClassInfo) -> Self` + @overload + def is_instance_of(self, some_class: tuple[type[_U], type[_U2]]) -> AssertionBuilder[_U | _U2]: ... + @overload + def is_instance_of( + self, some_class: tuple[type[_U], type[_U2], type[_U3]] + ) -> AssertionBuilder[_U | _U2 | _U3]: ... @overload def is_instance_of(self, some_class: type[_U]) -> AssertionBuilder[_U]: ... @overload - def is_instance_of(self, some_class: type) -> Self: ... - def is_instance_of(self, some_class: type) -> Any: ... + def is_instance_of(self, some_class: ClassInfo) -> Self: ... + def is_instance_of(self, some_class: Any) -> Any: ... + + @overload + def is_instance_of_any(self, first: type[_U], second: type[_U2], /) -> AssertionBuilder[_U | _U2]: ... + @overload + def is_instance_of_any( + self, first: type[_U], second: type[_U2], third: type[_U3], / + ) -> AssertionBuilder[_U | _U2 | _U3]: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> Self: ... + def is_instance_of_any(self, *some_classes: Any) -> Any: ... # the element pivots return `self.builder()` while `CollectionMixin` declares `-> Self`, so # `assert_that(rows).first().value.count(1)` type-checked and raised. Structural because `_T` is invariant: diff --git a/assertpy2/base.py b/assertpy2/base.py index 0a0e062f..138f065c 100644 --- a/assertpy2/base.py +++ b/assertpy2/base.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from ._engine._compare import _CompareConfig from ._engine._compat import Self + from ._matcher_impls import ClassInfo __tracebackhide__ = True @@ -576,11 +577,11 @@ def is_type_of(self, some_type: type) -> Self: ) return self - def is_instance_of(self, some_class: type) -> Self: + def is_instance_of(self, some_class: ClassInfo) -> Self: """Asserts that val is an instance of the given class. Args: - some_class: the expected class + some_class: the expected class, a union of them, or a tuple nested to any depth Examples: Usage: @@ -601,6 +602,12 @@ class Foo: pass assert_that(f).is_instance_of(Foo) assert_that(f).is_instance_of(object) + With alternatives, anything `isinstance` takes, nested to any depth: + + assert_that(1).is_instance_of(int | str) + assert_that(1).is_instance_of((int, str)) + assert_that(1).is_instance_of((int, (str, bytes))) + Returns: AssertionBuilder: returns this instance to chain to the next assertion @@ -610,19 +617,20 @@ class Foo: pass try: if not isinstance(self.val, some_class): type_name = self._type(self.val) + some_class_name = _type_expression_name(some_class) return self.error( - f"Expected <{self.val}:{type_name}> to be instance of class <{some_class.__name__}>, but was not.", + f"Expected <{self.val}:{type_name}> to be instance of class <{some_class_name}>, but was not.", expected=some_class, ) except TypeError: refuse(some_class, "a class", subject=argument("class")) return self - def is_instance_of_any(self, *some_classes: type) -> Self: + def is_instance_of_any(self, *some_classes: ClassInfo) -> Self: """Asserts that val is an instance of at least one of the given classes. Args: - *some_classes: the candidate classes + *some_classes: the candidate classes, each a class, a union of them, or a nested tuple Examples: Usage: @@ -630,6 +638,7 @@ def is_instance_of_any(self, *some_classes: type) -> Self: assert_that(1).is_instance_of_any(int, float) assert_that('foo').is_instance_of_any(str, bytes) assert_that(TimeoutError()).is_instance_of_any(OSError, ValueError) + assert_that(1).is_instance_of_any(int | str, (bytes, bytearray)) Returns: AssertionBuilder: returns this instance to chain to the next assertion @@ -644,7 +653,7 @@ def is_instance_of_any(self, *some_classes: type) -> Self: try: if not isinstance(self.val, some_classes): type_name = self._type(self.val) - class_names = ", ".join(some_class.__name__ for some_class in some_classes) + class_names = ", ".join(_type_expression_name(some_class) for some_class in some_classes) return self.error( f"Expected <{self.val}:{type_name}> to be instance of any of <{class_names}>, but was not.", expected=some_classes, diff --git a/assertpy2/errors.py b/assertpy2/errors.py index 308d5791..0a38a20f 100644 --- a/assertpy2/errors.py +++ b/assertpy2/errors.py @@ -4,6 +4,7 @@ import math import re from dataclasses import dataclass, field +from types import UnionType from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias if TYPE_CHECKING: @@ -44,6 +45,8 @@ def _type_expression_name(expected: object) -> str: """ if isinstance(expected, tuple): return ", ".join(_type_expression_name(member) for member in expected) + if isinstance(expected, UnionType): + return " | ".join(_type_expression_name(member) for member in expected.__args__) return expected.__name__ if isinstance(expected, type) else str(expected) diff --git a/docs/concepts/stability.md b/docs/concepts/stability.md index 9511e711..d6576937 100644 --- a/docs/concepts/stability.md +++ b/docs/concepts/stability.md @@ -10,7 +10,7 @@ an intention. | The 37 names `assertpy2` exports, and the fields of every record it hands you | [`test_public_surface.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_public_surface.py) pins both against a hand-written list | | Every assertion the type checker offers you existing at runtime | [`test_protocol_parity.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_protocol_parity.py) walks all twenty-nine protocols | | The signature you call: parameter names, their order, their defaults | [`test_api_compatibility.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_api_compatibility.py) compares a recorded snapshot of the whole surface and classifies every change as breaking, an addition, or typing-only | -| The type your chain has after each step | [`test_typing.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_typing.py), 199 `assert_type` checks under ty, mypy `--strict`, Pyright and Pyrefly, zero suppressions | +| The type your chain has after each step | [`test_typing.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_typing.py), 207 `assert_type` checks under ty, mypy `--strict`, Pyright and Pyrefly, zero suppressions | | One relation keeping one name across the API | [`test_api_vocabulary.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_api_vocabulary.py) | | The three-method `Matcher` protocol your custom matchers implement | [`test_matcher_parity.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_matcher_parity.py) | | The Allure attachment schema | versioned in its own `format` field, so a consumer branches on a number rather than guessing | diff --git a/docs/concepts/type-safety.md b/docs/concepts/type-safety.md index 7af30313..ed7b153a 100644 --- a/docs/concepts/type-safety.md +++ b/docs/concepts/type-safety.md @@ -198,6 +198,18 @@ paid = assert_that(order).is_not_none().is_instance_of(PaidOrder).value paid.refund() # statically typed as PaidOrder - no cast, no bare assert ``` +Alternatives narrow too, up to three of them, written as a tuple or as separate arguments: + +```python +found = assert_that(order).is_instance_of((PaidOrder, Order)).value +either = assert_that(order).is_instance_of_any(PaidOrder, Order).value +``` + +Both give `PaidOrder | Order`. The same alternatives written as a union, `PaidOrder | Order`, are +accepted and do not narrow: the chain keeps the type it already had. The union reaches the assertion as +one `types.UnionType` value, and the only annotation that binds its members also accepts type +expressions `isinstance` refuses, such as `Literal[1]`. + On the per-type protocols `value` returns the family type (`str` for string assertions, `dict` for dict assertions, ...), so extract-and-continue works after pivots too: diff --git a/scripts/generate_check_protocols.py b/scripts/generate_check_protocols.py index ce0885e0..f882c2dd 100644 --- a/scripts/generate_check_protocols.py +++ b/scripts/generate_check_protocols.py @@ -53,6 +53,7 @@ from typing_extensions import TypeIs + from .._matcher_impls import ClassInfo from ..errors import AssertionOutcome from ..matchers import Matcher from ._compat import Self @@ -78,6 +79,8 @@ _K = TypeVar("_K") _V = TypeVar("_V") _B_co = TypeVar("_B_co", bytes, bytearray, covariant=True) + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _Number = SupportsFloat ''' diff --git a/scripts/generate_poll_protocols.py b/scripts/generate_poll_protocols.py index 38d4ed94..392d26e7 100644 --- a/scripts/generate_poll_protocols.py +++ b/scripts/generate_poll_protocols.py @@ -138,6 +138,7 @@ def __getattr__(self, name: str) -> Callable[..., _AsyncPoll[_P_co]]: ... from typing_extensions import TypeIs + from .._matcher_impls import ClassInfo from ..assertpy import AssertionBuilder from ..matchers import Matcher from ._capable_typing import _Callable, _Orderable, _PathLike @@ -161,6 +162,8 @@ def __getattr__(self, name: str) -> Callable[..., _AsyncPoll[_P_co]]: ... _V = TypeVar("_V") _R = TypeVar("_R") _B_co = TypeVar("_B_co", bytes, bytearray, covariant=True) + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _Number = SupportsFloat # the rungs below restrict `self` with the annotations `assert_that()` overloads are written with @@ -589,6 +592,7 @@ def __getattr__(self, name: str) -> Callable[..., AssertionOutcome]: ... { "is_not_none", "is_instance_of", + "is_instance_of_any", "first", "last", "element", @@ -690,6 +694,7 @@ def generate_capable() -> str: from typing_extensions import TypeIs + from .._matcher_impls import ClassInfo from ..assertpy import AssertionBuilder from ..matchers import Matcher from ._builder_check_typing import _CheckAnyValue @@ -699,6 +704,8 @@ def generate_capable() -> str: # covariant: the façade only ever hands the subject back, through `value` _CapableT_co = TypeVar("_CapableT_co", covariant=True) + _U2 = TypeVar("_U2") + _U3 = TypeVar("_U3") _E_co = TypeVar("_E_co", covariant=True) class _Orderable(Protocol): @@ -748,9 +755,23 @@ def is_not_none(self: _CapableAssertion[_U | None]) -> _CapableAssertion[_U]: .. @overload def is_not_none(self) -> Self: ... @overload + def is_instance_of(self, some_class: tuple[type[_U], type[_U2]]) -> AssertionBuilder[_U | _U2]: ... + @overload + def is_instance_of( + self, some_class: tuple[type[_U], type[_U2], type[_U3]] + ) -> AssertionBuilder[_U | _U2 | _U3]: ... + @overload def is_instance_of(self, some_class: type[_U]) -> AssertionBuilder[_U]: ... @overload - def is_instance_of(self, some_class: type) -> Self: ... + def is_instance_of(self, some_class: ClassInfo) -> Self: ... + @overload + def is_instance_of_any(self, first: type[_U], second: type[_U2], /) -> AssertionBuilder[_U | _U2]: ... + @overload + def is_instance_of_any( + self, first: type[_U], second: type[_U2], third: type[_U3], / + ) -> AssertionBuilder[_U | _U2 | _U3]: ... + @overload + def is_instance_of_any(self, *some_classes: ClassInfo) -> Self: ... @overload def first(self: _CapableAssertion[Mapping[_K, _V]]) -> AssertionBuilder[_K]: ... @overload diff --git a/tests/api_snapshot.json b/tests/api_snapshot.json index 2620aa9c..5208bce6 100644 --- a/tests/api_snapshot.json +++ b/tests/api_snapshot.json @@ -1327,7 +1327,7 @@ "kind": "callable", "parameters": [ { - "annotation": "type", + "annotation": "ClassInfo", "default": null, "kind": "POSITIONAL_OR_KEYWORD", "name": "some_class", @@ -1340,7 +1340,7 @@ "kind": "callable", "parameters": [ { - "annotation": "type", + "annotation": "ClassInfo", "default": null, "kind": "VAR_POSITIONAL", "name": "some_classes", @@ -4895,8 +4895,8 @@ "_ArrayAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_ArrayAssertion.is_false": "() -> Self", "_ArrayAssertion.is_in": "(*items: object) -> Self", - "_ArrayAssertion.is_instance_of": "(some_class: type) -> Self", - "_ArrayAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_ArrayAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_ArrayAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_ArrayAssertion.is_iterable": "() -> Self", "_ArrayAssertion.is_length": "(length: int) -> Self", "_ArrayAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -4939,8 +4939,8 @@ "_ArrayLikeAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_ArrayLikeAssertion.is_false": "() -> Self", "_ArrayLikeAssertion.is_in": "(*items: object) -> Self", - "_ArrayLikeAssertion.is_instance_of": "(some_class: type) -> Self", - "_ArrayLikeAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_ArrayLikeAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_ArrayLikeAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_ArrayLikeAssertion.is_iterable": "() -> Self", "_ArrayLikeAssertion.is_length": "(length: int) -> Self", "_ArrayLikeAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -4976,8 +4976,8 @@ "_BoolAssertion.is_greater_than_or_equal_to": "(other: _Number) -> Self", "_BoolAssertion.is_in": "(*items: object) -> Self", "_BoolAssertion.is_inf": "() -> Self", - "_BoolAssertion.is_instance_of": "(some_class: type) -> Self", - "_BoolAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_BoolAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_BoolAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_BoolAssertion.is_iterable": "() -> Self", "_BoolAssertion.is_length": "(length: int) -> Self", "_BoolAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5034,8 +5034,8 @@ "_BytesAssertion.is_greater_than_or_equal_to": "(other: bytes | bytearray) -> Self", "_BytesAssertion.is_hex_equal_to": "(expected_hex: str) -> Self", "_BytesAssertion.is_in": "(*items: object) -> Self", - "_BytesAssertion.is_instance_of": "(some_class: type) -> Self", - "_BytesAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_BytesAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_BytesAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_BytesAssertion.is_iterable": "() -> Self", "_BytesAssertion.is_length": "(length: int) -> Self", "_BytesAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5080,8 +5080,8 @@ "_CallableAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_CallableAssertion.is_false": "() -> Self", "_CallableAssertion.is_in": "(*items: object) -> Self", - "_CallableAssertion.is_instance_of": "(some_class: type) -> Self", - "_CallableAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_CallableAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_CallableAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_CallableAssertion.is_iterable": "() -> Self", "_CallableAssertion.is_length": "(length: int) -> Self", "_CallableAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5125,8 +5125,8 @@ "_ComplexAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_ComplexAssertion.is_false": "() -> Self", "_ComplexAssertion.is_in": "(*items: object) -> Self", - "_ComplexAssertion.is_instance_of": "(some_class: type) -> Self", - "_ComplexAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_ComplexAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_ComplexAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_ComplexAssertion.is_iterable": "() -> Self", "_ComplexAssertion.is_length": "(length: int) -> Self", "_ComplexAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5157,8 +5157,8 @@ "_CoreAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_CoreAssertion.is_false": "() -> Self", "_CoreAssertion.is_in": "(*items: object) -> Self", - "_CoreAssertion.is_instance_of": "(some_class: type) -> Self", - "_CoreAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_CoreAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_CoreAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_CoreAssertion.is_iterable": "() -> Self", "_CoreAssertion.is_length": "(length: int) -> Self", "_CoreAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5190,8 +5190,8 @@ "_DateAssertion.is_greater_than": "(other: datetime.date) -> Self", "_DateAssertion.is_greater_than_or_equal_to": "(other: datetime.date) -> Self", "_DateAssertion.is_in": "(*items: object) -> Self", - "_DateAssertion.is_instance_of": "(some_class: type) -> Self", - "_DateAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_DateAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_DateAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_DateAssertion.is_iterable": "() -> Self", "_DateAssertion.is_length": "(length: int) -> Self", "_DateAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5233,8 +5233,8 @@ "_DateTimeAssertion.is_greater_than": "(other: datetime.datetime) -> Self", "_DateTimeAssertion.is_greater_than_or_equal_to": "(other: datetime.datetime) -> Self", "_DateTimeAssertion.is_in": "(*items: object) -> Self", - "_DateTimeAssertion.is_instance_of": "(some_class: type) -> Self", - "_DateTimeAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_DateTimeAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_DateTimeAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_DateTimeAssertion.is_iterable": "() -> Self", "_DateTimeAssertion.is_length": "(length: int) -> Self", "_DateTimeAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5291,8 +5291,8 @@ "_DictAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_DictAssertion.is_false": "() -> Self", "_DictAssertion.is_in": "(*items: object) -> Self", - "_DictAssertion.is_instance_of": "(some_class: type) -> Self", - "_DictAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_DictAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_DictAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_DictAssertion.is_iterable": "() -> Self", "_DictAssertion.is_length": "(length: int) -> Self", "_DictAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5352,8 +5352,8 @@ "_FrameAssertion.is_false": "() -> Self", "_FrameAssertion.is_frame_equal": "(expected: object, **options: Any) -> Self", "_FrameAssertion.is_in": "(*items: object) -> Self", - "_FrameAssertion.is_instance_of": "(some_class: type) -> Self", - "_FrameAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_FrameAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_FrameAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_FrameAssertion.is_iterable": "() -> Self", "_FrameAssertion.is_length": "(length: int) -> Self", "_FrameAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5426,8 +5426,8 @@ "_InvokedAssertion.is_greater_than": "(other: str) -> Self", "_InvokedAssertion.is_greater_than_or_equal_to": "(other: str) -> Self", "_InvokedAssertion.is_in": "(*items: object) -> Self", - "_InvokedAssertion.is_instance_of": "(some_class: type) -> Self", - "_InvokedAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_InvokedAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_InvokedAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_InvokedAssertion.is_iterable": "() -> Self", "_InvokedAssertion.is_length": "(length: int) -> Self", "_InvokedAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5506,8 +5506,8 @@ "_IterableAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_IterableAssertion.is_false": "() -> Self", "_IterableAssertion.is_in": "(*items: object) -> Self", - "_IterableAssertion.is_instance_of": "(some_class: type) -> Self", - "_IterableAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_IterableAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_IterableAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_IterableAssertion.is_iterable": "() -> Self", "_IterableAssertion.is_length": "(length: int) -> Self", "_IterableAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5583,8 +5583,8 @@ "_ListAssertion.is_equal_to": "(other: object, *ignore: _KeySpecs | None, *include: _KeySpecs | None, *tolerance: float | None, *comparators: dict[Any, Callable[[Any, Any], Any]] | None, *ignore_null: bool, *strict_types: bool) -> Self", "_ListAssertion.is_false": "() -> Self", "_ListAssertion.is_in": "(*items: object) -> Self", - "_ListAssertion.is_instance_of": "(some_class: type) -> Self", - "_ListAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_ListAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_ListAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_ListAssertion.is_iterable": "() -> Self", "_ListAssertion.is_length": "(length: int) -> Self", "_ListAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5634,8 +5634,8 @@ "_NumericAssertion.is_greater_than_or_equal_to": "(other: _Number) -> Self", "_NumericAssertion.is_in": "(*items: object) -> Self", "_NumericAssertion.is_inf": "() -> Self", - "_NumericAssertion.is_instance_of": "(some_class: type) -> Self", - "_NumericAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_NumericAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_NumericAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_NumericAssertion.is_iterable": "() -> Self", "_NumericAssertion.is_length": "(length: int) -> Self", "_NumericAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5679,8 +5679,8 @@ "_ObjectAssertion.is_greater_than": "(other: Any) -> Self", "_ObjectAssertion.is_greater_than_or_equal_to": "(other: Any) -> Self", "_ObjectAssertion.is_in": "(*items: object) -> Self", - "_ObjectAssertion.is_instance_of": "(some_class: type[str]) -> _StringAssertion | (some_class: type[bool]) -> _BoolAssertion | (some_class: type[int]) -> _NumericAssertion[int] | (some_class: type[float]) -> _NumericAssertion[float] | (some_class: type[complex]) -> _ComplexAssertion | (some_class: type[dict[_K, _V]]) -> _DictAssertion[_K, _V] | (some_class: type[list[_E] | tuple[_E, ...]]) -> _IterableAssertion[_E] | (some_class: type[set[_E] | frozenset[_E]]) -> _IterableAssertion[_E] | (some_class: type[datetime.datetime]) -> _DateTimeAssertion | (some_class: type[datetime.date]) -> _DateAssertion | (some_class: type[Path]) -> _PathAssertion | (some_class: type[bytes]) -> _BytesAssertion[bytes] | (some_class: type[bytearray]) -> _BytesAssertion[bytearray] | (some_class: type[_U]) -> _ObjectAssertion[_U] | (some_class: type) -> Self", - "_ObjectAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_ObjectAssertion.is_instance_of": "(some_class: type[str]) -> _StringAssertion | (some_class: type[bool]) -> _BoolAssertion | (some_class: type[int]) -> _NumericAssertion[int] | (some_class: type[float]) -> _NumericAssertion[float] | (some_class: type[complex]) -> _ComplexAssertion | (some_class: type[dict[_K, _V]]) -> _DictAssertion[_K, _V] | (some_class: type[list[_E] | tuple[_E, ...]]) -> _IterableAssertion[_E] | (some_class: type[set[_E] | frozenset[_E]]) -> _IterableAssertion[_E] | (some_class: type[datetime.datetime]) -> _DateTimeAssertion | (some_class: type[datetime.date]) -> _DateAssertion | (some_class: type[Path]) -> _PathAssertion | (some_class: type[bytes]) -> _BytesAssertion[bytes] | (some_class: type[bytearray]) -> _BytesAssertion[bytearray] | (some_class: tuple[type[_U], type[_U2]]) -> _ObjectAssertion[_U | _U2] | (some_class: tuple[type[_U], type[_U2], type[_U3]]) -> _ObjectAssertion[_U | _U2 | _U3] | (some_class: type[_U]) -> _ObjectAssertion[_U] | (some_class: ClassInfo) -> Self", + "_ObjectAssertion.is_instance_of_any": "(first: type[_U]/, second: type[_U2]/) -> _ObjectAssertion[_U | _U2] | (first: type[_U]/, second: type[_U2]/, third: type[_U3]/) -> _ObjectAssertion[_U | _U2 | _U3] | (*some_classes: ClassInfo) -> Self", "_ObjectAssertion.is_iterable": "() -> Self", "_ObjectAssertion.is_length": "(length: int) -> Self", "_ObjectAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5719,8 +5719,8 @@ "_PathAssertion.is_false": "() -> Self", "_PathAssertion.is_file": "() -> Self", "_PathAssertion.is_in": "(*items: object) -> Self", - "_PathAssertion.is_instance_of": "(some_class: type) -> Self", - "_PathAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_PathAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_PathAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_PathAssertion.is_iterable": "() -> Self", "_PathAssertion.is_length": "(length: int) -> Self", "_PathAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5818,8 +5818,8 @@ "_StringAssertion.is_greater_than": "(other: str) -> Self", "_StringAssertion.is_greater_than_or_equal_to": "(other: str) -> Self", "_StringAssertion.is_in": "(*items: object) -> Self", - "_StringAssertion.is_instance_of": "(some_class: type) -> Self", - "_StringAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_StringAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_StringAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_StringAssertion.is_iterable": "() -> Self", "_StringAssertion.is_length": "(length: int) -> Self", "_StringAssertion.is_length_between": "(low: int, high: int) -> Self", @@ -5916,8 +5916,8 @@ "_TextAssertion.is_greater_than": "(other: str) -> Self", "_TextAssertion.is_greater_than_or_equal_to": "(other: str) -> Self", "_TextAssertion.is_in": "(*items: object) -> Self", - "_TextAssertion.is_instance_of": "(some_class: type) -> Self", - "_TextAssertion.is_instance_of_any": "(*some_classes: type) -> Self", + "_TextAssertion.is_instance_of": "(some_class: ClassInfo) -> Self", + "_TextAssertion.is_instance_of_any": "(*some_classes: ClassInfo) -> Self", "_TextAssertion.is_iterable": "() -> Self", "_TextAssertion.is_length": "(length: int) -> Self", "_TextAssertion.is_length_between": "(low: int, high: int) -> Self", diff --git a/tests/pyright_baseline.py b/tests/pyright_baseline.py index bbc736a3..3be464ea 100644 --- a/tests/pyright_baseline.py +++ b/tests/pyright_baseline.py @@ -61,7 +61,8 @@ LADDER_OVERLAP: dict[tuple[str, str], int] = { # the ladders the umbrella's façade carries over from the builder, which overlap there too ("assertpy2/_engine/_capable_typing.py", "is_not_none"): 1, - ("assertpy2/_engine/_capable_typing.py", "is_instance_of"): 2, + ("assertpy2/_engine/_capable_typing.py", "is_instance_of"): 1, + ("assertpy2/_engine/_capable_typing.py", "is_instance_of_any"): 1, ("assertpy2/_engine/_capable_typing.py", "satisfies"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_even"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_odd"): 1, @@ -72,27 +73,27 @@ ("assertpy2/_engine/_builder_check_typing.py", "is_between"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_greater_than"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_greater_than_or_equal_to"): 1, - ("assertpy2/_engine/_builder_check_typing.py", "is_instance_of"): 2, + ("assertpy2/_engine/_builder_check_typing.py", "is_instance_of"): 1, + ("assertpy2/_engine/_builder_check_typing.py", "is_instance_of_any"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_less_than"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_less_than_or_equal_to"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_not_between"): 1, ("assertpy2/_engine/_builder_check_typing.py", "is_not_none"): 2, ("assertpy2/_engine/_builder_check_typing.py", "matches_structure"): 1, - ("assertpy2/_engine/_check_typing.py", "is_instance_of"): 1, ("assertpy2/_engine/_poll_typing.py", "is_between"): 2, ("assertpy2/_engine/_poll_typing.py", "is_greater_than"): 2, ("assertpy2/_engine/_poll_typing.py", "is_greater_than_or_equal_to"): 2, - ("assertpy2/_engine/_poll_typing.py", "is_instance_of"): 4, + ("assertpy2/_engine/_poll_typing.py", "is_instance_of"): 2, + ("assertpy2/_engine/_poll_typing.py", "is_instance_of_any"): 2, ("assertpy2/_engine/_poll_typing.py", "is_less_than"): 2, ("assertpy2/_engine/_poll_typing.py", "is_less_than_or_equal_to"): 2, ("assertpy2/_engine/_poll_typing.py", "is_not_between"): 2, ("assertpy2/_engine/_poll_typing.py", "is_not_none"): 30, ("assertpy2/_engine/_poll_typing.py", "matches_structure"): 2, - ("assertpy2/_engine/_typing.py", "is_instance_of"): 6, + ("assertpy2/_engine/_typing.py", "is_instance_of"): 5, ("assertpy2/_engine/_typing.py", "is_not_none"): 15, ("assertpy2/_engine/_typing.py", "satisfies"): 9, ("assertpy2/assertpy.py", "assert_that"): 5, - ("assertpy2/assertpy.py", "is_instance_of"): 1, } """Where a refinement ladder makes pyright call a later rung redundant, by the method it is on. diff --git a/tests/test_class.py b/tests/test_class.py index dc042a80..ea64308f 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -17,6 +17,9 @@ def name(self): def say_hello(self): return f"Hello, {self.first_name}!" + def __str__(self) -> str: + return self.name + class Developer(Person): def say_hello(self): @@ -72,6 +75,8 @@ def test_is_instance_of(): assert_that(joe).is_instance_of(Developer) assert_that(joe).is_instance_of(Person) + assert_that(joe).is_instance_of(Person | Developer) + assert_that(joe).is_instance_of((Person, Developer)) assert_that(joe).is_instance_of(object) assert_that(car).is_instance_of(Car) @@ -85,6 +90,8 @@ def test_is_instance_of(): def test_is_instance_of_class(): assert_that(fred.__class__).is_instance_of(Person.__class__) + assert_that(fred.__class__).is_instance_of(Car.__class__ | Person.__class__) + assert_that(fred.__class__).is_instance_of((Car.__class__, Person.__class__)) def test_is_instance_of_class_failure(): @@ -92,6 +99,34 @@ def test_is_instance_of_class_failure(): assert_that(fred.__class__).is_instance_of(Person) assert_that(str(exc_info.value)).contains("to be instance of class , but was not") + with pytest.raises(AssertionError) as exc_info: + assert_that(fred).is_instance_of(Car | str) + assert_that(str(exc_info.value)).contains("to be instance of class , but was not") + + with pytest.raises(AssertionError) as exc_info: + assert_that(fred).is_instance_of((Car, str)) + assert_that(str(exc_info.value)).contains("to be instance of class , but was not") + + +def test_is_instance_of_any(): + assert_that(fred).is_instance_of_any(Person) + assert_that(fred).is_instance_of_any(Car | Person) + assert_that(fred).is_instance_of_any(Truck, Car | Person) + + +def test_is_instance_of_any_failure(): + with pytest.raises(AssertionError) as exc_info: + assert_that(fred).is_instance_of_any(Truck, Car) + assert_that(str(exc_info.value)).is_equal_to( + "Expected to be instance of any of , but was not." + ) + + with pytest.raises(AssertionError) as exc_info: + assert_that(fred).is_instance_of_any(Developer, Car | Truck) + assert_that(str(exc_info.value)).is_equal_to( + "Expected to be instance of any of , but was not." + ) + def test_extract_attribute(): assert_that(people).extracting("first_name").is_equal_to(["Fred", "Joe"]) diff --git a/tests/test_protocol_parity.py b/tests/test_protocol_parity.py index 36c95507..7212ae87 100644 --- a/tests/test_protocol_parity.py +++ b/tests/test_protocol_parity.py @@ -163,6 +163,7 @@ # type. The core declares the plain pair, and this is the same pair with the ladder in front ("_ObjectAssertion", "is_not_none"), ("_ObjectAssertion", "is_instance_of"), + ("_ObjectAssertion", "is_instance_of_any"), # the string view keeps its own result type on the pivots: text for a message, `str` for a # string, which is what lets one be read as a path and the other not ("_StringAssertion", "first"), diff --git a/tests/test_type_expression_failures.py b/tests/test_type_expression_failures.py index 2ac37bb2..59b8b6ea 100644 --- a/tests/test_type_expression_failures.py +++ b/tests/test_type_expression_failures.py @@ -11,10 +11,6 @@ `AttributeError` out of the formatter instead of reporting what went wrong. It was found on `is_instance_of` by an external contributor and this table is what a sweep for the same shape turned up. -Two of the methods are left out on purpose, with the reason and the exit written down in `_PENDING`. -The point of the table is the class of defect, so an omission belongs in it visibly rather than as a -method quietly missing from a list. - The version matters and is not decoration. On 3.14 and later a union renders as the useless but harmless ``, so half of these cases look fine there and fail on the supported floor. Anything added here has to be run on 3.10 as well as on the development interpreter. @@ -70,12 +66,6 @@ def _has_root_cause(spec: object) -> None: "is_subclass_of": _subclass_of, "caused_by": _caused_by, "has_root_cause": _has_root_cause, -} - -# `is_instance_of` and `is_instance_of_any` carry the same defect and are being fixed in PR #33 by the -# contributor who reported it. Listed rather than omitted so the table describes the whole class, and -# so the day that lands is the day these two move up into `_COVERED` and this note goes away. -_PENDING = { "is_instance_of": _instance_of, "is_instance_of_any": _instance_of_any, } @@ -109,17 +99,6 @@ def test_the_message_names_the_members_rather_than_the_container(call, shape) -> assert_that(message).described_as("the failure message").contains("_Person").does_not_contain("") -@pytest.mark.parametrize("call", _PENDING.values(), ids=_PENDING.keys()) -def test_the_pending_two_are_still_pending(call) -> None: - """Fails the day PR #33 lands, which is the reminder to move them into `_COVERED`. - - A pending list nobody is forced to revisit is how an exclusion outlives the reason for it. - """ - shape = _SHAPES["a tuple"] - with pytest.raises(AttributeError): - call(shape) - - def test_the_floor_is_where_this_hides() -> None: """A union has carried `__name__` since 3.14, so the union half of the table is silent above it. diff --git a/tests/test_typing.py b/tests/test_typing.py index 129ce1fe..7d4ca953 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -196,6 +196,8 @@ def headers(self) -> Mapping[str, str]: ... assert_type(assert_that("FooBar").starts_with_ignoring_case("foo"), _StringAssertion) assert_type(assert_that("FooBar").ends_with_ignoring_case("BAR"), _StringAssertion) assert_type(assert_that(1).is_instance_of_any(int, float), _NumericAssertion[int]) + assert_type(assert_that(1).is_instance_of_any(str, int | float), _NumericAssertion[int]) + assert_type(assert_that(1).is_instance_of_any(str, (int, float)), _NumericAssertion[int]) assert_type(assert_that("s").is_subclass_of(object), _StringAssertion) # a caught message is text without being a `str` view, and its element pivots are the only way to # reach `_TextAssertion` at all @@ -222,6 +224,10 @@ def headers(self) -> Mapping[str, str]: ... assert_type(invoked.error_of(KeyError).value, str) assert_type(invoked.error_of(KeyError).raised(), _CoreAssertion) + class _Alpha: ... + + class _Beta: ... + maybe_name = cast("str | None", "fred") anything = cast("object", "fred") assert_type(assert_that(maybe_name), _ObjectAssertion[str | None]) @@ -231,6 +237,23 @@ def headers(self) -> Mapping[str, str]: ... assert_type(assert_that(maybe_name).is_not_none().value, str) assert_type(assert_that(anything).is_instance_of(bool), _BoolAssertion) assert_type(assert_that(anything).is_instance_of(bool).value, bool) + # a tuple of alternatives lands on the widest rung, nested to any depth because that is the shape + # `isinstance` takes and a flat rung refuses it. + # + # A union has no line here on purpose, and the reason is not that the checkers disagree. All four bind + # `_Alpha | _Beta` through `TypeForm[_U]`, measured on pyright 1.1.413 with no experimental flag. What + # that costs is the domain: `TypeForm` is any type expression, so with such a rung every checker accepts + # `Literal[1]`, `Never`, `Annotated[int, "x"]` and `list[int]`, each of which the runtime refuses with + # `TypeError`. There is no narrower spelling, since PEP 747 declined syntax for a restricted subset and + # Python has no intersection type. A union therefore keeps the view the chain already had. The runtime side is + # gated in `tests/test_class.py`, where all four spellings are accepted and named in the failure + assert_type(assert_that(anything).is_instance_of((_Alpha, _Beta)), _ObjectAssertion[_Alpha | _Beta]) + assert_type(assert_that(anything).is_instance_of((_Alpha, _Beta)).value, _Alpha | _Beta) + assert_type(assert_that(anything).is_instance_of((int, str, bytes)), _ObjectAssertion[int | str | bytes]) + assert_type(assert_that(anything).is_instance_of_any(_Alpha, _Beta), _ObjectAssertion[_Alpha | _Beta]) + # past the arities that bind, and for a tuple nested to any depth, the widest rung answers + assert_type(assert_that(anything).is_instance_of((_Alpha, (_Beta, str))), _ObjectAssertion[object]) + assert_type(assert_that(anything).is_instance_of_any((_Alpha, _Beta), (str, bytes)), _ObjectAssertion[object]) assert_type(assert_that(maybe_name).is_not_none().is_instance_of(str).value, str) assert_type(assert_that(anything).is_not_none(), _ObjectAssertion[object]) diff --git a/tests/test_typing_conformance.py b/tests/test_typing_conformance.py index 6ea94ca7..4f55c33a 100644 --- a/tests/test_typing_conformance.py +++ b/tests/test_typing_conformance.py @@ -254,19 +254,19 @@ def _aliases() -> dict[str, set[str]]: two ways: the views spell the numeric bound through the alias and the runtime cannot, since the alias lives inside the `TYPE_CHECKING` block. - Two shapes, because the surface uses two. A plain `X = Y` names one thing. An annotated - `ClassInfo: TypeAlias = "type | UnionType | tuple[ClassInfo, ...]"` names several, and its value is a - string, so it is parsed rather than evaluated. Reported as a name instead, it read as a runtime - shape of its own and the gate compared `ClassInfo` against `type` as though they were different - promises. - - The recursion needs nothing special: only top-level members are read, so `tuple[ClassInfo, ...]` - yields the head `tuple` and the alias never names itself in the result. + Three shapes, because the surface uses three. A plain `X = Y` names one thing. An annotated one + names several, written either as a string, which is parsed rather than evaluated, or as an + expression, which `ClassInfo` is: only its recursive reference is quoted, since ty ignores an alias + whose whole right-hand side is a string. Reported as a name instead, it read as a runtime shape of + its own and the gate compared `ClassInfo` against `type` as though they were different promises. + + The recursion needs nothing special: only top-level members are read, so the nested tuple yields the + head `tuple` and the alias never names itself in the result. """ found: dict[str, set[str]] = {} for source in (_typing.__file__, _matcher_impls.__file__): for node in ast.walk(ast.parse(pathlib.Path(source).read_text(encoding="utf-8"))): - name, value = _alias_parts(node) + name, value, annotated = _alias_parts(node) if not name: continue if isinstance(value, ast.Name): @@ -276,21 +276,25 @@ def _aliases() -> dict[str, set[str]]: # it names written = ast.parse(value.value, mode="eval").body found[name] = {member.split("[", 1)[0] for member in _members_of(written)} + elif annotated and isinstance(value, ast.BinOp | ast.Subscript): + found[name] = {member.split("[", 1)[0] for member in _members_of(value)} return found -def _alias_parts(node: ast.AST) -> tuple[str, ast.expr | None]: - """``(alias name, its value)`` for `X = Y` and for `X: TypeAlias = Y`, else an empty name. +def _alias_parts(node: ast.AST) -> tuple[str, ast.expr | None, bool]: + """``(alias name, its value, whether it was annotated)``, or an empty name. The annotated form is read only when the annotation says `TypeAlias`: any other string-valued assignment in these modules is a value rather than a type, and parsing one as an expression fails. + The flag matters because an expression-valued alias is only trustworthy when annotated: every + `seen = seen | {pair_id}` in a walked module is an expression assigned to a name too. """ if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): - return node.targets[0].id, node.value + return node.targets[0].id, node.value, False if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): if _plain_annotation(node.annotation) == "TypeAlias": - return node.target.id, node.value - return "", None + return node.target.id, node.value, True + return "", None, False def _plain_annotation(node: ast.expr) -> str: diff --git a/tests/typing_cases.py b/tests/typing_cases.py index 5b3437c7..c4878adc 100644 --- a/tests/typing_cases.py +++ b/tests/typing_cases.py @@ -259,6 +259,11 @@ def _methods_that_do_not_fit_the_value() -> None: # a verdict asked of a value the builder holds: an element pivot used to land on the untyped proxy assert_that([1, 2]).first().check().starts_with("x") # case: text-verdict-on-a-pivoted-number + # a member of a class-info tuple that is not a class. ty answers on the outermost level only, which is + # the price of the alias being written out rather than recursive: it ignores a fully quoted one + assert_that(object()).is_instance_of((int, "nope")) # case: a-tuple-member-that-is-not-a-class + assert_that(object()).is_instance_of((int, (str, "nope"))) # case: a-nested-member-that-is-not-a-class + # a polling chain used to be `Any` from its first assertion, so none of these were read at all assert_that(_a_number).eventually_sync().starts_with("x") # case: text-assertion-on-a-polled-number assert_that(_a_number).eventually_sync().is_close_to("x", 1) # case: bad-operand-on-a-polled-number diff --git a/tests/typing_negative_baseline.py b/tests/typing_negative_baseline.py index 25dd3223..81409129 100644 --- a/tests/typing_negative_baseline.py +++ b/tests/typing_negative_baseline.py @@ -37,6 +37,12 @@ "pyright": frozenset({"reportArgumentType"}), } +_CLASS_INFO_MEMBER: dict[str, frozenset[str]] = { + "ty": frozenset(), + "mypy": frozenset({"arg-type"}), + "pyright": frozenset({"reportArgumentType", "reportCallIssue"}), +} + _MISSING: dict[str, frozenset[str]] = { "ty": frozenset({"unresolved-attribute"}), "mypy": frozenset({"attr-defined"}), @@ -190,6 +196,11 @@ "predicate-reading-a-missing-numeric-method": _PREDICATE_OVER_THE_SUBJECT, "text-verdict-on-a-pivoted-number": _NOT_THE_VALUES_VIEW, "text-assertion-after-a-dynamic-one": _NOT_THE_CHAINS_VALUE, + # ty answers on the outermost level of a class-info tuple only. Its alias is written out rather than + # recursive because a fully quoted one is ignored outright, and the two that read the recursion cover + # the depth it gives up + "a-tuple-member-that-is-not-a-class": _CLASS_INFO_MEMBER, + "a-nested-member-that-is-not-a-class": _CLASS_INFO_MEMBER, "bad-operand-on-a-polled-number": { "ty": frozenset({"no-matching-overload"}), "mypy": frozenset({"arg-type"}), @@ -212,14 +223,18 @@ "predicate-reading-a-missing-string-method", "predicate-reading-a-missing-numeric-method", "text-verdict-on-a-pivoted-number", + "a-tuple-member-that-is-not-a-class", + "a-nested-member-that-is-not-a-class", } ) """The cases where the three do not agree, named so a new one has to be decided about. -Two relations, and ty is the silent one in both. The first two are a lambda over the subject reading a +Three relations, and ty is the silent one in all of them. The first two are a lambda over the subject reading a name the value has not got, where ty resolves the parameter through the overload set less precisely. The third is a verdict asked of a value the builder holds, refused through the ``self`` annotation of a -rung on its twin, which ty does not read either. +rung on its twin, which ty does not read either. The last two are a member of a class-info tuple that +is not a class: `ClassInfo` is written out one level with the recursion quoted, because ty ignores an +alias whose whole right-hand side is a string, and it reads the outermost level only. Each row records that silence as an empty set of codes rather than by leaving the checker out, since a missing checker would read as three dialects agreeing.