Skip to content

Commit

Permalink
beartype.peps.resolve_pep563() C-based.
Browse files Browse the repository at this point in the history
This commit improves our public `beartype.peps.resolve_pep563()`
function to raise human-readable exceptions when the passed callable is
a C-based decorator object (e.g., `@classmethod`, `@property`,
`@staticmethod`), en-route to resolving feature request #283 kindly
submitted by non-equilibrium quantum master @PhilipVinc (Filippo
Vicentini). Previously, `resolve_pep563()` raised non-human-readable
`AttributeError` exceptions when passed C-based decorator objects.
(*Jangly gangly jongleur!*)
  • Loading branch information
leycec committed Sep 20, 2023
1 parent 4f2da8f commit f705456
Show file tree
Hide file tree
Showing 14 changed files with 280 additions and 97 deletions.
4 changes: 2 additions & 2 deletions beartype/_cave/_cavefast.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@ class by that name. To circumvents this obvious oversight, this global globally
* **Unbound instance methods** (i.e., instance methods accessed on their
declaring classes rather than bound instances).
* **Static methods** (i.e., methods decorated with the builtin
:func:`staticmethod` decorator, regardless of those methods are accessed on
their declaring classes or associated instances).
:func:`staticmethod` decorator, regardless of whether those methods are
accessed on their declaring classes or associated instances).
**This type matches no callables whatsoever under some non-CPython
interpreters,** including:
Expand Down
17 changes: 9 additions & 8 deletions beartype/_check/checkcall.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from beartype._util.cache.pool.utilcachepoolobjecttyped import (
acquire_object_typed)
from beartype._util.func.utilfunccodeobj import get_func_codeobj
from beartype._util.func.utilfuncget import get_func_annotations
from beartype._util.func.utilfunctest import (
is_func_coro,
is_func_nested,
Expand Down Expand Up @@ -416,13 +417,13 @@ def reinit(

# Annotations dictionary *AFTER* resolving all postponed hints.
#
# The functools.update_wrapper() function underlying the @functools.wrap
# decorator underlying all sane decorators propagates this dictionary by
# default from lower-level wrappees to higher-level wrappers. We
# intentionally classify the annotations dictionary of this higher-level
# wrapper, which *SHOULD* be the superset of that of this lower-level
# wrappee (and thus more reflective of reality).
self.func_arg_name_to_hint = func.__annotations__
# Note that the functools.update_wrapper() function underlying the
# @functools.wrap decorator underlying all sane decorators propagates
# this dictionary from lower-level wrappees to higher-level wrappers by
# default. We intentionally classify the annotations dictionary of this
# higher-level wrapper, which *SHOULD* be the superset of that of this
# lower-level wrappee (and thus more reflective of reality).
self.func_arg_name_to_hint = get_func_annotations(func)

# dict.get() method bound to this dictionary.
self.func_arg_name_to_hint_get = self.func_arg_name_to_hint.get
Expand Down Expand Up @@ -495,7 +496,7 @@ def make_beartype_call(
**The caller must pass the metadata returned by this factory back to the**
:func:`beartype._util.cache.pool.utilcachepoolobjecttyped.release_object_typed`
**function.** If accidentally omitted, this metadata will simply be
garbage-collected rather than available for efficient reuse by this factory.
garbage-collected rather than available for efficient reuse by this factory.
Although hardly a worst-case outcome, omitting that explicit call largely
defeats the purpose of calling this factory in the first place.
Expand Down
2 changes: 1 addition & 1 deletion beartype/_check/convert/convsanify.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ class variable or method annotated by this hint *or* :data:`None`).
# and is thus *NOT* performed by the sanify_hint_root_statement() sanitizer.
if arg_name == ARG_NAME_RETURN:
hint = reduce_hint_pep484585_func_return(
func=bear_call.func_wrappee, exception_prefix=EXCEPTION_PLACEHOLDER)
func=bear_call.func_wrappee, exception_prefix=exception_prefix)
# Else, this hint annotates a parameter.

# Reduce this hint to a lower-level PEP-compliant type hint if this hint is
Expand Down
9 changes: 9 additions & 0 deletions beartype/_data/hint/datahinttyping.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@
'''

# ....................{ DICT }....................
HintAnnotations = LexicalScope
'''
PEP-compliant type hint matching **annotations** (i.e., dictionary mapping from
the name of each annotated parameter or return of a callable or annotated
variable of a class to the type hint annotating that parameter, return, or
variable).
'''


MappingStrToAny = Mapping[str, object]
'''
PEP-compliant type hint matching a mapping whose keys are *all* strings.
Expand Down
4 changes: 2 additions & 2 deletions beartype/_decor/error/errormain.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@ class variable or method annotated by this hint *or* :data:`None`).
# If this parameter or return value is unannotated, raise an exception.
#
# Note that this should *NEVER* occur, as the caller guarantees this
# parameter or return value to be annotated. Nonetheless, since callers
# could deface the "__annotations__" dunder dictionary without our
# parameter or return to be annotated. However, since malicious callers
# *COULD* deface the "__annotations__" dunder dictionary without our
# knowledge or permission, precautions are warranted.
if pith_name not in func.__annotations__:
raise _BeartypeCallHintPepRaiseException(
Expand Down
26 changes: 10 additions & 16 deletions beartype/_util/func/mod/utilbeartypefunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
# ....................{ IMPORTS }....................
from beartype._util.func.pep.utilpep484func import (
is_func_pep484_notypechecked)
from beartype._util.func.utilfuncget import get_func_annotations_or_none
from beartype._util.module.lib.utilsphinx import is_sphinx_autodocing
from collections.abc import Callable

# ....................{ TESTERS }....................
#FIXME: Unit test us up, please.
def is_func_unbeartypeable(func: Callable) -> bool:
'''
``True`` only if the passed callable is **unbeartypeable** (i.e., if the
:data:`True` only if the passed callable is **unbeartypeable** (i.e., if the
:func:`beartype.beartype` decorator should preserve that callable as is by
reducing to the identity decorator rather than wrap that callable with
constant-time type-checking).
Expand All @@ -34,25 +35,17 @@ def is_func_unbeartypeable(func: Callable) -> bool:
Returns
----------
bool
``True`` only if that callable is unbeartypeable.
:data:`True` only if that callable is unbeartypeable.
'''

# Return true only if either...
return (
# This callable is unannotated *OR*...
#
# Note that the "__annotations__" dunder attribute is guaranteed to
# exist *ONLY* for standard pure-Python callables. Various other
# callables of interest (e.g., functions exported by the standard
# "operator" module) do *NOT* necessarily declare that attribute. Since
# this tester is commonly called in general-purpose contexts where this
# guarantee does *NOT* necessarily hold, we intentionally access that
# attribute safely albeit somewhat more slowly via getattr().
not getattr(func, '__annotations__', None) or
# This callable is decorated by the @typing.no_type_check decorator
# That callable is unannotated *OR*...
get_func_annotations_or_none(func) is None or
# That callable is decorated by the @typing.no_type_check decorator
# defining this dunder instance variable on this callable *OR*...
is_func_pep484_notypechecked(func) or
# This callable is a @beartype-specific wrapper previously generated by
# That callable is a @beartype-specific wrapper previously generated by
# this decorator *OR*...
is_func_beartyped(func) or
# Sphinx is currently autogenerating documentation (i.e., if this
Expand All @@ -71,7 +64,7 @@ def is_func_unbeartypeable(func: Callable) -> bool:

def is_func_beartyped(func: Callable) -> bool:
'''
``True`` only if the passed callable is a **beartype-generated wrapper
:data:`True` only if the passed callable is a **beartype-generated wrapper
function** (i.e., function dynamically generated by the
:func:`beartype.beartype` decorator for a user-defined callable decorated by
that decorator, wrapping that callable with constant-time type-checking).
Expand All @@ -84,7 +77,8 @@ def is_func_beartyped(func: Callable) -> bool:
Returns
----------
bool
``True`` only if that callable is a beartype-generated wrapper function.
:data:`True` only if that callable is a beartype-generated wrapper
function.
'''

# Return true only if this callable is a @beartype-specific wrapper
Expand Down
39 changes: 20 additions & 19 deletions beartype/_util/func/utilfunccodeobj.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,12 @@ def get_func_codeobj(
Codeobjable to be inspected.
is_unwrap: bool, optional
:data:`True` only if this getter implicitly calls the
:func:`.unwrap_func_all_closures_isomorphic` function to unwrap this possibly higher-level
:func:`.unwrap_func_all` function to unwrap this possibly higher-level
wrapper into a possibly lower-level wrappee *before* returning the code
object of that wrappee. Note that doing so incurs worst-case time
complexity ``O(n)`` for ``n`` the number of lower-level wrappees wrapped
by this wrapper. Defaults to :data:`False` for efficiency.
complexity :math:``O(n)` for :math:`n` the number of lower-level
wrappees wrapped by this wrapper. Defaults to :data:`False` for
efficiency.
exception_cls : TypeException, optional
Type of exception to be raised in the event of a fatal error. Defaults
to :class:`._BeartypeUtilCallableException`.
Expand Down Expand Up @@ -163,25 +164,25 @@ def get_func_codeobj_or_none(
Caveats
-------
If ``is_unwrap``, **this callable has worst-case time complexity** ``O(n)``
**for** ``n`` **the number of lower-level wrappees wrapped by this
higher-level wrapper.** That parameter should thus be disabled in
If ``is_unwrap``, **this callable has worst-case time complexity**
:math:`O(n)` **for** :math:`n` **the number of lower-level wrappees wrapped
by this higher-level wrapper.** That parameter should thus be disabled in
time-critical code paths; instead, the lowest-level wrappee returned by the
:func:``beartype._util.func.utilfuncwrap.unwrap_func_all_closures_isomorphic`
function should be temporarily stored and then repeatedly passed.
:func:``beartype._util.func.utilfuncwrap.unwrap_func_all` function should be
temporarily stored and then repeatedly passed.
Parameters
----------
func : Any
Codeobjable to be inspected.
is_unwrap: bool, optional
:data:`True` only if this getter implicitly calls the
:func:`.unwrap_func_all_closures_isomorphic` function to unwrap this possibly
:func:`.unwrap_func_all` function to unwrap this possibly
higher-level wrapper into a possibly lower-level wrappee *before*
returning the code object of that wrappee. Note that doing so incurs
worst-case time complexity ``O(n)`` for ``n`` the number of lower-level
wrappees wrapped by this wrapper. Defaults to :data:`False` for both
efficiency and disambiguity.
worst-case time complexity :math:`O(n)` for :math:`n` the number of
lower-level wrappees wrapped by this wrapper. Defaults to :data:`False`
for both efficiency and disambiguity.
Returns
----------
Expand Down Expand Up @@ -267,13 +268,13 @@ def get_func_codeobj_or_none(
#
# If this object is a call stack frame, return this frame's code object.
elif isinstance(func, FrameType):
#FIXME: *SUS AF.* This is likely to behave as expected *ONLY* for
#frames encapsulating pure-Python callables. For frames encapsulating
#C-based callables, this is likely to fail with an "AttributeError"
#exception. That said, we have *NO* idea how to test this short of
#defining our own C-based callable accepting a pure-Python callable as
#a callback parameter and calling that callback. Are there even C-based
#callables like that in the wild?
#FIXME: *SUS AF.* This is likely to behave as expected *ONLY* for frames
#encapsulating pure-Python callables. For frames encapsulating C-based
#callables, this is likely to fail with an "AttributeError" exception.
#That said, we have *NO* idea how to test this short of defining our own
#C-based callable accepting a pure-Python callable as a callback
#parameter and calling that callback. Are there even C-based callables
#like that in the wild?
return func.f_code
# Else, this object is *NOT* a call stack frame. Since none of the above
# tests matched, this object *MUST* be a C-based callable.
Expand Down
118 changes: 115 additions & 3 deletions beartype/_util/func/utilfuncget.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,120 @@
from beartype.typing import (
Any,
Callable,
Optional,
)
from beartype._data.hint.datahinttyping import TypeException
from beartype._data.hint.datahinttyping import (
HintAnnotations,
TypeException,
)

# ....................{ GETTERS ~ hints }....................
#FIXME: Refactor all unsafe access of the low-level "__annotations__" dunder
#attribute to instead call this high-level getter, please.
#FIXME: Unit test us up, please.
def get_func_annotations(
# Mandatory parameters.
func: Callable,

# Optional parameters.
exception_cls: TypeException = _BeartypeUtilCallableException,
exception_prefix: str = '',
) -> HintAnnotations:
'''
**Annotations** (i.e., dictionary mapping from the name of each annotated
parameter or return of the passed pure-Python callable to the type hint
annotating that parameter or return) of that callable.
Parameters
----------
func : object
Object to be inspected.
exception_cls : TypeException, optional
Type of exception to be raised in the event of a fatal error. Defaults
to :exc:`._BeartypeUtilCallableException`.
exception_prefix : str, optional
Human-readable label prefixing the representation of this object in the
exception message. Defaults to the empty string.
Returns
----------
HintAnnotations
Annotations of that callable.
Raises
----------
exception_cls
If that callable is *not* actually a pure-Python callable.
See Also
----------
:func:`.get_func_annotations_or_none`
Further details.
'''

# Annotations of that callable if that callable is actually a pure-Python
# callable *OR* "None" otherwise.
hint_annotations = get_func_annotations_or_none(func)

# If that callable is *NOT* pure-Python, raise an exception.
if hint_annotations is None:
assert isinstance(exception_cls, type), (
f'{repr(exception_cls)} not class.')
assert issubclass(exception_cls, Exception), (
f'{repr(exception_cls)} not exception subclass.')
assert isinstance(exception_prefix, str), (
f'{repr(exception_prefix)} not string.')

# If that callable is uncallable, raise an appropriate exception.
if not callable(func):
raise exception_cls(f'{exception_prefix}{repr(func)} not callable.')
# Else, that callable is callable.

# Raise a human-readable exception.
raise exception_cls(
f'{exception_prefix}{repr(func)} not pure-Python function.')
# Else, that callable is pure-Python.

# Return these annotations.
return hint_annotations


#FIXME: Refactor all unsafe access of the low-level "__annotations__" dunder
#attribute to instead call this high-level getter, please.
#FIXME: Unit test us up, please.
def get_func_annotations_or_none(func: Callable) -> Optional[HintAnnotations]:
'''
**Annotations** (i.e., dictionary mapping from the name of each annotated
parameter or return of the passed pure-Python callable to the type hint
annotating that parameter or return) of that callable if that callable is
actually a pure-Python callable *or* :data:`None` otherwise (i.e., if that
callable is *not* a pure-Python callable).
Parameters
----------
func : object
Object to be inspected.
Returns
----------
Optional[HintAnnotations]
Either:
* If that callable is actually a pure-Python callable, the annotations
of that callable.
* Else, :data:`None`.
'''

# Demonstrable monstrosity demons!
#
# Note that the "__annotations__" dunder attribute is guaranteed to exist
# *ONLY* for standard pure-Python callables. Various other callables of
# interest (e.g., functions exported by the standard "operator" module) do
# *NOT* necessarily declare that attribute. Since this tester is commonly
# called in general-purpose contexts where this guarantee does *NOT*
# necessarily hold, we intentionally access that attribute safely albeit
# somewhat more slowly via getattr().
return getattr(func, '__annotations__', None)

# ....................{ GETTERS ~ descriptor }....................
def get_func_classmethod_wrappee(
Expand Down Expand Up @@ -53,7 +165,7 @@ def get_func_classmethod_wrappee(
Raises
----------
:exc:`exception_cls`
exception_cls
If the passed object is *not* a class method descriptor.
See Also
Expand Down Expand Up @@ -111,7 +223,7 @@ def get_func_staticmethod_wrappee(
Raises
----------
:exc:`exception_cls`
exception_cls
If the passed object is *not* a static method descriptor.
See Also
Expand Down

0 comments on commit f705456

Please sign in to comment.