Skip to content

Commit

Permalink
@bearytype + __call__() + __wrapped__ x 5.
Browse files Browse the repository at this point in the history
This commit is the next in a commit chain generalizing the `@beartype`
decorator to support **pseudo-callable wrapper objects** (i.e., objects
defining both the `__call__()` and `__wrapped__` dunder attributes),
en-route to resolving feature request #368 kindly submitted by
@danielward27 (Daniel Ward). Specifically, this commit is the first in a
commit subchain generalizing our **argument parser** (i.e., the private
`beartype._util.func.arg.utilfuncargiter.iter_func_args()` generator) to
transparently parse bound method descriptor parameters by omitting the
first mandatory `self` parameter implicitly passed by all bound method
descriptors. Frankly, it's best not to think too hard about any of this.
(*Sunny fauna in a funny sauna!*)
  • Loading branch information
leycec committed May 1, 2024
1 parent 86c4296 commit 0612105
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 88 deletions.
3 changes: 1 addition & 2 deletions beartype/_check/_checksnip.py
Expand Up @@ -16,7 +16,6 @@
from beartype._check.checkmagic import (
ARG_NAME_CHECK_META,
ARG_NAME_CONF,
ARG_NAME_FUNC,
ARG_NAME_EXCEPTION_PREFIX,
ARG_NAME_GET_VIOLATION,
ARG_NAME_HINT,
Expand All @@ -29,7 +28,7 @@
# ....................{ CODE ~ signature }....................
CODE_CHECKER_SIGNATURE = f'''{{code_signature_prefix}}def {{func_name}}(
{VAR_NAME_PITH_ROOT},
{{code_signature_args}}
{{code_signature_scope_args}}
):'''
'''
Code snippet declaring the signature of all type-checking tester functions
Expand Down
9 changes: 8 additions & 1 deletion beartype/_check/signature/_sigsnip.py
Expand Up @@ -18,9 +18,10 @@
VAR_NAME_RANDOM_INT,
)
from beartype._data.code.datacodeindent import CODE_INDENT_1
from collections.abc import Callable

# ....................{ CODE }....................
CODE_SIGNATURE_ARG = (
CODE_SIGNATURE_SCOPE_ARG = (
# Indentation prefixing all wrapper parameters.
f'{CODE_INDENT_1}'
# Default this parameter to the current value of the module-scoped attribute
Expand Down Expand Up @@ -134,3 +135,9 @@
https://eli.thegreenplace.net/2018/slow-and-fast-methods-for-generating-random-integers-in-python
Authoritative article profiling various :mod:`random` callables.
'''

# ..................{ FORMATTERS }..................
# str.format() methods, globalized to avoid inefficient dot lookups elsewhere.
# This is an absurd micro-optimization. *fight me, github developer community*
CODE_SIGNATURE_SCOPE_ARG_format: Callable = (
CODE_SIGNATURE_SCOPE_ARG.format)
26 changes: 9 additions & 17 deletions beartype/_check/signature/sigmake.py
Expand Up @@ -13,12 +13,11 @@
'''

# ....................{ IMPORTS }....................
from beartype.typing import Callable
from beartype._check.checkmagic import (
ARG_NAME_GETRANDBITS,
)
from beartype._check.signature._sigsnip import (
CODE_SIGNATURE_ARG,
CODE_SIGNATURE_SCOPE_ARG_format,
CODE_INIT_RANDOM_INT,
)
from beartype._conf.confcls import BeartypeConf
Expand All @@ -38,17 +37,12 @@ def make_func_signature(

# Optional parameters.
code_signature_prefix: str = '',

# String globals required only for their bound str.format() methods.
CODE_SIGNATURE_ARG_format: Callable = (
CODE_SIGNATURE_ARG.format),
) -> str:
'''
**Type-checking signature factory** (i.e., low-level function dynamically
generating and returning the **signature** (i.e., callable declaration
prefixing the body of that callable) of a callable type-checking arbitrary
objects against arbitrary PEP-compliant type hints to be subsequently
defined, described by the passed parameters.
objects against arbitrary type hints, described by the passed parameters.
Parameters
----------
Expand All @@ -68,15 +62,15 @@ def make_func_signature(
* ``{func_name}``, replaced by the value of the ``func_name`` parameter.
* ``{code_signature_prefix}``, replaced by the value of the
``code_signature_prefix`` parameter.
* ``{code_signature_args}``, replaced by the declaration of all hidden
parameters in the passed ``func_scope`` parameter.
* ``{code_signature_scope_args}``, replaced by the declaration of all
hidden parameters in the passed ``func_scope`` parameter.
conf : BeartypeConf, optional
**Beartype configuration** (i.e., self-caching dataclass encapsulating
all settings configuring type-checking for the passed object).
code_signature_prefix : str, optional
Code snippet prefixing this signature, typically either:
* For synchronous callables, the empty string.
* If a synchronous callables, the empty string.
* For asynchronous callables (e.g., asynchronous generators,
coroutines), the space-suffixed keyword ``"async "``.
Expand All @@ -98,7 +92,7 @@ def make_func_signature(
# Python code snippet declaring all optional private beartype-specific
# parameters directly derived from the local scope established by the above
# calls to the _code_check_args() and _code_check_return() functions.
code_signature_args = ''
code_signature_scope_args = ''

# For the name and value of each such parameter...
for arg_name, arg_value in func_scope.items():
Expand All @@ -116,10 +110,8 @@ def make_func_signature(

# Compose the declaration of this parameter in the signature of this
# wrapper from...
code_signature_args += CODE_SIGNATURE_ARG_format(
arg_name=arg_name,
arg_comment=arg_comment,
)
code_signature_scope_args += CODE_SIGNATURE_SCOPE_ARG_format(
arg_name=arg_name, arg_comment=arg_comment)

#FIXME: *YIKES.* We need to pass a unique tester function signature here
#resembling:
Expand All @@ -133,7 +125,7 @@ def make_func_signature(
code_signature = code_signature_format.format(
func_name=func_name,
code_signature_prefix=code_signature_prefix,
code_signature_args=code_signature_args,
code_signature_scope_args=code_signature_scope_args,
)

# Python code snippet of preliminary statements (e.g., local variable
Expand Down
107 changes: 51 additions & 56 deletions beartype/_decor/_decornontype.py
Expand Up @@ -118,7 +118,7 @@ def beartype_nontype(obj: BeartypeableT, **kwargs) -> BeartypeableT:
# * Under Python >= 3.10:
# * Descriptors created by @classmethod and @property are uncallable.
# * Descriptors created by @staticmethod are technically callable but
# C-based and thus unsuitable for decoration.
# C-based and thus unsuitable for direct decoration.
if obj_type in MethodDecoratorBuiltinTypes:
return beartype_descriptor_decorator_builtin(obj, **kwargs) # type: ignore[return-value]
# Else, this object is *NOT* an uncallable builtin method descriptor.
Expand Down Expand Up @@ -731,9 +731,9 @@ def beartype_pseudofunc(pseudofunc: BeartypeableT, **kwargs) -> BeartypeableT:
return beartype_pseudofunc_functools_lru_cache( # type: ignore
pseudofunc=pseudofunc, **kwargs) # pyright: ignore
# Else, this is *NOT* a C-based @functools.lru_cache-memoized callable.

#
# If...
if (
elif (
# This pseudo-callable object is a wrapper *AND*...
is_func_wrapper(pseudofunc) and
# This unbound __call__() dunder method is *NOT* a wrapper...
Expand All @@ -760,60 +760,55 @@ def beartype_pseudofunc(pseudofunc: BeartypeableT, **kwargs) -> BeartypeableT:
# Else, either this pseudo-callable object is not a wrapper *OR* this
# unbound __call__() dunder method is already a wrapper.

#FIXME: Revise commentary, please. This is no longer remotely true. *sigh*
# Replace the existing bound method descriptor to this __call__() dunder
# method with a new bound method descriptor to a new __call__() dunder
# method wrapping the old method with runtime type-checking.
# Unbound __call__() dunder method runtime type-checking the original bound
# __call__() dunder method of the passed pseudo-callable object.
pseudofunc_call_type_method_checked = beartype_func(
func=pseudofunc_call_boundmethod, **kwargs)
return pseudofunc_call_type_method_checked

#FIXME: *OKAY.* This is a wonderful approach -- but it's still *NOT* quite
#there. Why? Proxying. The only truly safe way of doing what we're currently
#doing is to additionally proxy *ALL* attributes except dunder attributes
#from the original "pseudofunc" object onto a new dynamically constructed
#proxy object. This shouldn't be *TOO* onerous, thankfully. Let's just use
#our existing "BeartypeForwardRefProxy" class (...or whatevahs) as a
#template to copy-paste from, please. Then:
#* Make a new "beartype._decor.pseudofunc" subpackage.
#* Make a new "beartype._decor.pseudofunc._pseudocls" submodule. In this
# submodule:
# * Define a new "BeartypePseudoFuncProxyABC" abstract base class (ABC).
# This ABC should define, in particular:
# * A concrete __getattribute__() method (or whatevahs).
# * An abstract __call__() method, ensuring that subclasses override this
# method with a concrete implementation.
#* Make a new "beartype._decor.pseudofunc.pseudomake" submodule. In this
# submodule:
# * Make a new make_pseudofunc_proxy() factory function resembling:
# def make_pseudofunc_proxy(pseudofunc: Callable) -> BeartypePseudoFuncProxy:
# Just as with our forward reference proxy factory, this factory should
# (in order):
# * Dynamically fabricate a new concrete subclass of the
# "BeartypePseudoFuncProxyABC" superclass.
# * This subclass should set the __call__() method to the passed
# callable. Note that this will require generalizing our usage of the
# beartype_func() function below to generate a wrapper function
# accepting an ignorable first "cls" parameter. Doing so will probably
# necessitate adding yet another optional parameter to beartype_func().
#
# Note that:
# * This is a monkey-patch. Since the caller is intentionally decorating
# this pseudo-callable with @beartype, this is exactly what the caller
# wanted. Probably. Hopefully. Okay! We're crossing our fingers here.
# * This monkey-patches the *CLASS* of this object rather than this object
# itself. Why? Because Python. For unknown reasons (so, speed is what
# we're saying), Python accesses the __call__() dunder method on the
# *CLASS* of an object rather than on the object itself. Of course, this
# implies that *ALL* instances of this pseudo-callable (rather than merely
# the passed instance) will be monkey-patched. This may *NOT* necessarily
# be what the caller wanted. Unfortunately, the only alternative would be
# for @beartype to raise an exception when passed a pseudo-callable. Since
# doing something beneficial is generally preferable to doing something
# harmful, @beartype prefers the former. See also official documentation
# on the subject:
# https://docs.python.org/3/reference/datamodel.html#special-method-names

#FIXME: I must confess that I don't quite get it. If you comment this "if"
#conditional out, the fallback fails to suffice for this case. Why? The
#fallback should suffice. The fallback should *ALWAYS* suffice. Why doesn't
#it? Let's investigate please. Why? Because the fallback would honestly be
#preferable to what we're doing here. Monkey-patching the class is totally
#non-ideal, honestly. Let's avoid that if at all possible, please.
#FIXME: *HMM.* I still don't get it, but I admit now that it doesn't
#particularly matter *WHY* the fallback fails to suffice -- because, in any
#case, the fallback is a *TERRIBLE* idea in general. Why? Because we
#absolutely *DO* want to preserve this pseudo-callable as is. In all
#likelihood, this pseudo-callable object serves a variety of purposes. It
#doesn't exist merely to be called as a callable. There probably exist
#various methods defined on this object that might be called elsewhere.
#Preserving this object is the highest priority. So, this is fine and good.
if is_func_boundmethod(pseudofunc_call_boundmethod):
# print(f'Beartyping pseudo-callable {repr(pseudofunc)} bound method...')
pseudofunc.__class__.__call__ = beartype_func( # type: ignore[method-assign,operator]
func=pseudofunc_call_type_method, **kwargs)
# pseudofunc = beartype_func( # type: ignore[method-assign,operator]
# func=pseudofunc_call_boundmethod, **kwargs)
# pseudofunc.__call__ = _beartype_descriptor_boundmethod( # type: ignore[method-assign,operator]
# descriptor=pseudofunc_call_boundmethod, **kwargs)
return pseudofunc

#FIXME: Comment us up, please. Note that this outlier edge case only applies
#to the extremely small subset of C-based pseudo-callable objects whose
#bound __call__() method is encapsulated by an uncommon C-based method
#wrapper descriptor rather a common C-based bound method descriptor. The
#only example we currently know of are the C-based pseudo-callable objects
#produced by the third-party "@jax.jit" decorator.
pseudofunc = beartype_func(func=pseudofunc_call_boundmethod, **kwargs)
return pseudofunc
# Note that defining a __call__() dunder method on the class rather
# than an instance of this class is unavoidable. Why? Because Python.
# For unknown reasons (so, speed is what we're saying), Python accesses
# the __call__() dunder method on the *CLASS* of an object rather than
# on the object itself. See also official documentation on the subject:
# https://docs.python.org/3/reference/datamodel.html#special-method-names
# * Instantiate and return a new instance of this subclass.

# Create and return a new pseudo-callable proxy (i.e., beartype-specific
# object transparently proxying all attributes of the passed pseudo-callable
# object *EXCEPT* the __call__() dunder method of that object, which this
# proxy implicitly wraps with a new __call__() dunder method runtime
# type-checking the original __call__() dunder method).
# return pseudofunc


def beartype_pseudofunc_functools_lru_cache(
Expand Down
51 changes: 48 additions & 3 deletions beartype/_decor/wrap/_wrapargs.py
Expand Up @@ -49,6 +49,7 @@
ArgMandatory,
iter_func_args,
)
from beartype._util.func.utilfunctest import is_func_boundmethod
from beartype._util.hint.utilhinttest import (
is_hint_ignorable,
is_hint_needs_cls_stack,
Expand Down Expand Up @@ -129,10 +130,38 @@ def code_check_args(decor_meta: BeartypeDecorMeta) -> str:

# ..................{ GENERATE }..................
#FIXME: Locally remove the "arg_index" local variable (and thus avoid
#calling the enumerate() builtin here) AFTER* refactoring @beartype to
#calling the enumerate() builtin here) *AFTER* refactoring @beartype to
#generate callable-specific wrapper signatures.

# For the 0-based index of each parameter accepted by this callable and the
#FIXME: Heh. Technically, this works -- but it's *SUPER*-awkward and not at
#all the correct location for this logic. Push this low-level hackery down
#into the iter_func_args() iterator, please.

# Arbitrary integer offset to be added to the 0-based index of each
# parameter accepted by the callable currently being decorated. Although
# typically zero, this offset is infrequently set to non-zero integers as
# follows:
# * If that callable is a bound method descriptor encapsulating the unbound
# __call__() dunder method of some type, the
# beartype._decor._decornontype.beartype_pseudofunc() decorator function
# is (probably) currently decorating the pseudo-callable object whose type
# is that type. That decorator wraps that bound method with a dynamically
# generated wrapper function that does *NOT* accept a "self" parameter, as
# a bound method is also guaranteed to *NOT* accept a "self" parameter.
# However, the code object of a bound method descriptor is simply an alias
# of the code object of the corresponding unbound method. Since the latter
# accepts a "self" parameter, so too does the former. Thus, there exists
# an (unfortunate) internal discrepancy in Python between:
# * The code object of a bound method descriptor, which declares that
# callable object to accept a "self" parameter.
# * The real-world calling semantics of a bound method descriptor, which
# by definition accepts *NO* "self" parameter.
ARG_INDEX_OFFSET = 0

if is_func_boundmethod(decor_meta.func_wrappee_wrappee):
ARG_INDEX_OFFSET = -1

# For the 0-based index of each parameter accepted by that callable and the
# "ParameterMeta" object describing this parameter (in declaration order)...
for arg_index, arg_meta in enumerate(iter_func_args(
# Possibly lowest-level wrappee underlying the possibly higher-level
Expand All @@ -143,11 +172,27 @@ def code_check_args(decor_meta: BeartypeDecorMeta) -> str:
# useless for our purposes.
func=decor_meta.func_wrappee_wrappee,
func_codeobj=decor_meta.func_wrappee_wrappee_codeobj,

# Avoid inefficiently attempting to re-unwrap this wrappee. The
# previously called BeartypeDecorMeta.reinit() method has already
# guaranteed this wrappee to be isomorphically unwrapped.
is_unwrap=False,
)):
#FIXME: Comment us up, please.
arg_index += ARG_INDEX_OFFSET

#FIXME: Comment us up, please. Note that this is a cute (and therefore
#*REALLY* dumb) optimization equivalent to detecting whether
#this is the first mandatory "self" parameter of a bound method
#descriptor.
if arg_index < 0:
continue

#FIXME: [SPEED] Optimize this by assigning all at once via tuple
#unpacking, please. See "codemake" for similar optimizations.
# Kind and name of this parameter.
arg_kind: ArgKind = arg_meta[ARG_META_INDEX_KIND] # type: ignore[assignment]
arg_name: str = arg_meta[ARG_META_INDEX_NAME] # type: ignore[assignment]
arg_name: str = arg_meta[ARG_META_INDEX_NAME] # type: ignore[assignment]

# Default value of this parameter if this parameter is optional *OR* the
# "ArgMandatory" singleton otherwise (i.e., if this parameter is
Expand Down
11 changes: 8 additions & 3 deletions beartype/_decor/wrap/wrapsnip.py
Expand Up @@ -38,18 +38,23 @@
# ....................{ CODE }....................
CODE_SIGNATURE = f'''{{code_signature_prefix}}def {{func_name}}(
*args,
{{code_signature_args}}{CODE_INDENT_1}**kwargs
{{code_signature_scope_args}}{CODE_INDENT_1}**kwargs
):'''
'''
Code snippet declaring the signature of a type-checking callable.
Note that:
Note that the :func:`beartype._check.signature.make_signature` factory function
internally interpolates these format variables into this string as follows:
* ``code_signature_prefix`` is usually either:
* ``code_signature_prefix`` is replaced by:
* For synchronous callables, the empty string.
* For asynchronous callables (e.g., asynchronous generators, coroutines),
the space-suffixed keyword ``"async "``.
* ``code_signature_scope_args`` is replaced by a comma-delimited string listing
all :mod:`beartype`-specific hidden parameters internally required to
type-check the currently decorated callable.
'''


Expand Down
3 changes: 2 additions & 1 deletion beartype/_util/func/utilfunctest.py
Expand Up @@ -954,7 +954,7 @@ def is_func_wrapper_isomorphic(
# Avoid circular import dependencies.
from beartype._util.func.utilfuncwrap import (
unwrap_func_boundmethod_once)
# print(f'Inspecting f{repr(func)} for isomorphism...')
# print(f'Detecting bound method f{repr(func)} isomorphism...')

# Unwrap this descriptor to the pure-Python callable encapsulated by
# this descriptor.
Expand All @@ -973,6 +973,7 @@ def is_func_wrapper_isomorphic(

# If that callable is C-based...
if not func_codeobj: # pragma: no cover
# print(f'Detecting C-based callable {repr(func)} isomorphism...')
# Return true only if that C-based callable is the __call__() dunder
# method of a pseudo-callable parent object. Although this tester
# *CANNOT* positively decide whether that object is isomorphic or not,
Expand Down

0 comments on commit 0612105

Please sign in to comment.