From 75e64bc857d26a2e59e4d4743cedd66d2f68a5d4 Mon Sep 17 00:00:00 2001 From: leycec Date: Sat, 13 Apr 2024 00:56:16 -0400 Subject: [PATCH] `beartype.vale.Is[...]` + `__call__()` x 2. This commit is the last in a commit chain generalizing the `beartype.vale.Is[...]` validator factory to accept **callable objects** (i.e., high-level pure-Python objects whose classes define the `__call__()` dunder method, rendering those objects callable), resolving feature request #360 kindly submitted by "Computer Graphics and Visualization" sufferer @sylvorg, who graciously sacrificed his entire undergraduate GPA for the gradual betterment of @beartype. Your journey of woe and hardship will *not* be forgotten, @sylvorg! Specifically, @beartype now accepts class-based beartype validators resembling: ```python from beartype.door import is_bearable from beartype.typing import Annotated from beartype.vale import Is from functools import partial class TruthSeeker(object): def __call__(self, obj: object) -> bool: ''' Tester method returning :data:`True` only if the passed object evaluates to :data:`True` when coerced into a boolean and whose first parameter is ignorable. ''' return bool(obj) # Beartype validator matching only objects that evaluate to "True". Truthy = Annotated[object, Is[TruthSeeker()]] assert is_bearable('', Truthy) is False assert is_bearable('Even lies are true now, huh?', Truthy) is True ``` Is this valuable? I have no idea. Let's pretend I did something useful tonight so that I can sleep without self-recrimination. (*Painful rain full of insanely manly manes!*) --- .../_check/forward/reference/fwdrefmake.py | 8 +- beartype/_decor/_decornontype.py | 12 +- beartype/_util/api/utilapifunctools.py | 117 +++++++- beartype/_util/func/arg/utilfuncargget.py | 199 +++++++------ beartype/_util/func/utilfuncwrap.py | 272 +++++++++--------- .../a20_util/func/arg/test_utilfuncargget.py | 8 + .../a20_util/func/test_utilfuncwrap.py | 20 +- .../data/hint/pep/proposal/_data_pep593.py | 26 ++ 8 files changed, 413 insertions(+), 249 deletions(-) diff --git a/beartype/_check/forward/reference/fwdrefmake.py b/beartype/_check/forward/reference/fwdrefmake.py index 4bcea427..27ba0146 100644 --- a/beartype/_check/forward/reference/fwdrefmake.py +++ b/beartype/_check/forward/reference/fwdrefmake.py @@ -55,14 +55,14 @@ def make_forwardref_indexable_subtype( scope_name : Optional[str] Possibly ignored lexical scope name. Specifically: - * If "hint_name" is absolute (i.e., contains one or more ``.`` + * If ``hint_name`` is absolute (i.e., contains one or more ``.`` delimiters), this parameter is silently ignored in favour of the - fully-qualified name of the module prefixing "hint_name". - * If "hint_name" is relative (i.e., contains *no* ``.`` delimiters), + fully-qualified name of the module prefixing ``hint_name``. + * If ``hint_name`` is relative (i.e., contains *no* ``.`` delimiters), this parameter declares the absolute (i.e., fully-qualified) name of the lexical scope to which this unresolved type hint is relative. - The fully-qualified name of the module prefixing "hint_name" (if any) + The fully-qualified name of the module prefixing ``hint_name`` (if any) thus *always* takes precedence over this lexical scope name, which only provides a fallback to resolve relative forward references. While unintuitive, this is needed to resolve absolute forward references. diff --git a/beartype/_decor/_decornontype.py b/beartype/_decor/_decornontype.py index a7e49556..f9ec06de 100644 --- a/beartype/_decor/_decornontype.py +++ b/beartype/_decor/_decornontype.py @@ -49,9 +49,9 @@ ) from beartype._util.func.utilfuncwrap import ( unwrap_func_once, - unwrap_func_boundmethod, - unwrap_func_classmethod, - unwrap_func_staticmethod, + unwrap_func_boundmethod_once, + unwrap_func_classmethod_once, + unwrap_func_staticmethod_once, ) from beartype._util.py.utilpyversion import IS_PYTHON_3_8 from contextlib import contextmanager @@ -442,7 +442,7 @@ def beartype_descriptor_decorator_builtin( # Ergo, the name "__func__" of this dunder attribute is disingenuous. # This descriptor does *NOT* merely decorate functions; this descriptor # permissively decorates all callable objects. - descriptor_wrappee = unwrap_func_classmethod(descriptor) # type: ignore[arg-type] + descriptor_wrappee = unwrap_func_classmethod_once(descriptor) # type: ignore[arg-type] # If this wrappee is *NOT* a pure-Python unbound function, this wrappee # is C-based and/or a type. In either case, avoid type-checking this @@ -507,7 +507,7 @@ def beartype_descriptor_decorator_builtin( # If this descriptor is a static method... elif descriptor_type is MethodDecoratorStaticType: # Possibly C-based callable wrappee object decorated by this descriptor. - descriptor_wrappee = unwrap_func_staticmethod(descriptor) # type: ignore[arg-type] + descriptor_wrappee = unwrap_func_staticmethod_once(descriptor) # type: ignore[arg-type] # Pure-Python unbound function type-checking this static method. func_checked = beartype_func(descriptor_wrappee, **kwargs) # type: ignore[union-attr] @@ -552,7 +552,7 @@ class method defined by the class being instantiated) with dynamically f'{repr(descriptor)} not builtin bound method descriptor.') # Possibly C-based callable wrappee object encapsulated by this descriptor. - descriptor_wrappee = unwrap_func_boundmethod(descriptor) + descriptor_wrappee = unwrap_func_boundmethod_once(descriptor) # Instance object to which this descriptor was bound at instantiation time. descriptor_self = get_func_boundmethod_self(descriptor) diff --git a/beartype/_util/api/utilapifunctools.py b/beartype/_util/api/utilapifunctools.py index 6c343a6f..a75c8231 100644 --- a/beartype/_util/api/utilapifunctools.py +++ b/beartype/_util/api/utilapifunctools.py @@ -11,6 +11,7 @@ ''' # ....................{ IMPORTS }.................... +from beartype.roar._roarexc import _BeartypeUtilCallableException from beartype.typing import ( Any, Tuple, @@ -20,11 +21,11 @@ CallableFunctoolsPartialType, ) from beartype._data.hint.datahintfactory import TypeGuard -from beartype._data.hint.datahinttyping import DictStrToAny -from collections.abc import ( - Callable, - # Generator, +from beartype._data.hint.datahinttyping import ( + DictStrToAny, + TypeException, ) +from collections.abc import Callable # ....................{ TESTERS }.................... def is_func_functools_lru_cache(func: Any) -> TypeGuard[Callable]: @@ -115,6 +116,114 @@ def get_func_functools_partial_args( # which this partial was originally partialized. return (func.args, func.keywords) + +def get_func_functools_partial_args_flexible_len( + # Mandatory parameters. + func: CallableFunctoolsPartialType, + + # Optional parameters. + is_unwrap: bool = True, + exception_cls: TypeException = _BeartypeUtilCallableException, + exception_prefix: str = '', +) -> int: + ''' + Number of **flexible parameters** (i.e., parameters passable as either + positional or keyword arguments but *not* positional-only, keyword-only, + variadic, or other more constrained kinds of parameters) accepted by the + passed **partial** (i.e., pure-Python callable :class:`functools.partial` + object directly wrapping this possibly C-based callable). + + Specifically, this getter transparently returns the total number of flexible + parameters accepted by the lower-level callable wrapped by this partial + minus the number of flexible parameters partialized away by this partial. + + Parameters + ---------- + func : CallableFunctoolsPartialType + Partial to be inspected. + is_unwrap: bool, optional + :data:`True` only if this getter implicitly calls the + :func:`beartype._util.func.utilfuncwrap.unwrap_func_all` function. + Defaults to :data:`True` for safety. See :func:`.get_func_codeobj` for + further commentary. + exception_cls : type, optional + Type of exception to be raised in the event of a fatal error. Defaults + to :class:`._BeartypeUtilCallableException`. + exception_prefix : str, optional + Human-readable label prefixing the message of any exception raised in + the event of a fatal error. Defaults to the empty string. + + Returns + ------- + int + Number of flexible parameters accepted by this callable. + + Raises + ------ + exception_cls + If that callable is *not* pure-Python. + ''' + assert isinstance(func, CallableFunctoolsPartialType), ( + f'{repr(func)} not "function.partial"-wrapped callable.') + + # Avoid circular import dependencies. + from beartype._util.func.arg.utilfuncargget import ( + get_func_args_flexible_len) + + # Pure-Python wrappee callable wrapped by that partial. + wrappee = unwrap_func_functools_partial_once(func) + + # Positional and keyword parameters implicitly passed by this partial to + # this wrappee. + partial_args, partial_kwargs = get_func_functools_partial_args(func) + + # Number of flexible parameters accepted by this wrappee. + # + # Note that this recursive function call is guaranteed to immediately bottom + # out and thus be safe. Why? Because a partial *CANNOT* wrap itself, because + # a partial has yet to be defined when the functools.partial.__init__() + # method defining that partial is called. Technically, the caller *COULD* + # violate sanity by directly interfering with the "func" instance variable + # of this partial after instantiation. Pragmatically, a malicious edge case + # like that is unlikely in the extreme. You are now reading this comment + # because this edge case just blew up in your face, aren't you!?!? *UGH!* + wrappee_args_flexible_len = get_func_args_flexible_len( + func=wrappee, + is_unwrap=is_unwrap, + exception_cls=exception_cls, + exception_prefix=exception_prefix, + ) + + # Number of flexible parameters passed by this partial to this wrappee. + partial_args_flexible_len = len(partial_args) + len(partial_kwargs) + + # Number of flexible parameters accepted by this wrappee minus the number of + # flexible parameters passed by this partial to this wrappee. + func_args_flexible_len = ( + wrappee_args_flexible_len - partial_args_flexible_len) + + # If this number is negative, the caller maliciously defined an invalid + # partial passing more flexible parameters than this wrappee accepts. In + # this case, raise an exception. + # + # Note that the "functools.partial" factory erroneously allows callers to + # define invalid partials passing more flexible parameters than their + # wrappees accept. Ergo, validation is required to guarantee sanity. + if func_args_flexible_len < 0: + raise exception_cls( + f'{exception_prefix}{repr(func)} passes ' + f'{partial_args_flexible_len} parameter(s) to ' + f'{repr(wrappee)} accepting only ' + f'{wrappee_args_flexible_len} parameter(s) ' + f'(i.e., {partial_args_flexible_len} > ' + f'{wrappee_args_flexible_len}).' + ) + # Else, this number is non-negative. The caller correctly defined a valid + # partial passing no more flexible parameters than this wrappee accepts. + + # Return this number. + return func_args_flexible_len + # ....................{ UNWRAPPERS }.................... def unwrap_func_functools_partial_once( func: CallableFunctoolsPartialType) -> Callable: diff --git a/beartype/_util/func/arg/utilfuncargget.py b/beartype/_util/func/arg/utilfuncargget.py index 0ccd805c..9598655b 100644 --- a/beartype/_util/func/arg/utilfuncargget.py +++ b/beartype/_util/func/arg/utilfuncargget.py @@ -14,6 +14,7 @@ # ....................{ IMPORTS }.................... from beartype.roar._roarexc import _BeartypeUtilCallableException from beartype.typing import Optional +from beartype._cave._cavefast import MethodBoundInstanceOrClassType from beartype._data.hint.datahinttyping import ( Codeobjable, TypeException, @@ -142,11 +143,9 @@ def get_func_args_flexible_len( # Avoid circular import dependencies. from beartype._util.api.utilapifunctools import ( - get_func_functools_partial_args, + get_func_functools_partial_args_flexible_len, is_func_functools_partial, - unwrap_func_functools_partial_once, ) - from beartype._util.func.utilfuncwrap import unwrap_func_boundmethod # Code object underlying the passed pure-Python callable unwrapped if any # *OR* "None" otherwise (i.e., that callable has *NO* code object). @@ -157,72 +156,30 @@ def get_func_args_flexible_len( if func_codeobj: return func_codeobj.co_argcount # Else, that callable has *NO* code object. - # + + # If that callable is *NOT* actually callable, raise an exception. + if not callable(func): + raise exception_cls(f'{exception_prefix}{repr(func)} uncallable.') + # Else, that callable is callable. + # If unwrapping that callable *AND* that callable is a partial (i.e., - # "functools.partial" object wrapping a lower-level callable)... - elif is_unwrap and is_func_functools_partial(func): - # Pure-Python wrappee callable wrapped by that partial. - wrappee = unwrap_func_functools_partial_once(func) - - # Positional and keyword parameters implicitly passed by this partial to - # this wrappee. - partial_args, partial_kwargs = get_func_functools_partial_args(func) - - # Number of flexible parameters accepted by this wrappee. - # - # Note that this recursive function call is guaranteed to immediately - # bottom out and thus be safe. Why? Because a partial *CANNOT* wrap - # itself, because a partial has yet to be defined when the - # functools.partial.__init__() method defining that partial is called. - # Technically, the caller *COULD* violate sanity by directly interfering - # with the "func" instance variable of this partial after instantiation. - # Pragmatically, a malicious edge case like that is unlikely in the - # extreme. You are now reading this comment because this edge case just - # blew up in your face, aren't you!?!? *UGH!* - wrappee_args_flexible_len = get_func_args_flexible_len( - func=wrappee, + # "functools.partial" object wrapping a lower-level callable), return the + # total number of flexible parameters accepted by the pure-Python wrappee + # callable wrapped by this partial minus the number of flexible parameters + # passed by this partial to this wrappee. + if is_unwrap and is_func_functools_partial(func): + return get_func_functools_partial_args_flexible_len( + func=func, is_unwrap=is_unwrap, exception_cls=exception_cls, exception_prefix=exception_prefix, ) - - # Number of flexible parameters passed by this partial to this wrappee. - partial_args_flexible_len = len(partial_args) + len(partial_kwargs) - - # Number of flexible parameters accepted by this wrappee minus the - # number of flexible parameters passed by this partial to this wrappee. - func_args_flexible_len = ( - wrappee_args_flexible_len - partial_args_flexible_len) - - # If this number is negative, the caller maliciously defined an invalid - # partial passing more flexible parameters than this wrappee accepts. In - # this case, raise an exception. - if func_args_flexible_len < 0: - raise exception_cls( - f'{exception_prefix}{repr(func)} passes ' - f'{partial_args_flexible_len} parameter(s) to ' - f'{repr(wrappee)} accepting only ' - f'{wrappee_args_flexible_len} parameter(s) ' - f'(i.e., {partial_args_flexible_len} > ' - f'{wrappee_args_flexible_len}).' - ) - # If this number is non-negative, implying the caller correctly defined - # a valid partial passing no more flexible parameters than this wrappee - # accepts. - - # Return this number. - return func_args_flexible_len # Else, that callable is *NOT* a partial. # # By process of elimination, that callable *MUST* be an otherwise uncallable # object whose class has intentionally made that object callable by defining # the __call__() dunder method. Fallback to introspecting that method. - # If that callable is *NOT* actually callable, raise an exception. - if not callable(func): - raise exception_cls(f'{exception_prefix}{repr(func)} uncallable.') - # Else, that callable is callable. - # "__call__" attribute of that callable if any *OR* "None" otherwise (i.e., # if that callable is actually uncallable). func_call_attr = getattr(func, '__call__', None) @@ -239,43 +196,17 @@ def get_func_args_flexible_len( ) # Else, that callable defines the __call__() dunder method. - # Unbound pure-Python __call__() function encapsulated by this C-based bound - # method descriptor bound to this callable object. - func_call = unwrap_func_boundmethod( + # Return the total number of flexible parameters accepted by the pure-Python + # wrappee callable wrapped by this bound method descriptor minus one to + # account for the first "self" parameter implicitly + # passed by this descriptor to that callable. + return _get_func_boundmethod_args_flexible_len( func=func_call_attr, - exception_cls=exception_cls, - exception_prefix=exception_prefix, - ) - - # Number of flexible parameters accepted by this __call__() function. - # - # Note that this recursive function call is guaranteed to immediately bottom - # out and thus be safe for similar reasons as given above. - func_call_args_flexible_len = get_func_args_flexible_len( - func=func_call, is_unwrap=is_unwrap, exception_cls=exception_cls, exception_prefix=exception_prefix, ) - # If this number is zero, the caller maliciously defined an invalid - # __call__() dunder method accepting *NO* parameters. Since this - # paradoxically includes the mandatory first "self" parameter for a bound - # method descriptor, it is probably infeasible for this edge case to occur. - # Nonetheless, raise an exception. - if not func_call_args_flexible_len: # pragma: no cover - raise exception_cls( - f'{exception_prefix}{repr(func_call_attr)} accepts no ' - f'parameters despite being a bound instance method descriptor.' - ) - # Else, this number is positive. - - # Return this number minus one to account for the fact that this bound - # method descriptor implicitly passes the instance object to which this - # method descriptor is bound as the first parameter to all calls of this - # method descriptor. - return func_call_args_flexible_len - 1 - #FIXME: Unit test us up, please. def get_func_args_nonvariadic_len( @@ -324,3 +255,93 @@ def get_func_args_nonvariadic_len( # Return the number of non-variadic parameters accepted by this callable. return func_codeobj.co_argcount + func_codeobj.co_kwonlyargcount + +# ....................{ PRIVATE ~ getters : args }.................... +def _get_func_boundmethod_args_flexible_len( + # Mandatory parameters. + func: MethodBoundInstanceOrClassType, + + # Optional parameters. + is_unwrap: bool = True, + exception_cls: TypeException = _BeartypeUtilCallableException, + exception_prefix: str = '', +) -> int: + ''' + Number of **flexible parameters** (i.e., parameters passable as either + positional or keyword arguments but *not* positional-only, keyword-only, + variadic, or other more constrained kinds of parameters) accepted by the + passed **C-based bound instance method descriptor** (i.e., callable + implicitly instantiated and assigned on the instantiation of an object whose + class declares an instance function (whose first parameter is typically + named ``self``)). + + Specifically, this getter transparently returns one less than the total + number of flexible parameters accepted by the lower-level callable wrapped + by this descriptor to account for the first ``self`` parameter implicitly + passed by this descriptor to that callable. + + Parameters + ---------- + func : MethodBoundInstanceOrClassType + Bound method descriptor to be inspected. + is_unwrap: bool, optional + :data:`True` only if this getter implicitly calls the + :func:`beartype._util.func.utilfuncwrap.unwrap_func_all` function. + Defaults to :data:`True` for safety. See :func:`.get_func_codeobj` for + further commentary. + exception_cls : type, optional + Type of exception to be raised in the event of a fatal error. Defaults + to :class:`._BeartypeUtilCallableException`. + exception_prefix : str, optional + Human-readable label prefixing the message of any exception raised in + the event of a fatal error. Defaults to the empty string. + + Returns + ------- + int + Number of flexible parameters accepted by this callable. + + Raises + ------ + exception_cls + If that callable is *not* pure-Python. + ''' + + # Avoid circular import dependencies. + from beartype._util.func.utilfuncwrap import unwrap_func_boundmethod_once + + # Unbound pure-Python function encapsulated by this C-based bound method + # descriptor bound to some callable object. + wrappee = unwrap_func_boundmethod_once( + func=func, + exception_cls=exception_cls, + exception_prefix=exception_prefix, + ) + + # Number of flexible parameters accepted by that function. + # + # Note that this recursive function call is guaranteed to immediately bottom + # out and thus be safe for similar reasons as given above. + wrappee_args_flexible_len = get_func_args_flexible_len( + func=wrappee, + is_unwrap=is_unwrap, + exception_cls=exception_cls, + exception_prefix=exception_prefix, + ) + + # If this number is zero, the caller maliciously defined a non-static + # function accepting *NO* parameters. Since this paradoxically includes the + # mandatory first "self" parameter for a bound method descriptor, it is + # infeasible for this edge case to occur. Nonetheless, raise an exception. + if not wrappee_args_flexible_len: # pragma: no cover + raise exception_cls( + f'{exception_prefix}{repr(func)} accepts no ' + f'parameters despite being a bound instance method descriptor.' + ) + # Else, this number is positive. + + # Return this number minus one to account for the fact that this bound + # method descriptor implicitly passes the instance object to which this + # method descriptor is bound as the first parameter to all calls of this + # method descriptor. + return wrappee_args_flexible_len - 1 diff --git a/beartype/_util/func/utilfuncwrap.py b/beartype/_util/func/utilfuncwrap.py index 8e3c8678..554a939e 100644 --- a/beartype/_util/func/utilfuncwrap.py +++ b/beartype/_util/func/utilfuncwrap.py @@ -17,7 +17,7 @@ from beartype._data.hint.datahinttyping import TypeException from collections.abc import Callable -# ....................{ UNWRAPPERS }.................... +# ....................{ UNWRAPPERS ~ once }.................... #FIXME: Unit test us up, please. def unwrap_func_once(func: Any) -> Callable: ''' @@ -66,140 +66,9 @@ def unwrap_func_once(func: Any) -> Callable: # Return this immediate wrappee callable. return func_wrappee -# ....................{ UNWRAPPERS ~ all }.................... -def unwrap_func_all(func: Any) -> Callable: - ''' - Lowest-level **wrappee** (i.e., callable wrapped by the passed wrapper - callable) of the passed higher-level **wrapper** (i.e., callable wrapping - the wrappee callable to be returned) if the passed callable is a wrapper - *or* that callable as is otherwise (i.e., if that callable is *not* a - wrapper). - - Specifically, this getter iteratively undoes the work performed by: - - * One or more consecutive uses of the :func:`functools.wrap` decorator on - the wrappee callable to be returned. - * One or more consecutive calls to the :func:`functools.update_wrapper` - function on the wrappee callable to be returned. - - Parameters - ---------- - func : Callable - Wrapper callable to be unwrapped. - - Returns - ------- - Callable - Either: - - * If the passed callable is a wrapper, the lowest-level wrappee - callable wrapped by that wrapper. - * Else, the passed callable as is. - ''' - - #FIXME: Not even this suffices to avoid a circular import, sadly. *sigh* - # Avoid circular import dependencies. - # from beartype._util.func.utilfunctest import is_func_wrapper - - # While this callable still wraps another callable, unwrap one layer of - # wrapping by reducing this wrapper to its next wrappee. - while hasattr(func, '__wrapped__'): - # while is_func_wrapper(func): - func = func.__wrapped__ # type: ignore[attr-defined] - - # Return this wrappee, which is now guaranteed to *NOT* be a wrapper. - return func - - -#FIXME: Unit test us up, please. -def unwrap_func_all_isomorphic(func: Any) -> Callable: - ''' - Lowest-level **non-isomorphic wrappee** (i.e., callable wrapped by the - passed wrapper callable) of the passed higher-level **isomorphic wrapper** - (i.e., closure wrapping the wrappee callable to be returned by accepting - both a variadic positional and keyword argument and thus preserving both the - positions and types of all parameters originally passed to that wrappee) if - the passed callable is an isomorphic wrapper *or* that callable as is - otherwise (i.e., if that callable is *not* an isomorphic wrapper). - - Specifically, this getter iteratively undoes the work performed by: - - * One or more consecutive decorations of the :func:`functools.wrap` - decorator on the wrappee callable to be returned. - * One or more consecutive calls to the :func:`functools.update_wrapper` - function on the wrappee callable to be returned. - - Parameters - ---------- - func : Callable - Wrapper callable to be unwrapped. - - Returns - ------- - Callable - Either: - - * If the passed callable is an isomorphic wrapper, the lowest-level - non-isomorphic wrappee callable wrapped by that wrapper. - * Else, the passed callable as is. - ''' - - # Avoid circular import dependencies. - from beartype._util.func.utilfunctest import ( - is_func_python, - is_func_wrapper_isomorphic, - ) - - # While that callable is a higher-level isomorphic wrapper wrapping a - # lower-level callable... - while is_func_wrapper_isomorphic(func): - # Undo one layer of wrapping by reducing the former to the latter. - # print(f'Unwrapping isomorphic closure wrapper {func} to wrappee {func.__wrapped__}...') - func_wrapped = func.__wrapped__ # type: ignore[attr-defined] - - # If the lower-level object wrapped by this higher-level isomorphic - # wrapper is *NOT* a pure-Python callable, this object is something - # uselessly pathological like a class or C-based callable. Silently - # ignore this useless object by halting iteration. Doing so preserves - # this useful higher-level isomorphic wrapper as is. - # - # Note that this insane edge case arises due to the standard - # @functools.wraps() decorator, which passively accepts possibly C-based - # classes by wrapping those classes with pure-Python functions: e.g., - # from beartype import beartype - # from functools import wraps - # from typing import Any - # - # @beartype - # @wraps(list) - # def wrapper(*args: Any, **kwargs: Any): - # return list(*args, **kwargs) - # - # In the above example, the higher-level isomorphic wrapper wrapper() - # wraps the lower-level C-based class "list". - # - # Unwrapping this wrapper to this class would induce insanity throughout - # the codebase, which sanely expects wrappers to be callables rather - # than classes. Clearly, classes have *NO* signatures. Technically, a - # pure-Python class may define __new__() and/or __init__() dunder - # methods that could be considered to be the signatures of those - # classes. Nonetheless, C-based classes like "list" have *NO* such - # analogues. The *ONLY* sane approach here is to pretend that we never - # saw this pathological edge case. - if not is_func_python(func_wrapped): - break - # Else, this lower-level callable is pure-Python. - - # Reduce this higher-level wrapper to this lower-level wrappee. - func = func_wrapped - - # Return this wrappee, which is now guaranteed to *NOT* be an isomorphic - # wrapper but might very well still be a wrapper, which is fine. - return func - -# ....................{ UNWRAPPERS ~ descriptor }.................... +# ....................{ UNWRAPPERS ~ once : descriptor }.................... #FIXME: Unit test us up, please. -def unwrap_func_boundmethod( +def unwrap_func_boundmethod_once( # Mandatory parameters. func: MethodBoundInstanceOrClassType, @@ -257,7 +126,7 @@ def unwrap_func_boundmethod( return func.__func__ -def unwrap_func_classmethod( +def unwrap_func_classmethod_once( # Mandatory parameters. func: classmethod, @@ -315,7 +184,7 @@ def unwrap_func_classmethod( return func.__func__ -def unwrap_func_staticmethod( +def unwrap_func_staticmethod_once( # Mandatory parameters. func: staticmethod, @@ -371,3 +240,134 @@ def unwrap_func_staticmethod( # Return the pure-Python function wrapped by this descriptor. Just do it! return func.__func__ + +# ....................{ UNWRAPPERS ~ all }.................... +def unwrap_func_all(func: Any) -> Callable: + ''' + Lowest-level **wrappee** (i.e., callable wrapped by the passed wrapper + callable) of the passed higher-level **wrapper** (i.e., callable wrapping + the wrappee callable to be returned) if the passed callable is a wrapper + *or* that callable as is otherwise (i.e., if that callable is *not* a + wrapper). + + Specifically, this getter iteratively undoes the work performed by: + + * One or more consecutive uses of the :func:`functools.wrap` decorator on + the wrappee callable to be returned. + * One or more consecutive calls to the :func:`functools.update_wrapper` + function on the wrappee callable to be returned. + + Parameters + ---------- + func : Callable + Wrapper callable to be unwrapped. + + Returns + ------- + Callable + Either: + + * If the passed callable is a wrapper, the lowest-level wrappee + callable wrapped by that wrapper. + * Else, the passed callable as is. + ''' + + #FIXME: Not even this suffices to avoid a circular import, sadly. *sigh* + # Avoid circular import dependencies. + # from beartype._util.func.utilfunctest import is_func_wrapper + + # While this callable still wraps another callable, unwrap one layer of + # wrapping by reducing this wrapper to its next wrappee. + while hasattr(func, '__wrapped__'): + # while is_func_wrapper(func): + func = func.__wrapped__ # type: ignore[attr-defined] + + # Return this wrappee, which is now guaranteed to *NOT* be a wrapper. + return func + + +#FIXME: Unit test us up, please. +def unwrap_func_all_isomorphic(func: Any) -> Callable: + ''' + Lowest-level **non-isomorphic wrappee** (i.e., callable wrapped by the + passed wrapper callable) of the passed higher-level **isomorphic wrapper** + (i.e., closure wrapping the wrappee callable to be returned by accepting + both a variadic positional and keyword argument and thus preserving both the + positions and types of all parameters originally passed to that wrappee) if + the passed callable is an isomorphic wrapper *or* that callable as is + otherwise (i.e., if that callable is *not* an isomorphic wrapper). + + Specifically, this getter iteratively undoes the work performed by: + + * One or more consecutive decorations of the :func:`functools.wrap` + decorator on the wrappee callable to be returned. + * One or more consecutive calls to the :func:`functools.update_wrapper` + function on the wrappee callable to be returned. + + Parameters + ---------- + func : Callable + Wrapper callable to be unwrapped. + + Returns + ------- + Callable + Either: + + * If the passed callable is an isomorphic wrapper, the lowest-level + non-isomorphic wrappee callable wrapped by that wrapper. + * Else, the passed callable as is. + ''' + + # Avoid circular import dependencies. + from beartype._util.func.utilfunctest import ( + is_func_python, + is_func_wrapper_isomorphic, + ) + + # While that callable is a higher-level isomorphic wrapper wrapping a + # lower-level callable... + while is_func_wrapper_isomorphic(func): + # Undo one layer of wrapping by reducing the former to the latter. + # print(f'Unwrapping isomorphic closure wrapper {func} to wrappee {func.__wrapped__}...') + func_wrapped = func.__wrapped__ # type: ignore[attr-defined] + + # If the lower-level object wrapped by this higher-level isomorphic + # wrapper is *NOT* a pure-Python callable, this object is something + # uselessly pathological like a class or C-based callable. Silently + # ignore this useless object by halting iteration. Doing so preserves + # this useful higher-level isomorphic wrapper as is. + # + # Note that this insane edge case arises due to the standard + # @functools.wraps() decorator, which passively accepts possibly C-based + # classes by wrapping those classes with pure-Python functions: e.g., + # from beartype import beartype + # from functools import wraps + # from typing import Any + # + # @beartype + # @wraps(list) + # def wrapper(*args: Any, **kwargs: Any): + # return list(*args, **kwargs) + # + # In the above example, the higher-level isomorphic wrapper wrapper() + # wraps the lower-level C-based class "list". + # + # Unwrapping this wrapper to this class would induce insanity throughout + # the codebase, which sanely expects wrappers to be callables rather + # than classes. Clearly, classes have *NO* signatures. Technically, a + # pure-Python class may define __new__() and/or __init__() dunder + # methods that could be considered to be the signatures of those + # classes. Nonetheless, C-based classes like "list" have *NO* such + # analogues. The *ONLY* sane approach here is to pretend that we never + # saw this pathological edge case. + if not is_func_python(func_wrapped): + break + # Else, this lower-level callable is pure-Python. + + # Reduce this higher-level wrapper to this lower-level wrappee. + func = func_wrapped + + # Return this wrappee, which is now guaranteed to *NOT* be an isomorphic + # wrapper but might very well still be a wrapper, which is fine. + return func diff --git a/beartype_test/a00_unit/a20_util/func/arg/test_utilfuncargget.py b/beartype_test/a00_unit/a20_util/func/arg/test_utilfuncargget.py index 0dbcfbc6..7cd402e2 100644 --- a/beartype_test/a00_unit/a20_util/func/arg/test_utilfuncargget.py +++ b/beartype_test/a00_unit/a20_util/func/arg/test_utilfuncargget.py @@ -68,6 +68,7 @@ def test_get_func_args_len_flexible() -> None: from beartype._util.func.arg.utilfuncargget import ( get_func_args_flexible_len) from beartype_test.a00_unit.data.data_type import ( + CallableClass, function_partial, function_partial_bad, ) @@ -83,6 +84,10 @@ def test_get_func_args_len_flexible() -> None: ) from pytest import raises + # ....................{ LOCALS }.................... + # Callable object whose class defines the __call__() dunder method. + callable_object = CallableClass() + # ....................{ PASS }.................... # Assert this getter returns the expected lengths of unwrapped callables. assert get_func_args_flexible_len(func_args_0) == 0 @@ -99,6 +104,9 @@ def test_get_func_args_len_flexible() -> None: # Assert this getter returns the expected length of a partial callable. assert get_func_args_flexible_len(function_partial) == 0 + # Assert this getter returns the expected length of a callable object. + assert get_func_args_flexible_len(callable_object) == 0 + # Assert this getter returns 0 when passed a wrapped callable and an option # disabling callable unwrapping. assert get_func_args_flexible_len( diff --git a/beartype_test/a00_unit/a20_util/func/test_utilfuncwrap.py b/beartype_test/a00_unit/a20_util/func/test_utilfuncwrap.py index cbc6438a..9e03adf1 100644 --- a/beartype_test/a00_unit/a20_util/func/test_utilfuncwrap.py +++ b/beartype_test/a00_unit/a20_util/func/test_utilfuncwrap.py @@ -59,17 +59,17 @@ def in_a_station_of_the_metro(): in_a_station_of_the_metro_line_two) # ....................{ TESTS ~ descriptor }.................... -def test_unwrap_func_classmethod() -> None: +def test_unwrap_func_classmethod_once() -> None: ''' Test the - :func:`beartype._util.func.utilfuncwrap.unwrap_func_classmethod` + :func:`beartype._util.func.utilfuncwrap.unwrap_func_classmethod_once` getter. ''' # ....................{ IMPORTS }.................... # Defer test-specific imports. from beartype.roar._roarexc import _BeartypeUtilCallableWrapperException - from beartype._util.func.utilfuncwrap import unwrap_func_classmethod + from beartype._util.func.utilfuncwrap import unwrap_func_classmethod_once from beartype_test.a00_unit.data.data_type import CALLABLES from pytest import raises @@ -100,7 +100,7 @@ class TheLimitsOfTheDeadAndLivingWorld(object): # "beartype.cave.MethodBoundInstanceOrClassType" type. class_method = TheLimitsOfTheDeadAndLivingWorld.__dict__[ 'of_insects_beasts_and_birds'] - class_method_wrappee = unwrap_func_classmethod(class_method) + class_method_wrappee = unwrap_func_classmethod_once(class_method) assert class_method_wrappee is never_to_be_reclaimed # ....................{ FAIL }.................... @@ -108,20 +108,20 @@ class TheLimitsOfTheDeadAndLivingWorld(object): # that is *NOT* a class method descriptor. for some_callable in CALLABLES: with raises(_BeartypeUtilCallableWrapperException): - unwrap_func_classmethod(some_callable) + unwrap_func_classmethod_once(some_callable) -def test_unwrap_func_staticmethod() -> None: +def test_unwrap_func_staticmethod_once() -> None: ''' Test the - :func:`beartype._util.func.utilfuncwrap.unwrap_func_staticmethod` + :func:`beartype._util.func.utilfuncwrap.unwrap_func_staticmethod_once` getter. ''' # ....................{ IMPORTS }.................... # Defer test-specific imports. from beartype.roar._roarexc import _BeartypeUtilCallableWrapperException - from beartype._util.func.utilfuncwrap import unwrap_func_staticmethod + from beartype._util.func.utilfuncwrap import unwrap_func_staticmethod_once from beartype_test.a00_unit.data.data_type import CALLABLES from pytest import raises @@ -151,7 +151,7 @@ class TheirFoodAndTheirRetreatForEverGone(object): # "beartype.cave.MethodBoundInstanceOrClassType" type. static_method = TheirFoodAndTheirRetreatForEverGone.__dict__[ 'so_much_of_life_and_joy_is_lost'] - static_method_wrappee = unwrap_func_staticmethod(static_method) + static_method_wrappee = unwrap_func_staticmethod_once(static_method) assert static_method_wrappee is becomes_its_spoil # ....................{ FAIL }.................... @@ -159,4 +159,4 @@ class TheirFoodAndTheirRetreatForEverGone(object): # that is *NOT* a static method descriptor. for some_callable in CALLABLES: with raises(_BeartypeUtilCallableWrapperException): - unwrap_func_staticmethod(some_callable) + unwrap_func_staticmethod_once(some_callable) 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 426dce34..14a9f474 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 @@ -49,6 +49,17 @@ def hints_pep593_meta() -> 'List[HintPepMetadata]': ) from functools import partial + # ..................{ CLASSES }.................. + class TruthSeeker(object): + def __call__(self, obj: object) -> bool: + ''' + Tester method returning :data:`True` only if the passed object + evaluates to :data:`True` when coerced into a boolean and whose + first parameter is ignorable. + ''' + + return bool(obj) + # ..................{ CALLABLES }.................. def is_true(ignorable_arg, obj): ''' @@ -203,6 +214,21 @@ def __init__(self) -> None: ), ), + # Annotated of the root "object" superclass annotated by a beartype + # validator defined as a callable object. + HintPepMetadata( + hint=Annotated[object, Is[TruthSeeker()]], + pep_sign=HintSignAnnotated, + piths_meta=( + # Objects evaluating to "True" when coerced into booleans. + HintPithSatisfiedMetadata(True), + HintPithSatisfiedMetadata( + 'Leaped in the boat, he spread his cloak aloft'), + # Empty string. + HintPithUnsatisfiedMetadata(''), + ), + ), + # Annotated of the root "object" superclass annotated by a beartype # validator defined as a partial function. HintPepMetadata(