diff --git a/beartype/_data/hint/pep/sign/datapepsignset.py b/beartype/_data/hint/pep/sign/datapepsignset.py index eb436433..6eff67a5 100644 --- a/beartype/_data/hint/pep/sign/datapepsignset.py +++ b/beartype/_data/hint/pep/sign/datapepsignset.py @@ -64,6 +64,7 @@ HintSignSized, HintSignTuple, HintSignType, + HintSignTypedDict, HintSignTypeVar, HintSignUnion, HintSignValuesView, @@ -267,12 +268,17 @@ _HINT_SIGNS_SUPPORTED_SHALLOW = frozenset(( # ..................{ PEP 484 }.................. HintSignTypeVar, + + # ..................{ PEP 589 }.................. + #FIXME: Shift into "HINT_SIGNS_SUPPORTED_DEEP" *AFTER* deeply type-checking + #typed dictionaries. + HintSignTypedDict, )) ''' Frozen set of all **shallowly supported non-originative signs** (i.e., arbitrary objects uniquely identifying PEP-compliant type hints *not* -originating from a non-:mod:`typing` origin type for which the -:func:`beartype.beartype` decorator generates shallow type-checking code). +originating from an isinstanceable type for which the :func:`beartype.beartype` +decorator generates shallow type-checking code). ''' diff --git a/beartype/_util/hint/pep/proposal/pep484585/utilpepgeneric.py b/beartype/_util/hint/pep/proposal/pep484585/utilpepgeneric.py index 9c2f88dc..53cb071f 100644 --- a/beartype/_util/hint/pep/proposal/pep484585/utilpepgeneric.py +++ b/beartype/_util/hint/pep/proposal/pep484585/utilpepgeneric.py @@ -33,11 +33,11 @@ def is_hint_pep484585_generic(hint: object) -> bool: ''' ``True`` only if the passed object is either a :pep:`484`- or - :pep:`585`-compliant **generic** (i.e., class superficially subclassing at - least one PEP-compliant type hint that is possibly *not* an actual class). + :pep:`585`-compliant **generic** (i.e., object that may *not* actually be a + class despite subclassing at least one PEP-compliant type hint that also + may *not* actually be a class). - Specifically, this tester returns ``True`` only if this object is a class - that is either: + Specifically, this tester returns ``True`` only if this object is either: * A :pep:`585`-compliant generic as tested by the lower-level :func:`is_hint_pep585_generic` function. @@ -49,6 +49,23 @@ def is_hint_pep484585_generic(hint: object) -> bool: one-liner are non-negligible. Moreover, this tester is called frequently enough to warrant its reduction to an efficient lookup. + Caveats + ---------- + **Generics are not necessarily classes,** despite originally being declared + as classes. Although *most* generics are classes, subscripting a generic + class usually produces a generic non-class that *must* nonetheless be + transparently treated as a generic class: e.g., + + .. code-block:: python + + >>> from typing import Generic, TypeVar + >>> S = TypeVar('S') + >>> T = TypeVar('T') + >>> class MuhGeneric(Generic[S, T]): pass + >>> non_class_generic = MuhGeneric[S, T] + >>> isinstance(non_class_generic, type) + False + Parameters ---------- hint : object @@ -410,7 +427,6 @@ class such that *all* objects satisfying this hint are instances of that isinstanceable class). Callers *must* perform subsequent tests to distinguish these two cases. - Parameters ---------- hint : object diff --git a/beartype/_util/hint/pep/proposal/utilpep484.py b/beartype/_util/hint/pep/proposal/utilpep484.py index 4022a43c..84e59dd8 100644 --- a/beartype/_util/hint/pep/proposal/utilpep484.py +++ b/beartype/_util/hint/pep/proposal/utilpep484.py @@ -299,28 +299,28 @@ def is_hint_pep484_generic(hint: object) -> bool: # >>> MuhList.__mro__ # (__main__.MuhList, list, typing.Generic, object) # - # Note that this issubclass() call implicitly performs a surprisingly - # inefficient search over the method resolution order (MRO) of all - # superclasses of this hint. In theory, the cost of this search might - # be circumventable by observing that this ABC is expected to reside at - # the second-to-last index of the tuple exposing this MRO far all - # generics by virtue of fragile implementation details violating - # privacy encapsulation. In practice, this codebase is fragile enough. - # - # Note lastly that the following logic superficially appears to - # implement the same test *WITHOUT* the onerous cost of a search: - # return len(get_hint_pep484_generic_bases_unerased_or_none(hint)) > 0 - # - # Why didn't we opt for that, then? Because this tester is routinely - # passed objects that *CANNOT* be guaranteed to be PEP-compliant. - # Indeed, the high-level is_hint_pep() tester establishing the - # PEP-compliance of arbitrary objects internally calls this lower-level - # tester to do so. Since the - # get_hint_pep484_generic_bases_unerased_or_none() getter internally - # reduces to returning the tuple of the general-purpose - # "__orig_bases__" dunder attribute formalized by PEP 560, testing - # whether that tuple is non-empty or not in no way guarantees this - # object to be a PEP-compliant generic. + # Note that: + # * This issubclass() call implicitly performs a surprisingly + # inefficient search over the method resolution order (MRO) of all + # superclasses of this hint. In theory, the cost of this search might + # be circumventable by observing that this ABC is expected to reside + # at the second-to-last index of the tuple exposing this MRO far all + # generics by virtue of fragile implementation details violating + # privacy encapsulation. In practice, this codebase is already + # fragile enough. + # * The following logic superficially appears to implement the same + # test *WITHOUT* the onerous cost of a search: + # return len(get_hint_pep484_generic_bases_unerased_or_none(hint)) > 0 + # Why didn't we opt for that, then? Because this tester is routinely + # passed objects that *CANNOT* be guaranteed to be PEP-compliant. + # Indeed, the high-level is_hint_pep() tester establishing the + # PEP-compliance of arbitrary objects internally calls this + # lower-level tester to do so. Since the + # get_hint_pep484_generic_bases_unerased_or_none() getter internally + # reduces to returning the tuple of the general-purpose + # "__orig_bases__" dunder attribute formalized by PEP 560, testing + # whether that tuple is non-empty or not in no way guarantees this + # object to be a PEP-compliant generic. return is_type_subclass(hint, Generic) # type: ignore[arg-type] # Else, the active Python interpreter targets Python 3.6.x. In this case, # implement this function specific to this Python version. @@ -378,17 +378,17 @@ def is_hint_pep484_generic(hint: object) -> bool: # Docstring for this function regardless of implementation details. is_hint_pep484_generic.__doc__ = ''' - ``True`` only if the passed object is a :mod:`typing` **generic** (i.e., - class superficially subclassing at least one non-class PEP-compliant - object defined by the :mod:`typing` module). + ``True`` only if the passed object is a :pep:`484`-compliant **generic** + (i.e., object that may *not* actually be a class originally subclassing at + least one PEP-compliant type hint defined by the :mod:`typing` module). - Specifically, this tester returns ``True`` only if this object is a class - subclassing a combination of: + Specifically, this tester returns ``True`` only if this object was + originally defined as a class subclassing a combination of: * At least one of: * The :pep:`484`-compliant :mod:`typing.Generic` superclass. - * The `PEP 544`-_compliant :mod:`typing.Protocol` superclass. + * The :pep:`544`-compliant :mod:`typing.Protocol` superclass. * Zero or more non-class :mod:`typing` pseudo-superclasses (e.g., ``typing.List[int]``). @@ -398,21 +398,6 @@ class superficially subclassing at least one non-class PEP-compliant :func:`callable_cached` decorator), as the implementation trivially reduces to an efficient one-liner. - Design - ---------- - Since *all* :mod:`typing` generics subclass the :pep:`484`-compliant - :mod:`typing.Generic` superclass first introduced with :pep:`484`, this - tester is intentionally: - - * Defined in the :pep:`484`-specific submodule rather than either the `PEP - 585`_-specific submodule *or* higher-level PEP-agnostic test submodule. - * Named ``is_hint_pep484_generic`` rather than - ``is_hint_pep484or544_generic`` or ``is_hint_pep_typing_generic``. - - From the end user perspective, *all* :mod:`typing` generics are effectively - indistinguishable from :pep:`484`-compliant generics and should typically - be generically treated as such. - Parameters ---------- hint : object diff --git a/beartype/_util/hint/pep/proposal/utilpep585.py b/beartype/_util/hint/pep/proposal/utilpep585.py index 59bdb11c..a630a318 100644 --- a/beartype/_util/hint/pep/proposal/utilpep585.py +++ b/beartype/_util/hint/pep/proposal/utilpep585.py @@ -4,7 +4,7 @@ # See "LICENSE" for further details. ''' -Project-wide :pep:`585`:-compliant type hint utilities. +Project-wide :pep:`585`-compliant type hint utilities. This private submodule is *not* intended for importation by downstream callers. ''' @@ -34,9 +34,9 @@ # ....................{ VALIDATORS }.................... def die_unless_hint_pep585_generic(hint: object) -> None: ''' - Raise an exception unless the passed object is a :pep:`585`:-compliant + Raise an exception unless the passed object is a :pep:`585`-compliant **generic** (i.e., class superficially subclassing at least one subscripted - :pep:`585`:-compliant pseudo-superclass). + :pep:`585`-compliant pseudo-superclass). Parameters ---------- @@ -46,7 +46,7 @@ def die_unless_hint_pep585_generic(hint: object) -> None: Raises ---------- BeartypeDecorHintPep585Exception - If this hint is *not* a :pep:`585`:-compliant generic. + If this hint is *not* a :pep:`585`-compliant generic. ''' # If this hint is *NOT* a PEP 585-compliant generic, raise an exception @@ -134,7 +134,7 @@ def is_hint_pep585_generic(hint: object) -> bool: # ....................{ TESTERS ~ doc }.................... # Docstring for this function regardless of implementation details. is_hint_pep585_builtin.__doc__ = ''' - ``True`` only if the passed object is a C-based :pep:`585`:-compliant + ``True`` only if the passed object is a C-based :pep:`585`-compliant **builtin type hint** (i.e., C-based type hint instantiated by subscripting either a concrete builtin container class like :class:`list` or :class:`tuple` *or* an abstract base class (ABC) declared by the @@ -147,18 +147,18 @@ def is_hint_pep585_generic(hint: object) -> bool: Caveats ---------- - **This test returns false for** :pep:`585`:-compliant **generics,** which - fail to satisfy the same API as all other :pep:`585`:-compliant type hints. + **This test returns false for** :pep:`585`-compliant **generics,** which + fail to satisfy the same API as all other :pep:`585`-compliant type hints. Why? Because :pep:`560`-type erasure erases this API on :pep:`585`-compliant generics immediately after those generics are - declared, preventing their subsequent detection as :pep:`585`:-compliant. - Instead, :pep:`585`:-compliant generics are only detectable by calling + declared, preventing their subsequent detection as :pep:`585`-compliant. + Instead, :pep:`585`-compliant generics are only detectable by calling either: * The high-level PEP-agnostic :func:`beartype._util.hint.pep.utilpeptest.is_hint_pep484585_generic` tester. - * The low-level :pep:`585`:-specific :func:`is_hint_pep585_generic` tester. + * The low-level :pep:`585`-specific :func:`is_hint_pep585_generic` tester. Parameters ---------- @@ -168,14 +168,14 @@ def is_hint_pep585_generic(hint: object) -> bool: Returns ---------- bool - ``True`` only if this object is a :pep:`585`:-compliant type hint. + ``True`` only if this object is a :pep:`585`-compliant type hint. ''' is_hint_pep585_generic.__doc__ = ''' - ``True`` only if the passed object is a :pep:`585`:-compliant **generic** - (i.e., class superficially subclassing at least one subscripted `PEP - 585`_-compliant pseudo-superclass). + ``True`` only if the passed object is a :pep:`585`-compliant **generic** + (i.e., object that may *not* actually be a class originally subclassing at + least one subscripted :pep:`585`-compliant pseudo-superclass). This tester is memoized for efficiency. @@ -187,17 +187,17 @@ def is_hint_pep585_generic(hint: object) -> bool: Returns ---------- bool - ``True`` only if this object is a :pep:`585`:-compliant generic. + ``True`` only if this object is a :pep:`585`-compliant generic. ''' # ....................{ GETTERS }.................... def get_hint_pep585_generic_bases_unerased(hint: Any) -> tuple: ''' - Tuple of all unerased :pep:`585`:-compliant **pseudo-superclasses** (i.e., + Tuple of all unerased :pep:`585`-compliant **pseudo-superclasses** (i.e., :mod:`typing` objects originally listed as superclasses prior to their - implicit type erasure under `PEP 560`_) of the passed `PEP 585`-compliant - **generic** (i.e., class subclassing at least one non-class `PEP - 585`-compliant object). + implicit type erasure under :pep:`560`) of the passed :pep:`585`-compliant + **generic** (i.e., class subclassing at least one non-class + :pep:`585`-compliant object). This getter is intentionally *not* memoized (e.g., by the :func:`callable_cached` decorator), as the implementation trivially reduces @@ -212,12 +212,12 @@ def get_hint_pep585_generic_bases_unerased(hint: Any) -> tuple: ---------- Tuple[object] Tuple of the one or more unerased pseudo-superclasses of this - :pep:`585`:-compliant generic. + :pep:`585`-compliant generic. Raises ---------- BeartypeDecorHintPep585Exception - If this hint is *not* a :pep:`585`:-compliant generic. + If this hint is *not* a :pep:`585`-compliant generic. See Also ---------- @@ -247,7 +247,7 @@ def get_hint_pep585_generic_bases_unerased(hint: Any) -> tuple: def get_hint_pep585_generic_typevars(hint: object) -> TupleTypes: ''' Tuple of all **unique type variables** (i.e., subscripted :class:`TypeVar` - instances of the passed :pep:`585`:-compliant generic listed by the caller + instances of the passed :pep:`585`-compliant generic listed by the caller at hint declaration time ignoring duplicates) if any *or* the empty tuple otherwise. @@ -255,12 +255,12 @@ def get_hint_pep585_generic_typevars(hint: object) -> TupleTypes: Motivation ---------- - The current implementation of :pep:`585`: under at least Python 3.9 is + The current implementation of :pep:`585` under at least Python 3.9 is fundamentally broken with respect to parametrized generics. While `PEP 484`_-compliant generics properly propagate type variables from - pseudo-superclasses to subclasses, :pep:`585`: fails to do so. This function + pseudo-superclasses to subclasses, :pep:`585` fails to do so. This function "fills in the gaps" by recovering these type variables from parametrized - :pep:`585`:-compliant generics by iteratively constructing a new tuple from + :pep:`585`-compliant generics by iteratively constructing a new tuple from the type variables parametrizing all pseudo-superclasses of this generic. Parameters @@ -273,14 +273,14 @@ def get_hint_pep585_generic_typevars(hint: object) -> TupleTypes: Tuple[TypeVar, ...] Either: - * If this :pep:`585`:-compliant generic defines a ``__parameters__`` + * If this :pep:`585`-compliant generic defines a ``__parameters__`` dunder attribute, the value of that attribute. * Else, the empty tuple. Raises ---------- BeartypeDecorHintPep585Exception - If this hint is *not* a :pep:`585`:-compliant generic. + If this hint is *not* a :pep:`585`-compliant generic. ''' # Avoid circular import dependencies. diff --git a/beartype/_util/hint/pep/proposal/utilpep589.py b/beartype/_util/hint/pep/proposal/utilpep589.py index 9ff98eb3..8ca26c43 100644 --- a/beartype/_util/hint/pep/proposal/utilpep589.py +++ b/beartype/_util/hint/pep/proposal/utilpep589.py @@ -10,16 +10,13 @@ ''' # ....................{ IMPORTS }.................... +from beartype._util.cls.utilclstest import is_type_subclass from beartype._util.py.utilpyversion import IS_PYTHON_3_8 # See the "beartype.cave" submodule for further commentary. __all__ = ['STAR_IMPORTS_CONSIDERED_HARMFUL'] # ....................{ TESTERS }.................... -#FIXME: Call this tester directly *ONLY* from the get_hint_pep_sign_or_none() -#getter. All other areas of the code should efficiently test signs against -#"HintSignTypedDict" instead. - # The implementation of the "typing.TypedDict" attribute substantially varies # across Python interpreter *AND* "typing" implementation. Specifically: # * The "typing.TypedDict" attribute under Python >= 3.9 is *NOT* actually a @@ -73,19 +70,36 @@ def is_hint_pep589(hint: object) -> bool: ``True`` only if this object is a typed dictionary. ''' - # If this hint is neither... - if not ( - # A class *NOR*... - isinstance(hint, type) and - # A "dict" subclass... - issubclass(hint, dict) - # Then this hint *CANNOT* be a typed dictionary. By definition, each typed - # dictionary is necessarily a "dict" subclass. - ): + # If this hint is *NOT* a "dict" subclass, this hint *CANNOT* be a typed + # dictionary. By definition, typed dictionaries are "dict" subclasses. + # + # Note that PEP 589 actually lies about the type of typed dictionaries: + # Methods are not allowed, since the runtime type of a TypedDict object + # will always be just dict (it is never a subclass of dict). + # + # This is *ABSOLUTELY* untrue. PEP 589 authors plainly forgot to implement + # this constraint. Contrary to the above: + # * All typed dictionaries are subclasses of "dict". + # * The type of typed dictionaries is the private "typing._TypedDictMeta" + # metaclass across all Python versions (as of this comment). + # + # This is where we generously and repeatedly facepalm ourselves. + if not is_type_subclass(hint, dict): return False - # Else, this hint is a "dict" subclass and thus might be a typed + # Else, this hint is a "dict" subclass and thus *MIGHT* be a typed # dictionary. + # Return true *ONLY* if this "dict" subclass defines all three dunder + # attributes guaranteed to be defined by all typed dictionaries. Although + # slow, this is still faster than the MRO-based approach delineated above. + # + # Note that *ONLY* the Python 3.8-specific implementation of + # "typing.TypedDict" fails to unconditionally define the + # "__required_keys__" and "__optional_keys__" dunder attributes. Ergo, if + # the active Python interpreter targets exactly Python 3.8, we relax this + # test to *ONLY* test for the "__annotations__" dunder attribute. + # Specifically, we return true only if... + # # Technically, this test can also be performed by inefficiently violating # privacy encapsulation. Specifically, this test could perform an O(k) walk # up the class inheritance tree of the passed class (for k the number of @@ -110,20 +124,10 @@ def is_hint_pep589(hint: object) -> bool: # efficiently rather than accommodate this error. # # In short, the current approach of is strongly preferable. - - # Return true *ONLY* if this "dict" subclass defines all three dunder - # attributes guaranteed to be defined by all typed dictionaries. Although - # slow, this is still faster than the MRO-based approach delineated above. - # - # Note that *ONLY* the Python 3.8-specific implementation of - # "typing.TypedDict" fails to unconditionally define the - # "__required_keys__" and "__optional_keys__" dunder attributes. Ergo, if - # the active Python interpreter targets exactly Python 3.8, we relax this - # test to *ONLY* test for the "__annotations__" dunder attribute. - # Specifically, we return true only if... return ( - # This "dict" subclass defines this "TypedDict" attribute *AND*... + # This "dict" subclass defines these "TypedDict" attributes *AND*... hasattr(hint, '__annotations__') and + hasattr(hint, '__total__') and # Either... ( # The active Python interpreter targets exactly Python 3.8 and diff --git a/beartype/_util/hint/pep/utilpepget.py b/beartype/_util/hint/pep/utilpepget.py index b075f22d..b3b38102 100644 --- a/beartype/_util/hint/pep/utilpepget.py +++ b/beartype/_util/hint/pep/utilpepget.py @@ -28,6 +28,7 @@ from beartype._data.hint.pep.sign.datapepsigns import ( HintSignGeneric, HintSignNewType, + HintSignTypedDict, ) from beartype._data.hint.pep.sign.datapepsignset import ( HINT_SIGNS_ORIGIN_ISINSTANCEABLE, @@ -345,6 +346,8 @@ def get_hint_pep_sign(hint: Any) -> HintSign: See Also ---------- + :func:`get_hint_pep_sign_or_none` + Further details. ''' # Sign uniquely identifying this hint if recognized *OR* "None" otherwise. @@ -474,11 +477,6 @@ class like :class:`list` or :class:`tuple` *or* an abstract base class typing.Iterable ''' - # ..................{ IMPORTS }.................. - # Avoid circular import dependencies. - from beartype._util.hint.pep.proposal.pep484585.utilpepgeneric import ( - is_hint_pep484585_generic) - # For efficiency, this tester identifies the sign of this type hint with # multiple phases performed in ascending order of average time complexity. # @@ -583,11 +581,14 @@ class like :class:`list` or :class:`tuple` *or* an abstract base class # For minor efficiency gains, the following tests are intentionally ordered # in descending likelihood of a match. + # Avoid circular import dependencies. + from beartype._util.hint.pep.proposal.pep484585.utilpepgeneric import ( + is_hint_pep484585_generic) + from beartype._util.hint.pep.proposal.utilpep589 import is_hint_pep589 + # If this hint is a PEP 484- or 585-compliant generic (i.e., user-defined # class superficially subclassing at least one PEP 484- or 585-compliant - # type hint), return that sign - # - # However, note that: + # type hint), return that sign. However, note that: # * Generics *CANNOT* be detected by the general-purpose logic performed # above, as the "typing.Generic" ABC does *NOT* define a __repr__() # dunder method returning a string prefixed by the "typing." substring. @@ -605,14 +606,27 @@ class like :class:`list` or :class:`tuple` *or* an abstract base class # * *NO* PEP 585-compliant generics subclass this ABC unless those generics # are also either PEP 484- or 544-compliant. Indeed, PEP 585-compliant # generics subclass *NO* common superclass. + # * Generics are *NOT* necessarily classes, despite originally being + # declared as classes. Although *MOST* generics are classes, some are + # shockingly *NOT*: e.g., + # >>> from typing import Generic, TypeVar + # >>> S = TypeVar('S') + # >>> T = TypeVar('T') + # >>> class MuhGeneric(Generic[S, T]): pass + # >>> non_class_generic = MuhGeneric[S, T] + # >>> isinstance(non_class_generic, type) + # False # # Ergo, the "typing.Generic" ABC uniquely identifies many but *NOT* all - # generics. While non-ideal, the failure of PEP 585-compliant generics to - # subclass a common superclass leaves us with little alternative. + # generics. While non-ideal, the failure of PEP 585-compliant generics + # to subclass a common superclass leaves us with little alternative. if is_hint_pep484585_generic(hint): return HintSignGeneric # Else, this hint is *NOT* a PEP 484- or 585-compliant generic. # + # If this hint is a PEP 589-compliant typed dictionary, return that sign. + elif is_hint_pep589(hint): + return HintSignTypedDict # If the active Python interpreter targets Python < 3.10 (and thus defines # PEP 484-compliant "NewType" type hints as closures returned by that # function that are sufficiently dissimilar from all other type hints to @@ -621,8 +635,8 @@ class like :class:`list` or :class:`tuple` *or* an abstract base class # # Note that these hints *CANNOT* be detected by the general-purpose logic # performed above, as the __repr__() dunder methods of the closures created - # and returned by the NewType() closure factory function returns a standard - # representation rather than string prefixed by "typing.": e.g., + # and returned by the NewType() closure factory function return a standard + # representation rather than a string prefixed by "typing.": e.g., # >>> import typing as t # >>> repr(t.NewType('FakeStr', str)) # '.new_type at 0x7fca39388050>' diff --git a/beartype/_util/hint/utilhintget.py b/beartype/_util/hint/utilhintget.py index 53e471c0..4de444a4 100644 --- a/beartype/_util/hint/utilhintget.py +++ b/beartype/_util/hint/utilhintget.py @@ -18,7 +18,9 @@ HintSignNewType, HintSignNumpyArray, HintSignType, + HintSignTypedDict, ) +from collections.abc import Mapping from typing import Any # See the "beartype.cave" submodule for further commentary. @@ -91,6 +93,9 @@ def get_hint_reduced( # # ..................{ NON-PEP }.................. # If this hint is unidentifiable, return this hint as is unmodified. + # + # Since this includes *ALL* isinstanceable classes (including both + # user-defined classes and builtin types), this is *ALWAYS* detected first. if hint_sign is None: return hint # ..................{ PEP 484 ~ none }.................. @@ -99,15 +104,16 @@ def get_hint_reduced( # "typing" module, PEP 484 explicitly supports this singleton: # When used in a type hint, the expression None is considered # equivalent to type(None). + # # The "None" singleton is used to type callables lacking an explicit - # "return" statement and thus absurdly common. Ergo, detect this first. + # "return" statement and thus absurdly common. Ergo, detect this early. elif hint is None: hint = NoneType # ..................{ PEP 593 }.................. # If this hint is a PEP 593-compliant metahint... # # Since metahints form the core backbone of our beartype-specific data - # validation API, metahints are extremely common and thus detected next. + # validation API, metahints are extremely common and thus detected early. elif hint_sign is HintSignAnnotated: # Avoid circular import dependencies. from beartype._util.hint.pep.proposal.utilpep593 import ( @@ -128,7 +134,7 @@ def get_hint_reduced( # "numpy.typing.NDArray[np.float64]"), reduce this hint to the equivalent # well-supported beartype validator. # - # Typed NumPy arrays are increasingly common and thus detected next. + # Typed NumPy arrays are increasingly common and thus detected early. elif hint_sign is HintSignNumpyArray: # Avoid circular import dependencies. from beartype._util.hint.pep.mod.utilmodnumpy import ( @@ -152,6 +158,32 @@ def get_hint_reduced( reduce_hint_pep484585_subclass_superclass_if_ignorable) hint = reduce_hint_pep484585_subclass_superclass_if_ignorable( hint=hint, exception_prefix=exception_prefix) + # ..................{ PEP 589 }.................. + #FIXME: Remove *AFTER* deeply type-checking typed dictionaries. For now, + #shallowly type-checking such hints by reduction to untyped dictionaries + #remains the sanest temporary work-around. + #FIXME: The PEP 589 edict that "any TypedDict type is consistent with + #"Mapping[str, object]" suggests that we should trivially reduce this hint + #to "Mapping[str, object]" rather than merely "Mapping" *AFTER* we deeply + #type-check mappings. Doing so will get us slightly deeper type-checking of + #typed dictionaries, effectively for free. Note that: + #* Care should be taken to ensure that the "Mapping" factory appropriate + # for the active Python interpreter is used. PEP 585 gonna PEP 585. + #* We should cache "Mapping[str, object]" to a private global above rather + # than return a new "Mapping[str, object]" type hint on each call. Right? + + # If this hint is a PEP 589-compliant typed dictionary (i.e., + # "typing.TypedDict" or "typing_extensions.TypedDict" subclass), silently + # ignore all child type hints annotating this dictionary by reducing this + # hint to the "Mapping" superclass. Yes, "Mapping" rather than "dict". By + # PEP 589 edict: + # First, any TypedDict type is consistent with Mapping[str, object]. + # + # Typed dictionaries are largely discouraged in the typing community, due + # to their non-standard semantics and syntax. Ergo, typed dictionaries are + # reasonably uncommon and thus detected late. + elif hint_sign is HintSignTypedDict: + return Mapping # ..................{ PEP 484 ~ new type }.................. # If this hint is a PEP 484-compliant new type, reduce this hint to the # user-defined class aliased by this hint. Although this logic could also @@ -171,7 +203,7 @@ def get_hint_reduced( # corresponding functionally useful beartype-specific PEP 544-compliant # protocol implementing this hint. # - # IO generic base classes are extremely rare and thus detected last. + # IO generic base classes are extremely rare and thus detected even later. # # Note that PEP 484-compliant IO generic base classes are technically # usable under Python < 3.8 (e.g., by explicitly subclassing those classes diff --git a/beartype_test/a00_unit/a00_util/hint/a00_pep/test_a00_utilpepget.py b/beartype_test/a00_unit/a00_util/hint/a00_pep/test_a00_utilpepget.py index 817f7bb9..749126e6 100644 --- a/beartype_test/a00_unit/a00_util/hint/a00_pep/test_a00_utilpepget.py +++ b/beartype_test/a00_unit/a00_util/hint/a00_pep/test_a00_utilpepget.py @@ -103,8 +103,7 @@ def test_get_hint_pep_sign() -> None: # Assert this getter returns the expected unsubscripted "typing" attribute # for all PEP-compliant type hints associated with such an attribute. for hint_pep_meta in HINTS_PEP_META: - assert get_hint_pep_sign(hint_pep_meta.hint) == ( - hint_pep_meta.pep_sign) + assert get_hint_pep_sign(hint_pep_meta.hint) is hint_pep_meta.pep_sign # Assert this getter raises the expected exception for an instance of a # class erroneously masquerading as a "typing" class. diff --git a/beartype_test/a00_unit/a10_pep/test_pep589.py b/beartype_test/a00_unit/a10_pep/test_pep589.py index a910f811..6cdc38fa 100644 --- a/beartype_test/a00_unit/a10_pep/test_pep589.py +++ b/beartype_test/a00_unit/a10_pep/test_pep589.py @@ -35,6 +35,7 @@ class NonTypedDict(dict): # subclass to be erroneously detected as a typed dictionary under Python # >= 3.8. And we will sleep now. This has spiralled into insanity, folks. __optional_keys__ = () + __total__ = True # ....................{ TESTS ~ validators }.................... def test_is_hint_pep589() -> None: diff --git a/beartype_test/a00_unit/data/hint/pep/data_pep.py b/beartype_test/a00_unit/data/hint/pep/data_pep.py index 57f43249..e94cea04 100644 --- a/beartype_test/a00_unit/data/hint/pep/data_pep.py +++ b/beartype_test/a00_unit/data/hint/pep/data_pep.py @@ -92,6 +92,7 @@ def _init() -> None: _data_pep544, _data_pep585, _data_pep586, + _data_pep589, _data_pep593, ) @@ -113,6 +114,7 @@ def _init() -> None: _data_pep544, _data_pep585, _data_pep586, + _data_pep589, _data_pep593, ) diff --git a/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep586.py b/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep586.py index 7a116424..18c855d7 100644 --- a/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep586.py +++ b/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep586.py @@ -33,7 +33,7 @@ def add_data(data_module: 'ModuleType') -> None: Module to be added to. ''' - # "typing.Literal" type hint factory imported from either the "typing" or + # "Literal" type hint factory imported from either the "typing" or # "typing_extensions" modules if importable *OR* "None" otherwise. Literal = import_module_typing_any_attr_or_none_safe('Literal') diff --git a/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep589.py b/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep589.py new file mode 100644 index 00000000..6ef75e00 --- /dev/null +++ b/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep589.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# --------------------( LICENSE )-------------------- +# Copyright (c) 2014-2021 Beartype authors. +# See "LICENSE" for further details. + +''' +Project-wide :pep:`589`-compliant **type hint test data.** +''' + +# ....................{ IMPORTS }.................... +from beartype_test.util.mod.pytmodimport import ( + import_module_typing_any_attr_or_none_safe) + +# ....................{ ADDERS }.................... +def add_data(data_module: 'ModuleType') -> None: + ''' + Add :pep:`589`-compliant type hint test data to various global containers + declared by the passed module. + + Parameters + ---------- + data_module : ModuleType + Module to be added to. + ''' + + # "TypedDict" type hint factory imported from either the "typing" or + # "typing_extensions" modules if importable *OR* "None" otherwise. + TypedDict = import_module_typing_any_attr_or_none_safe('TypedDict') + + # If this factory is unimportable, the active Python interpreter fails to + # support PEP 589. In this case, reduce to a noop. + if TypedDict is None: + return + # Else, this interpreter supports PEP 589. + + # ..................{ IMPORTS }.................. + # Defer attribute-dependent imports. + from beartype._data.hint.pep.sign.datapepsigns import ( + HintSignList, + HintSignTypedDict, + ) + from beartype_test.a00_unit.data.hint.util.data_hintmetacls import ( + HintPepMetadata, + HintPithSatisfiedMetadata, + HintPithUnsatisfiedMetadata, + ) + from typing import List, Type, Union + + # ..................{ SUBCLASSES }.................. + class ISeemAsInATranceSublimeAndStrange(TypedDict): + ''' + Arbitrary empty typed dictionary annotated to require *NO* key-value + pairs. + + While patently absurd, this dictionary exercises an uncommon edge case + in :pep:`589`. + ''' + + pass + + + class DizzyRavine(ISeemAsInATranceSublimeAndStrange): + ''' + Arbitrary non-empty typed dictionary annotated to require arbitrary + key-value pairs, intentionally subclassing the empty typed dictionary + subclass :class:`ISeemAsInATranceSublimeAndStrange` to trivially + exercise subclassability. + ''' + + # Arbitrary key whose value is annotated to be a PEP-noncompliant + # instance of an isinstanceable type. + and_when: str + + # Arbitrary key whose value is annotated to be a PEP-compliant union of + # either a subclass of an issubclassable type or a PEP-noncompliant + # instance of an isinstanceable type. + I_gaze_on_thee: Union[bytes, Type[Exception]] + + + #FIXME: Note that even this fails to suffice, thanks to *CRAY-CRAY* + #subclassing logic that absolutely no one has ever exercised, but which + #we'll nonetheless need to support. And I quoth: + # The totality flag only applies to items defined in the body of the + # TypedDict definition. Inherited items won't be affected, and instead + # use totality of the TypedDict type where they were defined. This makes + # it possible to have a combination of required and non-required keys in + # a single TypedDict type. + #Ergo, we need to additionally declare yet another new class subclassing + #"ToMuse" but *NOT* explicitly subclassed with a "total" keyword parameter. + #This clearly gets *EXTREMELY* ugly *EXTREMELY* fast, as we'll now need to + #iterate over "hint.__mro__" in our code generation algorithm. Well, I + #suppose we technically needed to do that anyway... but still. Yikes! + class ToMuse(TypedDict, total=False): + ''' + Arbitrary non-empty typed dictionary annotated to require zero or more + arbitrary key-value pairs. + ''' + + # Arbitrary key whose value is annotated to be a PEP-noncompliant + # instance of an isinstanceable type. + on_my_own: str + + # Arbitrary key whose value is annotated to be a PEP-compliant union of + # either a subclass of an issubclassable type or a PEP-noncompliant + # instance of an isinstanceable type. + separate_fantasy: Union[Type[Exception], bytes] + + # ..................{ TUPLES }.................. + # Add PEP 586-specific test type hints to this tuple global. + data_module.HINTS_PEP_META.extend(( + # ................{ TYPEDDICT }................ + # Empty typed dictionary. Look, this is ridiculous. What can you do? + HintPepMetadata( + hint=ISeemAsInATranceSublimeAndStrange, + pep_sign=HintSignTypedDict, + is_type_typing=False, + piths_satisfied_meta=( + # Empty dictionary instantiated with standard Python syntax. + HintPithSatisfiedMetadata({}), + # Empty dictionary instantiated from this typed dictionary. + HintPithSatisfiedMetadata(ISeemAsInATranceSublimeAndStrange()), + ), + piths_unsatisfied_meta=( + # String constant. + HintPithUnsatisfiedMetadata( + pith='Hadithian bodies kindle Bodkin deathbeds', + # Match that the exception message raised for this object + # embeds the representation of the expected type. + exception_str_match_regexes=(r'\bMapping\b',), + ), + #FIXME: Uncomment *AFTER* deeply type-checking "TypedDict". + # # Non-empty dictionary. + # HintPithSatisfiedMetadata({ + # 'Corinthian bodachean kinslayers lay': ( + # 'wedded weal‐kith with in‐'), + # }), + ), + ), + + # Non-empty totalizing typed dictionary. + HintPepMetadata( + hint=DizzyRavine, + pep_sign=HintSignTypedDict, + is_type_typing=False, + piths_satisfied_meta=( + # Non-empty dictionary of the expected keys and values. + HintPithSatisfiedMetadata({ + 'and_when': 'Corrigible‐ragged gun corruptions within', + 'I_gaze_on_thee': b"Hatross-ev-olved eleven imp's", + }), + # Non-empty dictionary of the expected keys and values + # instantiated from this typed dictionary. + HintPithSatisfiedMetadata(DizzyRavine( + and_when=( + 'Machiavellian‐costumed, tumid stock fonts of a'), + I_gaze_on_thee=RuntimeError, + )), + ), + piths_unsatisfied_meta=( + # String constant. + HintPithUnsatisfiedMetadata( + pith='Matross‐elevated elven velvet atrocities of', + # Match that the exception message raised for this object + # embeds the representation of the expected type. + exception_str_match_regexes=(r'\bMapping\b',), + ), + # #FIXME: Uncomment *AFTER* deeply type-checking "TypedDict". + # # Empty dictionary. + # HintPithUnsatisfiedMetadata( + # pith={}, + # # Match that the exception message raised for this object + # # embeds the expected number of key-value pairs. + # exception_str_match_regexes=(r'\b2\b',), + # ), + # # Non-empty dictionary of the expected keys but *NOT* values. + # HintPithUnsatisfiedMetadata( + # pith={ + # 'and_when': 'Matricidally', + # 'I_gaze_on_thee': ( + # 'Hatchet‐cachepotting, ' + # 'Scossetting mock misrule by' + # ), + # }, + # # Match that the exception message raised for this object + # # embeds: + # # * The name of the unsatisfied key. + # # * The expected types of this key's value. + # exception_str_match_regexes=( + # r'\bI_gaze_on_thee\b', + # r'\bbytes\b', + # ), + # ), + ), + ), + + # Non-empty non-totalizing typed dictionary. + HintPepMetadata( + hint=ToMuse, + pep_sign=HintSignTypedDict, + is_type_typing=False, + piths_satisfied_meta=( + # Empty dictionary. + HintPithSatisfiedMetadata({}), + # Non-empty dictionary defining only one of the expected keys. + HintPithSatisfiedMetadata({ + 'on_my_own': ( + 'Spurned Court‐upturned, upper gladness, ' + 'edifyingly humidifying'), + }), + # Non-empty dictionary defining *ALL* of the expected keys, + # instantiated from this typed dictionary. + HintPithSatisfiedMetadata(ToMuse( + on_my_own=( + 'Sepulchral epic‐âpostatizing home tombs metem‐'), + separate_fantasy=b'Macroglia relics', + )), + ), + piths_unsatisfied_meta=( + # String constant. + HintPithUnsatisfiedMetadata( + pith=( + 'Psychotically tempered Into temporal ' + 'afterwork‐met portals portending a' + ), + # Match that the exception message raised for this object + # embeds the representation of the expected type. + exception_str_match_regexes=(r'\bMapping\b',), + ), + # #FIXME: Uncomment *AFTER* deeply type-checking "TypedDict". + # # Non-empty dictionary of the expected keys but *NOT* values. + # HintPithUnsatisfiedMetadata( + # pith={ + # 'on_my_own': ( + # 'Psyche’s Maidenly‐enladened, ' + # 'aidful Lads‐lickspittling Potenc‐ies —', + # ), + # 'separate_fantasy': ( + # 'Psychedelic metal‐metastasized, glib'), + # }, + # # Match that the exception message raised for this object + # # embeds: + # # * The name of the unsatisfied key. + # # * The expected types of this key's value. + # exception_str_match_regexes=( + # r'\bseparate_fantasy\b', + # r'\bbytes\b', + # ), + # ), + ), + ), + + # ................{ LITERALS ~ nested }................ + # List of non-empty totalizing typed dictionaries. + HintPepMetadata( + hint=List[DizzyRavine], + pep_sign=HintSignList, + isinstanceable_type=list, + piths_satisfied_meta=( + # List of dictionaries of the expected keys and values. + HintPithSatisfiedMetadata([ + { + 'and_when': ( + 'Matriculating ‘over‐sized’ ' + 'research urchin Haunts of', + ), + 'I_gaze_on_thee': b"Stands - to", + }, + { + 'and_when': ( + 'That resurrected, Erectile reptile’s ' + 'pituitary capitulations to', + ), + 'I_gaze_on_thee': b"Strand our under-", + }, + ]), + ), + piths_unsatisfied_meta=( + # List of string constants. + HintPithUnsatisfiedMetadata( + pith=[ + 'D-as K-apital, ' + 'notwithstanding Standard adiós‐', + ], + # Match that the exception message raised for this object + # embeds the representation of the expected type. + exception_str_match_regexes=(r'\bMapping\b',), + ), + # #FIXME: Uncomment *AFTER* deeply type-checking "TypedDict". + # # List of empty dictionaries. + # HintPithUnsatisfiedMetadata( + # pith=[{}, {},], + # # Match that the exception message raised for this object + # # embeds the expected number of key-value pairs. + # exception_str_match_regexes=(r'\b2\b',), + # ), + # # List of non-empty dictionaries, only one of which fails to + # # define both the expected keys and values. + # HintPithUnsatisfiedMetadata( + # pith=[ + # { + # 'and_when': ( + # 'Diased capitalization of (or into)'), + # 'I_gaze_on_thee': ( + # b'Witheringly dithering, dill husks of'), + # }, + # { + # 'and_when': ( + # 'Will, like Whitewash‐ed, musky'), + # 'I_gaze_on_thee': 'Likenesses injecting', + # }, + # ], + # # Match that the exception message raised for this object + # # embeds: + # # * The index of the unsatisfied dictionary. + # # * The name of the unsatisfied key. + # # * The expected types of this key's value. + # exception_str_match_regexes=( + # r'\b1\b', + # r'\bI_gaze_on_thee\b', + # r'\bbytes\b', + # ), + # ), + ), + ), + )) diff --git a/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep593.py b/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep593.py index 1a9c6af1..cac208e8 100644 --- a/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep593.py +++ b/beartype_test/a00_unit/data/hint/pep/proposal/_data_pep593.py @@ -23,7 +23,7 @@ def add_data(data_module: 'ModuleType') -> None: Module to be added to. ''' - # "typing.Annotated" type hint factory imported from either the "typing" or + # "Annotated" type hint factory imported from either the "typing" or # "typing_extensions" modules if importable *OR* "None" otherwise. Annotated = import_module_typing_any_attr_or_none_safe('Annotated')