Skip to content

Commit

Permalink
PEP 563 + typing.NamedTuple x 8.
Browse files Browse the repository at this point in the history
This commit is the next in a commit chain explicitly supporting
`typing.NamedTuple` subclasses under PEP 563 (i.e., `from __future__
import annotations`), en-route to resolving issue #318 kindly submitted
by the cosmically rare-earth GitHub element @kasium. For unknown
reasons, `typing.NamedTuple` subclasses encapsulate type hints
stringified by PEP 563 as `typing.ForwardRef(...)` objects -- which is
just all manner of strange. In response, this commit lightly refactors
our forward reference proxy metaclass to manually rather than
automatically memoize previously imported forward referees. In theory,
doing so *should* enable subsequent commits to manually unmemoized all
previously imported forward referees across the entire app on detecting
when the user externally reloads one or more previously imported
modules, which *should* then allow us to repair the last remaining
broken unit test. (*Mighty sights!*)
  • Loading branch information
leycec committed Feb 6, 2024
1 parent 15b1d1f commit 79db992
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 97 deletions.
60 changes: 14 additions & 46 deletions beartype/_check/forward/reference/fwdrefabc.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ class variable is the name of the attribute referenced by that reference.
(i.e., if :attr:`__name_beartype__` is absolute).
'''


__type_imported_beartype__: Optional[type] = None
'''
Type hint referenced by this forward reference subclass if this subclass has
already been passed at least once as the second parameter to either the
:func:`isinstance` or :func:`issubclass` builtins (i.e., as the first
parameter to the :meth:`.BeartypeForwardRefMeta.__instancecheck__` or
:meth:`.BeartypeForwardRefMeta.__subclasscheck__` dunder methods) *or*
:data:`None` otherwise.
Note that this class variable is an optimization reducing space and time
complexity for subsequent lookup of this same type hint.
'''

# ....................{ INITIALIZERS }....................
def __new__(cls, *args, **kwargs) -> NoReturn:
'''
Expand Down Expand Up @@ -137,52 +151,6 @@ class referred to by this forward reference.
# referenced by this forward reference.
return issubclass(obj, cls.__type_beartype__) # type: ignore[arg-type]

# ....................{ PRIVATE ~ resolvers }....................
#FIXME: [SPEED] Optimize this by refactoring this into a cached class
#property defined on the metaclass of the superclass instead. Since doing so
#is a bit non-trivial and nobody particularly cares, the current naive
#approach certainly suffices for now. *sigh*
#
#On doing so, note that we'll also need to disable this line below:
# forwardref_subtype.__type_beartype__ = None # pyright: ignore[reportGeneralTypeIssues]
# @classmethod
# def __beartype_resolve_type__(cls) -> None:
# '''
# **Resolve** (i.e., dynamically lookup) the external class referred to by
# this forward reference and permanently store that class in the
# :attr:`__type_beartype__` class variable for subsequent lookup.
#
# Caveats
# -------
# This method should *always* be called before accessing the
# :attr:`__type_beartype__` class variable, which should *always* be
# assumed to be :data:`None` before calling this method.
# '''
#
# # If the external class referenced by this forward reference has yet to
# # be resolved, do so now.
# if cls.__type_beartype__ is None:
# # Fully-qualified name of that class, defined as either...
# type_name = (
# # If that name already contains one or more "." delimiters and
# # is thus presumably already fully-qualified, that name as is;
# cls.__name_beartype__
# if '.' in cls.__name_beartype__ else
# # Else, that name contains *NO* "." delimiters and is thus
# # unqualified. In this case, canonicalize that name into a
# # fully-qualified name relative to the fully-qualified name of
# # the scope presumably declaring that class.
# f'{cls.__scope_name_beartype__}.{cls.__name_beartype__}'
# )
#
# # Resolve that class by deferring to our existing "bear_typistry"
# # dictionary, which already performs lookup-based resolution and
# # caching of arbitrary forward references at runtime.
# cls.__type_beartype__ = bear_typistry[type_name]
# # Else, that class has already been resolved.
# #
# # In either case, that class is now resolved.

# ....................{ SUPERCLASSES ~ index }....................
#FIXME: Unit test us up, please.
class _BeartypeForwardRefIndexedABC(BeartypeForwardRefABC):
Expand Down
6 changes: 3 additions & 3 deletions beartype/_check/forward/reference/fwdrefmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,11 @@ def _make_forwardref_subtype(
)

# Classify passed parameters with this subclass.
forwardref_subtype.__name_beartype__ = hint_name # pyright: ignore[reportGeneralTypeIssues]
forwardref_subtype.__scope_name_beartype__ = scope_name # pyright: ignore[reportGeneralTypeIssues]
forwardref_subtype.__name_beartype__ = hint_name # pyright: ignore
forwardref_subtype.__scope_name_beartype__ = scope_name # pyright: ignore

# Nullify all remaining class variables of this subclass for safety.
# forwardref_subtype.__type_beartype__ = None # pyright: ignore[reportGeneralTypeIssues]
forwardref_subtype.__type_imported_beartype__ = None # pyright: ignore

# Return this subclass.
return forwardref_subtype
100 changes: 61 additions & 39 deletions beartype/_check/forward/reference/fwdrefmeta.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from beartype.roar import BeartypeCallHintForwardRefException
from beartype.typing import Type
from beartype._check.forward.reference import fwdrefabc # <-- satisfy mypy
from beartype._util.cache.utilcachecall import property_cached
from beartype._util.cls.pep.utilpep3119 import die_unless_type_isinstanceable
from beartype._util.hint.pep.proposal.pep484585.utilpep484585generic import (
is_hint_pep484585_generic,
Expand Down Expand Up @@ -223,14 +222,27 @@ def __repr__(cls: ForwardRef) -> str: # type: ignore[misc]
return cls_repr

# ....................{ PROPERTIES }....................
@property # type: ignore[misc]
@property_cached
def __type_beartype__(cls: ForwardRef) -> type:
@property
def __type_beartype__(cls: ForwardRef) -> type: # type: ignore[misc]
'''
**Forward referee** (i.e., type hint referenced by this forward
reference subclass, which is usually but *not* necessarily a class).
This class property is memoized for efficiency.
This class property is manually memoized for efficiency. However, note
this class property is *not* automatically memoized (e.g., by the
``property_cached`` decorator). Why? Because manual memoization enables
other functionality in the beartype codebase to explicitly unmemoize all
previously memoized forward referees across all forward reference
proxies, effectively forcing all subsequent calls of this property
across all forward reference proxies to reimport their forward referees.
Why is that desirable? Because other functionality in the beartype
codebase detects when the user has manually reloaded user-defined
modules defining user-defined types annotating user-defined callables
previously decorated by the :mod:`beartype.beartype` decorator. Since
reloading those modules redefines those types, all previously cached
types (including those memoized by this property) *must* then be assumed
to be invalid and thus uncached. In short, manual memoization allows
beartype to avoid desynchronization between memoized and actual types.
Raises
------
Expand All @@ -245,43 +257,53 @@ def __type_beartype__(cls: ForwardRef) -> type:
subclass circularly proxies itself.
'''

# Forward referee dynamically imported from this module.
referee = import_module_attr(
attr_name=cls.__name_beartype__,
module_name=cls.__scope_name_beartype__,
exception_cls=BeartypeCallHintForwardRefException,
exception_prefix='Forward reference ',
)

# If this referee is this forward reference subclass, then this subclass
# circularly proxies itself. Since allowing this edge case would openly
# invite infinite recursion, we detect this edge case and instead raise
# a human-readable exception.
if referee is cls:
raise BeartypeCallHintForwardRefException(
f'Forward reference proxy {repr(cls)} '
f'circularly (i.e., infinitely recursively) references itself.'
# If this forward referee has yet to be resolved, this is the first call
# to this property. In this case...
if cls.__type_imported_beartype__ is None: # type: ignore[has-type]
# Forward referee dynamically imported from this module.
referee = import_module_attr(
attr_name=cls.__name_beartype__,
module_name=cls.__scope_name_beartype__,
exception_cls=BeartypeCallHintForwardRefException,
exception_prefix='Forward reference ',
)
# Else, this referee is *NOT* this forward reference subclass.
#
# If this referee is a subscripted generic (e.g., ``MuhGeneric[int]``),
# reduce this referee to the class subscripting this generic (e.g.,
# "int").
elif is_hint_pep484585_generic(referee):
referee = get_hint_pep484585_generic_type(
hint=referee,

# If this referee is this forward reference subclass, then this
# subclass circularly proxies itself. Since allowing this edge case
# would openly invite infinite recursion, we detect this edge case
# and instead raise a human-readable exception.
if referee is cls:
raise BeartypeCallHintForwardRefException(
f'Forward reference proxy {repr(cls)} circularly '
f'(i.e., infinitely recursively) references itself.'
)
# Else, this referee is *NOT* this forward reference subclass.
#
# If this referee is a subscripted generic (e.g.,
# "MuhGeneric[int]"), reduce this referee to the class subscripting
# this generic (e.g., "int").
elif is_hint_pep484585_generic(referee):
referee = get_hint_pep484585_generic_type(
hint=referee,
exception_cls=BeartypeCallHintForwardRefException,
exception_prefix='Forward reference ',
)
# Else, this referee is *NOT* a subscripted generic.

# If this referee is *NOT* an isinstanceable class, raise an
# exception.
die_unless_type_isinstanceable(
cls=referee,
exception_cls=BeartypeCallHintForwardRefException,
exception_prefix='Forward reference ',
)
# Else, this referee is *NOT* a subscripted generic.
# Else, this referee is an isinstanceable class.

# If this referee is *NOT* an isinstanceable class, raise an exception.
die_unless_type_isinstanceable(
cls=referee,
exception_cls=BeartypeCallHintForwardRefException,
exception_prefix='Forward reference ',
)
# Else, this referee is an isinstanceable class.
# Cache this referee for subsequent lookup by this property.
cls.__type_imported_beartype__ = referee
# Else, this referee has already been resolved.
#
# In either case, this referee is now resolved.

# Return this forward referee.
return referee
# Return this previously resolved referee.
return cls.__type_imported_beartype__ # type: ignore[return-value]
63 changes: 54 additions & 9 deletions beartype/_util/module/utilmodimport.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ def import_module_attr(
assert isinstance(exception_prefix, str), (
f'{exception_prefix} not string.')

# Avoid circular import dependencies.
from beartype._util.module.utilmodtest import is_module

# Exception message to be raised.
exception_message = f'{exception_prefix}"{attr_name}" unimportable'

Expand All @@ -263,14 +266,30 @@ def import_module_attr(
'(i.e., contains no "." delimiters).\n'
'* Not the name of a builtin type (e.g., "int", "str").'
)
# Else, a non-empty module name was passed. Append an appropriate
# Else, a non-empty module name was passed.
#
# If this module is importable, append an appropriate substring.
elif is_module(module_name):
exception_message += f' from module "{module_name}".'
# Else, this module is unimportable. Append an appropriate
# substring.
else:
exception_message += f' from module "{module_name}".'
# Else, this attribute name contains one or more "." characters. Append
# an appropriate substring.
exception_message += (
f' from unimportable module "{module_name}".')
# Else, this attribute name contains one or more "." characters. In
# this case...
else:
exception_message += '.'
# Fully-qualified name of the module declaring this attribute.
module_name, _, _ = attr_name.rpartition('.')

# If this module is importable, append an appropriate substring.
if is_module(module_name):
exception_message += '.'
# Else, this module is unimportable. Append an appropriate
# substring.
else:
exception_message += (
f' from unimportable module "{module_name}".')

# Raise this exception.
raise exception_cls(exception_message)
Expand All @@ -280,7 +299,6 @@ def import_module_attr(
return module_attr


#FIXME: Fix up all calls to this function, please.
#FIXME: Fix up all tests of this function, please.
#FIXME: Fix up docstring, please.
def import_module_attr_or_sentinel(
Expand Down Expand Up @@ -364,12 +382,15 @@ def import_module_attr_or_sentinel(
)
# Else, this attribute name is a syntactically valid Python identifier.

# True only if this attribute name contains *NO* "." characters and is thus
# an unqualified basename relative to this module name.
is_attr_name_unqualified = '.' not in attr_name

# Unqualified basename of this attribute, defaulting to this attribute name.
attr_basename = attr_name

# If this attribute name contains *NO* "." characters, this is an
# unqualified basename. In this case...
if '.' not in attr_name:
# If this attribute name is an unqualified basename...
if is_attr_name_unqualified:
# If either no module name was passed *OR* only an empty module name was
# passed...
if not module_name:
Expand Down Expand Up @@ -409,6 +430,30 @@ def import_module_attr_or_sentinel(
# sentinel otherwise.
module_attr = getattr(module, attr_basename, SENTINEL)

# If...
if (
# That module does not declare this attribute *AND*...
module_attr is SENTINEL and
# This attribute name is an unqualified basename...
is_attr_name_unqualified
# Then this attribute was imported relative to this module. In this case,
# this attribute could still be the name of a builtin type.
):
# Builtin type with this name if any *OR* the sentinel otherwise
# (i.e., if *NO* builtin type with this name exists).
#
# Note that this edge case is distinct from the prior edge case and thus
# *MUST* be handled distinctly. Why? Because this module *COULD* have
# globally overridden a builtin type by declaring a global attribute of
# the same name. Although extremely unlikely (and strongly frowned
# upon), Python *DOES* permit insanity like:
# # In some user-defined module at global scope...
# class int(object): ... # <-- by all the gods never do this
module_attr = getattr(TYPES_BUILTIN, attr_basename, SENTINEL)
# Else, either that module declared this attribute *OR* this attribute name
# is fully-qualified and thus not the name of a builtin type. In either
# case, return this attribute as is.

# Return this attribute.
return module_attr

Expand Down

0 comments on commit 79db992

Please sign in to comment.