Skip to content

Commit

Permalink
Out-of-band beartype.peps.resolve_pep563().
Browse files Browse the repository at this point in the history
This commit resolves a critical edge case in our **PEP 563 resolver**
(i.e., the public `beartype.peps.resolve_pep563()` function), resolving
issue #245 kindly submitted by @machow (Michael Chow). Specifically,
this commit relaxes our own self-imposed internal constraints on the
resolution of the local lexical scope of the passed callable. Doing so
enables `resolve_pep563()` to resolve PEP 563-postponed type hints
"out-of-band," by which we mean *after* the declaration of a callable
and thus *after* the local lexical scope of that callable has already
been deleted from the call stack. Likely more importantly, doing so
significantly improves the human-readability of exceptions raised by
`resolve_pep563()` for **missing forward references** (i.e., type hints
referring to undefined types that are *not* actually defined anywhere).
(*Casual causality obliterates the irate literacy in a city!*)
  • Loading branch information
leycec committed Sep 3, 2023
1 parent fa5389a commit 718d1b7
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 76 deletions.
145 changes: 85 additions & 60 deletions beartype/_check/forward/fwdhint.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

# ....................{ IMPORTS }....................
from beartype.roar import BeartypeDecorHintForwardRefException
from beartype.roar._roarexc import _BeartypeUtilCallableScopeNotFoundException
from beartype._check.checkcall import BeartypeCall
from beartype._check.forward.fwdscope import BeartypeForwardScope
from beartype._data.hint.datahinttyping import TypeException
Expand Down Expand Up @@ -250,66 +251,90 @@ def resolve_hint(
# If the decorated callable is nested (rather than global) and thus
# *MAY* have a non-empty local nested scope...
if bear_call.func_wrappee_is_nested:
# Local scope of the decorated callable, localized to improve both
# readability and negligible efficiency when accessed below.
func_locals = get_func_locals(
func=func,

# Ignore all lexical scopes in the fully-qualified name of the
# decorated callable corresponding to parent classes lexically
# nesting the current decorated class containing that callable
# (including that class). Why? Because these classes are *ALL*
# currently being decorated and thus have yet to be encapsulated
# by new stack frames on the call stack. If these lexical scopes
# are *NOT* ignored, this call to get_func_locals() will fail to
# find the parent lexical scope of the decorated callable and
# then raise an unexpected exception.
#
# Consider, for example, this nested class decoration of a
# fully-qualified "muh_package.Outer" class:
# @beartype
# class Outer(object):
# class Middle(object):
# class Inner(object):
# def muh_method(self) -> str:
# return 'Painful API is painful.'
#
# When @beartype finally recurses into decorating the nested
# muh_package.Outer.Middle.Inner.muh_method() method, this call
# to get_func_locals() if *NOT* passed this parameter would
# naively assume that the parent lexical scope of the current
# muh_method() method on the call stack is named "Inner".
# Instead, the parent lexical scope of that method on the call
# stack is named "muh_package" -- the first lexical scope
# enclosing that method that exists on the call stack. The
# non-existent "Outer", "Middle", and "Inner" lexical scopes
# must *ALL* be silently ignored.
func_scope_names_ignore=(
0 if cls_stack is None else len(cls_stack)),

#FIXME: Consider dynamically calculating exactly how many
#additional @beartype-specific frames are ignorable on the first
#call to this function, caching that number, and then reusing
#that cached number on all subsequent calls to this function.
#The current approach employed below of naively hard-coding a
#number of frames to ignore was incredibly fragile and had to be
#effectively disabled, which hampers runtime efficiency.

# Ignore additional frames on the call stack embodying:
# * The current call to this function.
#
# Note that, for safety, we currently avoid ignoring additional
# frames that we could technically ignore. These include:
# * The call to the parent
# beartype._check.checkcall.BeartypeCall.reinit() method.
# * The call to the parent @beartype.beartype() decorator.
#
# Why? Because the @beartype codebase has been sufficiently
# refactored so as to render any such attempts non-trivial,
# fragile, and frankly dangerous.
func_stack_frames_ignore=1,
exception_cls=exception_cls,
)
# Attempt to...
try:
# Local scope of the decorated callable, localized to improve
# readability and negligible efficiency when accessed below.
func_locals = get_func_locals(
func=func,

# Ignore all lexical scopes in the fully-qualified name of
# the decorated callable corresponding to parent classes
# lexically nesting the current decorated class containing
# that callable (including that class). Why? Because these
# classes are *ALL* currently being decorated and thus have
# yet to be encapsulated by new stack frames on the call
# stack. If these lexical scopes are *NOT* ignored, this
# call to get_func_locals() will fail to find the parent
# lexical scope of the decorated callable and then raise an
# unexpected exception.
#
# Consider, for example, this nested class decoration of a
# fully-qualified "muh_package.Outer" class:
# @beartype
# class Outer(object):
# class Middle(object):
# class Inner(object):
# def muh_method(self) -> str:
# return 'Painful API is painful.'
#
# When @beartype finally recurses into decorating the nested
# muh_package.Outer.Middle.Inner.muh_method() method, this
# call to get_func_locals() if *NOT* passed this parameter
# would naively assume that the parent lexical scope of the
# current muh_method() method on the call stack is named
# "Inner". Instead, the parent lexical scope of that method
# on the call stack is named "muh_package" -- the first
# lexical scope enclosing that method that exists on the
# call stack. The non-existent "Outer", "Middle", and
# "Inner" lexical scopes must *ALL* be silently ignored.
func_scope_names_ignore=(
0 if cls_stack is None else len(cls_stack)),

#FIXME: Consider dynamically calculating exactly how many
#additional @beartype-specific frames are ignorable on the first
#call to this function, caching that number, and then reusing
#that cached number on all subsequent calls to this function.
#The current approach employed below of naively hard-coding a
#number of frames to ignore was incredibly fragile and had to be
#effectively disabled, which hampers runtime efficiency.

# Ignore additional frames on the call stack embodying:
# * The current call to this function.
#
# Note that, for safety, we currently avoid ignoring
# additional frames that we could technically ignore. These
# include:
# * The call to the parent
# beartype._check.checkcall.BeartypeCall.reinit() method.
# * The call to the parent @beartype.beartype() decorator.
#
# Why? Because the @beartype codebase has been sufficiently
# refactored so as to render any such attempts non-trivial,
# fragile, and frankly dangerous.
func_stack_frames_ignore=1,
exception_cls=exception_cls,
)
# If this local scope cannot be found (i.e., if this getter found
# the lexical scope of the module declaring the decorated callable
# *before* that of the parent callable or class declaring that
# callable), then this resolve_hint() function was called *AFTER*
# rather than *DURING* the declaration of the decorated callable.
# This implies that that callable is not, in fact, currently being
# decorated. Instead, that callable was *NEVER* decorated by
# @beartype but has instead subsequently been passed to this
# resolve_hint() function after its initial declaration -- typically
# due to an external caller passing that callable to our public
# beartype.peps.resolve_pep563() function.
#
# In this case, the call stack frame providing this local scope has
# (almost certainly) already been deleted and is no longer
# accessible. We have no recourse but to default this local scope to
# the empty dictionary -- which might be subsequently modified and
# *CANNOT* thus default to the singleton empty dictionary
# "DICT_EMPTY" (unlike below).
except _BeartypeUtilCallableScopeNotFoundException:
func_locals = {}

# If the decorated callable is a method transitively defined by a
# root decorated class, add a pair of local attributes exposing:
Expand Down
28 changes: 18 additions & 10 deletions beartype/_util/func/utilfuncscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
'''

# ....................{ IMPORTS }....................
from beartype.roar._roarexc import _BeartypeUtilCallableException
from beartype.roar._roarexc import (
_BeartypeUtilCallableScopeException,
_BeartypeUtilCallableScopeNotFoundException,
)
from beartype.typing import (
Any,
Optional,
Expand All @@ -32,7 +35,7 @@ def get_func_globals(
func: Callable,

# Optional parameters.
exception_cls: TypeException = _BeartypeUtilCallableException,
exception_cls: TypeException = _BeartypeUtilCallableScopeException,
) -> LexicalScope:
'''
**Global scope** (i.e., a dictionary mapping from the name to value of each
Expand All @@ -59,7 +62,7 @@ def get_func_globals(
the passed callable. Defaults to 0.
exception_cls : Type[Exception], optional
Type of exception to be raised in the event of a fatal error. Defaults
to :class:`._BeartypeUtilCallableException`.
to :class:`._BeartypeUtilCallableScopeException`.
Returns
----------
Expand Down Expand Up @@ -102,7 +105,7 @@ def get_func_locals(
# Optional parameters.
func_scope_names_ignore: int = 0,
func_stack_frames_ignore: int = 0,
exception_cls: TypeException = _BeartypeUtilCallableException,
exception_cls: TypeException = _BeartypeUtilCallableScopeException,
) -> LexicalScope:
'''
**Local scope** for the passed callable.
Expand Down Expand Up @@ -181,7 +184,7 @@ def get_func_locals(
the passed callable. Defaults to 0.
exception_cls : Type[Exception], optional
Type of exception to be raised in the event of a fatal error. Defaults
to :class:`._BeartypeUtilCallableException`.
to :class:`._BeartypeUtilCallableScopeException`.
Returns
----------
Expand All @@ -190,9 +193,14 @@ def get_func_locals(
Raises
----------
:exc:`exception_cls`
exception_cls
If the next non-ignored frame following the last ignored frame is *not*
the parent callable or module directly declaring the passed callable.
_BeartypeUtilCallableScopeNotFoundException
If this lexical scope cannot be found (i.e., if this getter found the
lexical scope of the module declaring the passed callable *before* that
of the parent callable or class declaring that callable), enabling
callers to identify this common edge case.
'''
assert callable(func), f'{repr(func)} not callable.'
assert isinstance(func_scope_names_ignore, int), (
Expand Down Expand Up @@ -445,9 +453,9 @@ def get_func_locals(
# Because that scope *MUST* necessarily be in the same module as that
# of this nested callable. In this case, raise an exception.
if func_frame_name == FUNC_CODEOBJ_NAME_MODULE:
raise exception_cls(
raise _BeartypeUtilCallableScopeNotFoundException(
f'{func_name_qualified}() parent lexical scope '
f'{func_scope_name}() not found on call stack.'
f'"{func_scope_name}" not found on call stack.'
)
# Else, that scope is *NOT* a module.
#
Expand Down Expand Up @@ -531,7 +539,7 @@ def add_func_scope_attr(
Raises
----------
:exc:`._BeartypeUtilCallableException`
:exc:`._BeartypeUtilCallableScopeException`
If an attribute with the same name as that internally generated by
this adder but having a different value already exists in this scope.
This adder uniquifies names by object identifier and should thus
Expand Down Expand Up @@ -577,7 +585,7 @@ def add_func_scope_attr(
# If an attribute with the same name but differing value already exists in
# this scope, raise an exception.
if func_scope.get(attr_name, attr) is not attr:
raise _BeartypeUtilCallableException(
raise _BeartypeUtilCallableScopeException(
f'{exception_prefix}"{attr_name}" already exists with '
f'differing value:\n'
f'~~~~[ NEW VALUE ]~~~~\n{repr(attr)}\n'
Expand Down
36 changes: 34 additions & 2 deletions beartype/roar/_roarexc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1253,7 +1253,39 @@ class _BeartypeUtilCallableException(_BeartypeUtilException):
**Beartype callable utility exception.**
This exception is raised by public functions of the private
:mod:`beartype._util.utilfunc` subpackage.
:mod:`beartype._util.func` subpackage.
This exception denotes a critical internal issue and should thus *never* be
raised -- let alone allowed to percolate up the call stack to end users.
'''

pass


class _BeartypeUtilCallableScopeException(_BeartypeUtilCallableException):
'''
**Beartype callable scope utility exception.**
This exception is raised by public functions of the private
:mod:`beartype._util.func.utilfuncscope` submodule.
This exception denotes a critical internal issue and should thus *never* be
raised -- let alone allowed to percolate up the call stack to end users.
'''

pass


class _BeartypeUtilCallableScopeNotFoundException(
_BeartypeUtilCallableException):
'''
**Beartype callable missing scope utility exception.**
This exception is raised by the private
:mod:`beartype._util.func.utilfuncscope.get_func_locals` getter on failing
to find the lexical scope of the parent callable or class declaring the
passed nested callable, enabling callers of that getter to identify this
common edge case.
This exception denotes a critical internal issue and should thus *never* be
raised -- let alone allowed to percolate up the call stack to end users.
Expand All @@ -1267,7 +1299,7 @@ class _BeartypeUtilCallableWrapperException(_BeartypeUtilCallableException):
**Beartype callable wrapper utility exception.**
This exception is raised by public functions of the private
:mod:`beartype._util.utilfunc.utilfuncwrap` subpackage.
:mod:`beartype._util.func.utilfuncwrap` subpackage.
This exception denotes a critical internal issue and should thus *never* be
raised -- let alone allowed to percolate up the call stack to end users.
Expand Down
21 changes: 18 additions & 3 deletions beartype_test/a00_unit/a40_api/peps/test_pep563.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def test_resolve_pep563() -> None:
)
from beartype_test.a00_unit.data.pep.pep563.data_pep563_resolve import (
ToAvariceOrPride,
FrequentWith,
their_starry_domes,
)
from pytest import raises
Expand All @@ -39,20 +40,34 @@ def test_resolve_pep563() -> None:
# Arbitrary instance of the generic accepted by the function called below.
numberless_and_immeasurable_halls = ToAvariceOrPride()

# Arbitrary instance of the class defining various problematic methods
# called below.
and_thrones_radiant_with_chrysolite = FrequentWith()

# .....................{ PASS }....................
# Assert that this function unsuccessfully raises the expected exception
# *BEFORE* resolving all PEP 563-postponed type hints annotating this
# function.
# *BEFORE* resolving all PEP 563-postponed type hints annotating these
# callables.
with raises(BeartypeCallHintForwardRefException):
their_starry_domes(numberless_and_immeasurable_halls)
with raises(BeartypeCallHintForwardRefException):
and_thrones_radiant_with_chrysolite.crystal_column(
'Nor had that scene of ampler majesty')

# Resolve all PEP 563-postponed type hints annotating this function.
# Resolve all PEP 563-postponed type hints annotating these callables.
resolve_pep563(their_starry_domes)
resolve_pep563(FrequentWith.crystal_column)

# Assert that this function successfully accepts and returns this instance.
assert their_starry_domes(numberless_and_immeasurable_halls) is (
numberless_and_immeasurable_halls)

# Assert that this method unsuccessfully raises the excepted exception, due
# to being annotated by a missing forward reference.
with raises(BeartypeCallHintForwardRefException):
and_thrones_radiant_with_chrysolite.crystal_column(
'Than gems or gold, the varying roof of heaven')

# .....................{ FAIL }....................
# Assert that this resolver raises the expected exception when passed an
# uncallable object.
Expand Down

0 comments on commit 718d1b7

Please sign in to comment.