Skip to content

Commit

Permalink
beartype.vale.Is[...] + __call__() x 1.
Browse files Browse the repository at this point in the history
This commit is the first 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), en-route
to 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. (*Pungent puns, gentlemen!*)
  • Loading branch information
leycec committed Apr 12, 2024
1 parent 19c5fe8 commit 769c7cd
Show file tree
Hide file tree
Showing 13 changed files with 635 additions and 253 deletions.
27 changes: 20 additions & 7 deletions beartype/_cave/_cavefast.py
Expand Up @@ -267,7 +267,7 @@ class by that name. To circumvents this obvious oversight, this global globally
See Also
--------
:class:`MethodBoundInstanceOrClassType`
:class:`.MethodBoundInstanceOrClassType`
Type of all pure-Python bound instance and class methods.
'''

Expand All @@ -291,7 +291,7 @@ class by that name. To circumvents this obvious oversight, this global globally
-------
There exists *no* corresponding :class:`MethodUnboundInstanceType` type, as
unbound pure-Python instance methods are ambiguously implemented as functions of
type :class:`FunctionType` indistinguishable from conventional functions.
type :class:`.FunctionType` indistinguishable from conventional functions.
Indeed, `official documentation <PyInstanceMethod_Type documentation_>`__ for
the ``PyInstanceMethod_Type`` C type explicitly admits that:
Expand All @@ -303,6 +303,7 @@ class by that name. To circumvents this obvious oversight, this global globally
'''


#FIXME: Directly alias this to "_types.MethodWrapperType" now, please.
# Although Python >= 3.7 now exposes an explicit method wrapper type via the
# standard "types.MethodWrapperType" object, this is of no benefit to older
# versions of Python. Ergo, the type of an arbitrary method wrapper guaranteed
Expand Down Expand Up @@ -1335,6 +1336,18 @@ class HintPep695Type(object): pass
'''


MethodDescriptorNondataTypes = (
MethodDecoratorClassType,
MethodDecoratorStaticType,
MethodBoundInstanceOrClassType,
)
'''
Tuple of all **builtin method non-data descriptor types** (i.e., C-based
descriptors builtin to Python defining only the ``__get__()`` dunder method,
encapsulating read-only access to some kind of method).
'''


MethodDescriptorTypes = (
# @classmethod, @staticmethod, and @property descriptor types.
MethodDecoratorBuiltinTypes + (
Expand All @@ -1343,16 +1356,16 @@ class HintPep695Type(object): pass
)
)
'''
Tuple of all **C-based unbound method descriptor types** (i.e., builtin types
implemented in low-level C whose instances are typically uncallable, associated
with callable methods implemented in pure Python).
Tuple of all **builtin method descriptor types** (i.e., C-based descriptors
builtin to Python, encapsulating various operations on various kinds of methods
whose instances are typically uncallable).
This tuple matches the types of all:
* **Class method descriptors** (i.e., methods decorated by the builtin
:class:`classmethod` decorator).
* Instance method descriptors (i.e., methods *not* decorated by a builtin method
decorator).
* **Instance method descriptors** (i.e., methods *not* decorated by a builtin
method decorator).
* **Property method descriptors** (i.e., methods decorated by the builtin
:class:`property` decorator).
* **Static method descriptors** (i.e., methods decorated by the builtin
Expand Down
40 changes: 40 additions & 0 deletions beartype/_check/checkmake.py
Expand Up @@ -642,6 +642,46 @@ def _make_func_checker(
hint_refs_type_basename,
) = make_code_check(hint, conf, exception_prefix)

#FIXME: Actually, nothing below is particularly significant. Users
#now basically require this. So, let's find a way to do this. The
#only genuinely significant blocker here from @beartype's
#perspective is *MEMOIZATION.* Currently, the parent factories
#(e.g., make_func_raiser()) transitively calling this factory are
#memoized by @callable_cached. Clearly, memoization breaks down in
#the face of relative forward references... *OR DOES IT!?* We now
#need to probably:
#* Figure out a way of replacing all relative forward references
# with corresponding "ForwardRefRelativeProxy" objects.
#* This is a fundamentally new type of thing we currently do *NOT*
# have. The idea here is that these objects should dynamically
# introspect up the call stack for the first stack frame residing
# in a non-"beartype" module, which these objects then resolve each
# relative forward reference against.
#* Consider refactoring our "codemake" algorthm to unconditionally
# do this for *ALL* relative forward references. Doing so would
# (probably) be a lot faster than the current global string
# replacement approach... maybe. Okay, maybe not. But maybe.
#
#Sounds fun! Sounds like a lot of non-trivial work, too. But that's
#where all the fun resides, doesn't it? *DOESN'T IT!?*
#FIXME: *WAIT.* That doesn't quite work. The issue, of course, that
#the scope in which a callable is called may no longer have access
#to the scope in which a callable was defined, which is where the
#class referred to by relative forward references actually lives.
#So, we absolutely should *NOT* "Consider refactoring our..." No.
#Don't do that. That said, the above idea *SHOULD* still behave
#itself for if_bearable() and die_if_unbearable(), because these
#statement-level type-checkers actually do run in the same scopes
#that their type hints are defined in. Huh. Pretty nifty, eh? This
#then suggests that:
#* We'll need to generalize our "codemake" function to accept a new
# optional "is_refs_relative_proxy: bool = False" parameter. When:
# * "True", code generation replaces all relative forward
# references with corresponding "ForwardRefRelativeProxy" objects
# as detailed above.
# * "False", code generation simply returns relative forward
# references as it currently does.

# If this hint contains one or more relative forward references,
# this hint is non-portable across lexical scopes. In this case,
# raise an exception. Why? Because this hint is relative to and thus
Expand Down
23 changes: 22 additions & 1 deletion beartype/_data/hint/datahinttyping.py
Expand Up @@ -37,7 +37,7 @@
Union,
)
from beartype._cave._cavefast import (
# MethodBoundInstanceOrClassType,
MethodBoundInstanceOrClassType,
MethodDecoratorClassType,
MethodDecoratorPropertyType,
MethodDecoratorStaticType,
Expand Down Expand Up @@ -249,6 +249,27 @@
* Pure-Python callable stack frames.
'''


MethodDescriptorNondata = Union[
# A C-based unbound class method descriptor (i.e., a pure-Python unbound
# function decorated by the builtin @classmethod decorator) *OR*...
MethodDecoratorClassType,

# A C-based unbound static method descriptor (i.e., a pure-Python
# unbound function decorated by the builtin @staticmethod decorator).
MethodDecoratorStaticType,

# A C-based bound method descriptor (i.e., a pure-Python unbound
# function bound to an object instance on Python's instantiation of that
# object) *OR*...
MethodBoundInstanceOrClassType,
]
'''
PEP-compliant type hint matching any **builtin method non-data descriptor**
(i.e., C-based descriptor builtin to Python defining only the ``__get__()``
dunder method, encapsulating read-only access to some kind of method).
'''

# ....................{ CALLABLE ~ args }....................
CallableMethodGetitemArg = Union[int, slice]
'''
Expand Down
41 changes: 28 additions & 13 deletions beartype/_decor/_decornontype.py
Expand Up @@ -41,9 +41,18 @@
from beartype._util.api.utilapifunctools import is_func_functools_lru_cache
from beartype._util.cache.pool.utilcachepoolobjecttyped import (
release_object_typed)
from beartype._util.func.utilfuncget import get_func_boundmethod_self
from beartype._util.func.utilfuncmake import make_func
from beartype._util.func.utilfunctest import is_func_python
from beartype._util.func.utilfuncwrap import unwrap_func_once
from beartype._util.func.utilfunctest import (
is_func_boundmethod,
is_func_python,
)
from beartype._util.func.utilfuncwrap import (
unwrap_func_once,
unwrap_func_boundmethod,
unwrap_func_classmethod,
unwrap_func_staticmethod,
)
from beartype._util.py.utilpyversion import IS_PYTHON_3_8
from contextlib import contextmanager
from functools import lru_cache
Expand Down Expand Up @@ -433,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 = descriptor.__func__ # type: ignore[union-attr]
descriptor_wrappee = unwrap_func_classmethod(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
Expand Down Expand Up @@ -497,8 +506,11 @@ 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]

# Pure-Python unbound function type-checking this static method.
func_checked = beartype_func(descriptor.__func__, **kwargs) # type: ignore[union-attr]
func_checked = beartype_func(descriptor_wrappee, **kwargs) # type: ignore[union-attr]

# Return a new static method descriptor decorating the pure-Python
# unbound function wrapped by this descriptor with type-checking,
Expand All @@ -514,7 +526,7 @@ def beartype_descriptor_decorator_builtin(
)

# ....................{ PRIVATE ~ decorators }....................
def _beartype_descriptor_method_bound(
def _beartype_descriptor_boundmethod(
descriptor: BeartypeableT, **kwargs) -> BeartypeableT:
'''
Decorate the passed **builtin bound method object** (i.e., C-based bound
Expand All @@ -536,11 +548,14 @@ class method defined by the class being instantiated) with dynamically
BeartypeableT
New pure-Python callable wrapping this descriptor with type-checking.
'''
assert isinstance(descriptor, MethodBoundInstanceOrClassType), (
assert is_func_boundmethod(descriptor), (
f'{repr(descriptor)} not builtin bound method descriptor.')

# Pure-Python unbound function encapsulated by this descriptor.
descriptor_func_old = descriptor.__func__
# Possibly C-based callable wrappee object encapsulated by this descriptor.
descriptor_wrappee = unwrap_func_boundmethod(descriptor)

# Instance object to which this descriptor was bound at instantiation time.
descriptor_self = get_func_boundmethod_self(descriptor)

# Pure-Python unbound function decorating the similarly pure-Python unbound
# function encapsulated by this descriptor with type-checking.
Expand All @@ -553,7 +568,7 @@ class method defined by the class being instantiated) with dynamically
# descriptor (created below) encapsulating this wrapper function. Bad!
# Thankfully, only one such attribute exists as of this time: "__doc__".
# We propagate this attribute manually below.
descriptor_func_new = beartype_func(func=descriptor_func_old, **kwargs) # pyright: ignore
func_checked = beartype_func(func=descriptor_wrappee, **kwargs) # pyright: ignore

# New instance method descriptor rebinding this function to the instance of
# the class bound to the prior descriptor.
Expand All @@ -571,7 +586,7 @@ class method defined by the class being instantiated) with dynamically
# That said, there exist *NO* benefits to doing so. Indeed, doing so only
# reduces the legibility and maintainability of this operation.
descriptor_new = MethodBoundInstanceOrClassType(
descriptor_func_new, descriptor.__self__) # type: ignore[return-value]
func_checked, descriptor_self) # type: ignore[return-value]

#FIXME: Actually, Python doesn't appear to support this at the moment.
#Attempting to do so raises this exception:
Expand Down Expand Up @@ -639,7 +654,7 @@ def beartype_pseudofunc(pseudofunc: BeartypeableT, **kwargs) -> BeartypeableT:
f'(i.e., callable object defining __call__() dunder method).'
)
# Else, this object is a pseudo-callable.

#
# If this method is *NOT* pure-Python, this method is C-based. In this
# case...
#
Expand All @@ -648,7 +663,7 @@ def beartype_pseudofunc(pseudofunc: BeartypeableT, **kwargs) -> BeartypeableT:
# does *NOT* apply to C-based pseudo-callables; indeed, there exists *NO*
# general-purpose means of safely monkey-patching the latter. Instead,
# specific instances of the latter *MUST* be manually detected and handled.
if not is_func_python(pseudofunc_call_method):
elif not is_func_python(pseudofunc_call_method):
# If this is a C-based @functools.lru_cache-memoized callable (i.e.,
# low-level C-based callable object both created and returned by the
# standard @functools.lru_cache decorator), @beartype was listed above
Expand Down Expand Up @@ -697,7 +712,7 @@ def beartype_pseudofunc(pseudofunc: BeartypeableT, **kwargs) -> BeartypeableT:
# harmful, @beartype prefers the former. See also official documentation
# on the subject:
# https://docs.python.org/3/reference/datamodel.html#special-method-names
pseudofunc.__class__.__call__ = _beartype_descriptor_method_bound( # type: ignore[assignment,method-assign]
pseudofunc.__class__.__call__ = _beartype_descriptor_boundmethod( # type: ignore[assignment,method-assign]
descriptor=pseudofunc_call_method, **kwargs)

# Return this monkey-patched object.
Expand Down
3 changes: 1 addition & 2 deletions beartype/_util/api/utilapifunctools.py
Expand Up @@ -27,8 +27,7 @@
)

# ....................{ TESTERS }....................
def is_func_functools_lru_cache(func: Any) -> TypeGuard[
CallableFunctoolsLruCacheType]:
def is_func_functools_lru_cache(func: Any) -> TypeGuard[Callable]:
'''
:data:`True` only if the passed object is a
:func:`functools.lru_cache`-memoized **pseudo-callable** (i.e., low-level
Expand Down
84 changes: 74 additions & 10 deletions beartype/_util/func/arg/utilfuncargget.py
Expand Up @@ -22,7 +22,10 @@
ARG_META_INDEX_NAME,
iter_func_args,
)
from beartype._util.func.utilfunccodeobj import get_func_codeobj
from beartype._util.func.utilfunccodeobj import (
get_func_codeobj_or_none,
get_func_codeobj,
)
from collections.abc import Callable

# ....................{ GETTERS ~ arg }....................
Expand Down Expand Up @@ -143,10 +146,21 @@ def get_func_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).
func_codeobj = get_func_codeobj_or_none(func=func, is_unwrap=is_unwrap)

# If that callable has a code object, return the number of flexible
# parameters accepted by this callable exposed by this code object.
if func_codeobj:
return func_codeobj.co_argcount
# Else, that callable has *NO* code object.
#
# If unwrapping that callable *AND* that callable is a partial (i.e.,
# "functools.partial" object wrapping a lower-level callable)...
if is_unwrap and is_func_functools_partial(func):
elif is_unwrap and is_func_functools_partial(func):
# Pure-Python wrappee callable wrapped by that partial.
wrappee = unwrap_func_functools_partial_once(func)

Expand Down Expand Up @@ -198,19 +212,69 @@ def get_func_args_flexible_len(

# Return this number.
return func_args_flexible_len
# Else, that callable is *NOT* a partial. In this case, fallback to the
# standard logic for pure-Python callables.
# 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)

# If that callable fails to define the "__call__" attribute, that callable
# is actually uncallable. But the callable() builtin claimed that callable
# to be callable above. In this case, raise an exception.
#
# Note that this should *NEVER* happen. Nonetheless, this just happened.
if func_call_attr is None: # pragma: no cover
raise exception_cls(
f'{exception_prefix}{repr(func)} uncallable '
f'(i.e., defines no __call__() dunder method).'
)
# Else, that callable defines the __call__() dunder method.

# Code object underlying the passed pure-Python callable unwrapped.
func_codeobj = get_func_codeobj(
func=func,
# Unbound pure-Python __call__() function encapsulated by this C-based bound
# method descriptor bound to this callable object.
func_call = unwrap_func_boundmethod(
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,
)

# Return the number of flexible parameters accepted by this callable.
return func_codeobj.co_argcount
# 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.
Expand Down

0 comments on commit 769c7cd

Please sign in to comment.