Skip to content

Commit

Permalink
typing.TypedDict x 1.
Browse files Browse the repository at this point in the history
This commit is the first in a commit chain adding support for
superficially type-checking **typed dictionaries** (i.e.,
`typing.TypedDict` and `typing_extensions.TypedDict` subclasses).
Specifically, this commit adds a new
`beartype._util.hint.pep.proposal.utilpep593.is_hint_pep593()` tester
reliably detecting typed dictionaries across all supported Python
versions and `typing` module implementations. Unsurprisingly, this is
non-trivial, as the Python 3.8-specific implementation of the
`typing.TypedDict` subclass is functionally deficient and presumably
never meaningfully unit-tested. *It's not a good look.* (*Unrequited requiem!*)
  • Loading branch information
leycec committed Oct 12, 2021
1 parent c22a241 commit 1816402
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 6 deletions.
11 changes: 6 additions & 5 deletions README.rst
Expand Up @@ -2192,7 +2192,7 @@ Let's chart current and future compliance with Python's `typing`_ landscape:
+--------------------+-----------------------------------------+-------------------------------+---------------------------+
| | `586 <PEP 586_>`__ | **0.7.0**\ —\ *current* | **0.7.0**\ —\ *current* |
+--------------------+-----------------------------------------+-------------------------------+---------------------------+
| | `589 <PEP 589_>`__ | *none* | *none* |
| | `589 <PEP 589_>`__ | **0.9.0**\ —\ *current* | *none* |
+--------------------+-----------------------------------------+-------------------------------+---------------------------+
| | `591 <PEP 591_>`__ | *none* | *none* |
+--------------------+-----------------------------------------+-------------------------------+---------------------------+
Expand Down Expand Up @@ -2369,8 +2369,9 @@ dynamically generated by ``@beartype`` consume comparatively little wall-clock,
even when repeatedly called many times.
See also `this comprehensive exegesis <beartype versus inline_>`__
:superscript:`it's a word, people` for a comparative analysis of the cost of
``@beartype`` versus hand-rolled inlined runtime type checking.
:superscript:`...it's a word, people` for a comparative analysis of the cost of
calling ``@beartype``\ -decorated callables versus undecorated callables
performing hand-rolled inlined runtime type checking.
That's Some Catch, That Catch-22
--------------------------------
Expand Down Expand Up @@ -2549,14 +2550,14 @@ Beartype is fully compliant with these `Python Enhancement Proposals (PEPs)
* `PEP 572 -- Assignment Expressions <PEP 572_>`__.
* `PEP 585 -- Type Hinting Generics In Standard Collections <PEP 585_>`__.
* `PEP 586 -- Literal Types <PEP 586_>`__.
* `PEP 589 -- TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys
<PEP 589_>`__, subject to `caveats detailed below <Partial Compliance_>`__
* `PEP 593 -- Flexible function and variable annotations <PEP 593_>`__.
* `PEP 604 -- Allow writing union types as X | Y <PEP 604_>`__.
Beartype is currently *not* compliant whatsoever with these PEPs:
* `PEP 526 -- Syntax for Variable Annotations <PEP 526_>`__.
* `PEP 589 -- TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys
<PEP 589_>`__.
* `PEP 591 -- Adding a final qualifier to typing <PEP 591_>`__.
* `PEP 612 -- Parameter Specification Variables <PEP 612_>`__.
Expand Down
5 changes: 5 additions & 0 deletions beartype/_decor/_pep563.py
Expand Up @@ -102,6 +102,11 @@ def resolve_hints_pep563_if_active(data: BeartypeData) -> None:

# If neither...
if not (
#FIXME: Revert this as soon as Gentoo's Python 3.10 target reverts
#unconditional enabling of PEP 563. Actually, let's just revert this
#now, because we absolutely know we're going to forget about this, and
#then everything terrifyingly breaks when the real Python 3.10 drops.

# The active Python interpreter targets Python >= 3.10 *NOR*...
#
# If this interpreter targets Python >= 3.10, PEP 563 is
Expand Down
2 changes: 1 addition & 1 deletion beartype/_util/hint/pep/proposal/utilpep585.py
Expand Up @@ -177,7 +177,7 @@ def is_hint_pep585_generic(hint: object) -> bool:
(i.e., class superficially subclassing at least one subscripted `PEP
585`_-compliant pseudo-superclass).
This tester memoized for efficiency.
This tester is memoized for efficiency.
Parameters
----------
Expand Down
140 changes: 140 additions & 0 deletions beartype/_util/hint/pep/proposal/utilpep589.py
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
# --------------------( LICENSE )--------------------
# Copyright (c) 2014-2021 Beartype authors.
# See "LICENSE" for further details.

'''
Project-wide :pep:`589`-compliant type hint utilities.
This private submodule is *not* intended for importation by downstream callers.
'''

# ....................{ IMPORTS }....................
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
# superclass but instead a factory function masquerading as a superclass by
# setting the subversive "__mro_entries__" dunder attribute to a tuple
# containing a private "typing._TypedDict" superclass. This superclass
# necessarily defines the three requisite dunder attributes.
# * The "typing_extensions.TypedDict" attribute under Python < 3.8 is actually
# a superclass also necessarily defining the three requisite dunder
# attributes.
# * The "typing.TypedDict" attribute under *ONLY* Python 3.8 is also actually
# a superclass that *ONLY* defines the requisite "__annotations__" dunder
# attribute. The two remaining dunder attributes are only conditionally
# defined and thus *CANNOT* be unconditionally assumed to exist.
# In all three cases, passing the passed hint and that superclass to the
# issubclass() builtin fails, as the metaclass of that superclass prohibits
# issubclass() checks. I am throwing up in my mouth as I write this.
#
# Unfortunately, all of the above complications are further complicated by the
# "dict" type under Python >= 3.10. For unknown reasons, Python >= 3.10 adds
# spurious "__annotations__" dunder attributes to "dict" subclasses -- even if
# those subclasses annotate *NO* class or instance variables. While a likely
# bug, we have little choice but to at least temporarily support this insanity.
def is_hint_pep589(hint: object) -> bool:
'''
``True`` only if the passed object is a :pep:`589`-compliant **typed
dictionary** (i.e., :class:`typing.TypedDict` subclass).
This getter is intentionally *not* memoized (e.g., by the
:func:`callable_cached` decorator). Although the implementation
inefficiently performs three calls to the :func:`hasattr` builtin (which
inefficiently calls the :func:`getattr` builtin and catches the
:exc:`AttributeError` exception to detect false cases), callers are
expected to instead (in order):
#. Call the memoized
:func:`beartype._util.hint.pep.utilpepget.get_hint_pep_sign_or_none`
getter, which internally calls this unmemoized tester.
#. Compare the object returned by that getter against the
:attr:`from beartype._util.data.hint.pep.sign.datapepsigns.HintSignTypedDict`
sign.
Parameters
----------
hint : object
Object to be tested.
Returns
----------
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.
):
return False
# Else, this hint is a "dict" subclass and thus might be a typed
# dictionary.

# 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
# superclasses of that class), iteratively comparing each such superclass
# for against the "typing.TypeDict" superclass. That is, this tester could
# crazily reimplement the issubclass() builtin in pure-Python. Since the
# implementation of typed dictionaries varies substantially across Python
# versions, doing so would require version-specific tests in addition to
# unsafely violating privacy encapsulation and inefficiently violating
# constant-time guarantees.
#
# Technically, the current implementation of this test is susceptible to
# false positives in unlikely edge cases. Specifically, this test queries
# for dunder attributes and thus erroneously returns true for user-defined
# "dict" subclasses *NOT* subclassing the "typing.TypedDict" superclass but
# nonetheless declaring the same dunder attributes declared by that
# superclass. Since the likelihood of any user-defined "dict" subclass
# accidentally defining these attributes is vanishingly small *AND* since
# "typing.TypedDict" usage is largely discouraged in the typing community,
# this error is unlikely to meaningfully arise in real-world use cases.
# Ergo, it is preferable to implement this test portably, safely, and
# 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*...
hasattr(hint, '__annotations__') and
# Either...
(
# The active Python interpreter targets exactly Python 3.8 and
# thus fails to unconditionally define the remaining attributes
# *OR*...
IS_PYTHON_3_8 or
# The active Python interpreter targets any other Python version
# and thus unconditionally defines the remaining attributes.
(
hasattr(hint, '__required_keys__') and
hasattr(hint, '__optional_keys__')
)
)
)
8 changes: 8 additions & 0 deletions beartype/_util/py/utilpyversion.py
Expand Up @@ -71,6 +71,14 @@
'''


#FIXME: After dropping Python 3.8 support, *REMOVE* all code conditionally
#testing this global.
IS_PYTHON_3_8 = version_info[:2] == (3, 8)
'''
``True`` only if the active Python interpreter targets exactly Python 3.8.
'''


#FIXME: After dropping Python 3.6 support:
#* Refactor all code conditionally testing this global to be unconditional.
#* Remove this global.
Expand Down
83 changes: 83 additions & 0 deletions beartype_test/a00_unit/a10_pep/test_pep589.py
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# --------------------( LICENSE )--------------------
# Copyright (c) 2014-2021 Beartype authors.
# See "LICENSE" for further details.

'''
**Beartype** :pep:`589` **unit tests.**
This submodule unit tests :pep:`589` support implemented in the
:func:`beartype.beartype` decorator.
'''

# ....................{ IMPORTS }....................
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# WARNING: To raise human-readable test errors, avoid importing from
# package-specific submodules at module scope.
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

# ....................{ CLASSES }....................
class NonTypedDict(dict):
'''
:class:`dict` subclass defining only one of the requisite three dunder
attributes necessarily defined by the :class:`typing.TypedDict` superclass.
'''

# Note that:
# * The "__annotations__" dunder attribute is intentionally omitted; that
# is the *ONLY* dunder attribute guaranteed to be declared by Python 3.8.
# * The "__required_keys__" dunder attribute is also intentionally omitted;
# for unknown reasons, Python >= 3.8 implicitly adds an unwanted
# "__annotations__" dunder attribute to *ALL* "dict" subclasses --
# including "dict" subclasses annotating *NO* class or instance
# variables. Defining both the "__required_keys__" and
# "__optional_keys__" dunder attributes here would thus suffice for this
# 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__ = ()

# ....................{ TESTS ~ validators }....................
def test_is_hint_pep589() -> None:
'''
Test the
:beartype._util.hint.pep.proposal.utilpep589.is_hint_pep589` tester.
'''

# Defer heavyweight imports.
from beartype._util.hint.pep.proposal.utilpep589 import is_hint_pep589
from beartype_test.util.mod.pytmodimport import (
import_module_typing_any_attr_or_none_safe)

# "typing.TypedDict" superclass 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 superclass exists...
if TypedDict is not None:
class ThouArtThePath(TypedDict):
'''
Arbitrary non-empty typed dictionary annotated to require arbitrary
key-value pairs.
'''

of_that: str
unresting_sound: int

# Assert this tester returns true when passed a typed dictionary.
assert is_hint_pep589(ThouArtThePath) is True

# Assert this tester returns false when passed a non-class.
assert is_hint_pep589(
'Thou art pervaded with that ceaseless motion,') is False

# Assert this tester returns false when passed a class *NOT* subclassing
# the builtin "dict" type.
assert is_hint_pep589(str) is False

# Assert this tester returns false when passed the builtin "dict" type.
assert is_hint_pep589(dict) is False

# Assert this tester returns false when passed a "dict" subclass defining
# only two of the requisite three dunder attributes necessarily defined by
# the "typing.TypedDict" superclass.
assert is_hint_pep589(NonTypedDict) is False

0 comments on commit 1816402

Please sign in to comment.