Skip to content

Commit

Permalink
beartype.claw + typed keyword-only callables.
Browse files Browse the repository at this point in the history
This commit resolves a critical defect in the `beartype.claw` API in
which import hooks published by that API (e.g.,
`beartype_this_package()`) silently failed to type-check edge-case
callables accepting *only* keyword-only parameters and annotated *only*
by type hints on those parameters, resolving issue #345 kindly submitted
by chad `typing` expert @avolchek (Andrei Volchek). This commit also
applies the same consideration to callables accepting *only*
keyword-only parameters and annotated *only* by type hints on those
parameters, which *may* have suffered a similar critical defect. It's
kinda hard to tell. Let us pretend everything works now, everybody. It's
easy. Hypnobear compels you to believe these sweet nothings. (*Fleet bleating bear sheep!*)
  • Loading branch information
leycec committed Mar 26, 2024
1 parent bbf92e8 commit 3926b09
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 137 deletions.
49 changes: 47 additions & 2 deletions beartype/_data/hint/datahinttyping.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@

# ....................{ IMPORTS }....................
import beartype # <-- satisfy mypy [note to self: i can't stand you, mypy]
from ast import AST
from ast import (
AST,
AsyncFunctionDef,
ClassDef,
FunctionDef,
)
from beartype.typing import (
AbstractSet,
Any,
Expand Down Expand Up @@ -48,7 +53,47 @@
)

# ....................{ AST }....................
ListNodes = List[AST]
NodeCallable = Union[FunctionDef, AsyncFunctionDef]
'''
PEP-compliant type hint matching a **callable node** (i.e., abstract syntax tree
(AST) node encapsulating the definition of a pure-Python function or method that
is either synchronous or asynchronous).
'''


NodeDecoratable = Union[NodeCallable, ClassDef]
'''
PEP-compliant type hint matching a **decoratable node** (i.e., abstract syntax
tree (AST) node encapsulating the definition of a pure-Python object supporting
decoration by one or more ``"@"``-prefixed decorations, including both
pure-Python classes *and* callables).
'''


NodeT = TypeVar('NodeT', bound=AST)
'''
**Node type variable** (i.e., type variable constrained to match *only* abstract
syntax tree (AST) nodes).
'''


NodeVisitResult = Optional[Union[AST, List[AST]]]
'''
PEP-compliant type hint matching a **node visitation result** (i.e., object
returned by any visitor method of an :class:`ast.NodeVisitor` subclass).
Specifically, this hint matches either:
* A single node, in which case a visitor method has effectively preserved the
currently visited node passed to that method in the AST.
* A list of zero or more nodes, in which case a visitor method has replaced the
currently visited node passed to that method with those nodes in the AST.
* :data:`None`, in which case a visitor method has effectively destroyed the
currently visited node passed to that method from the AST.
'''


NodesList = List[AST]
'''
PEP-compliant type hint matching an **abstract syntax tree (AST) node list**
(i.e., list of zero or more AST nodes).
Expand Down
63 changes: 62 additions & 1 deletion beartype/_util/ast/utilastget.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@
# ....................{ IMPORTS }....................
from ast import (
AST,
Module,
dump as ast_dump,
parse as ast_parse,
)
from beartype.roar._roarexc import _BeartypeUtilAstException
from beartype._util.py.utilpyversion import IS_PYTHON_AT_LEAST_3_9

# ....................{ GETTERS }....................
# ....................{ GETTERS ~ node }....................
#FIXME: Unit test us up, please.
def get_node_repr_indented(node: AST) -> str:
'''
Expand Down Expand Up @@ -47,3 +50,61 @@ def get_node_repr_indented(node: AST) -> str:
# case, the non-pretty-printed contents of this AST as a single line.
ast_dump(node)
)

# ....................{ GETTERS ~ node }....................
#FIXME: Unit test us up, please. When we do, remove the "pragma: no cover" from
#the body of this getter below.
def get_code_child_node(code: str) -> AST:
'''
Abstract syntax tree (AST) node parsed from the passed (presumably)
triple-quoted string defining a single child object.
This function is principally intended to be called from our test suite as a
convenient means of "parsing" triple-quoted strings into AST nodes.
Caveats
-------
**This function assumes that this string defines only a single child
object.** If this string defines either no *or* two or more child objects,
an exception is raised.
Parameters
----------
code : str
Triple-quoted string defining a single child object.
Returns
-------
AST
AST node encapsulating the object defined by this string.
Raises
-------
_BeartypeUtilAstException
If this string defines either no *or* two or more child objects.
'''
assert isinstance(code, str), f'{repr(code)} not string.'

# "ast.Module" AST tree parsed from this string.
node_module = ast_parse(code)

# If this node is *NOT* actually a module node, raise an exception.
if not isinstance(node_module, Module): # pragma: no cover
raise _BeartypeUtilAstException(
f'{repr(node_module)} not AST module node.')
# Else, this node is a module node.

# List of all direct child nodes of this parent module name.
nodes_child = node_module.body

# If this module node contains either no *OR* two or more child nodes, raise
# an exception.
if len(nodes_child) != 1: # pragma: no cover
raise _BeartypeUtilAstException(
f'Python code {repr(code)} defines '
f'{len(nodes_child)} != 1 child objects.'
)
# Else, this module node contains exactly one child node.

# Return this child node.
return nodes_child[0]
8 changes: 4 additions & 4 deletions beartype/_util/ast/utilastmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
NODE_CONTEXT_LOAD,
NODE_CONTEXT_STORE,
)
from beartype._data.hint.datahinttyping import ListNodes
from beartype._data.hint.datahinttyping import NodesList
from beartype._data.kind.datakindsequence import LIST_EMPTY
from beartype._util.ast.utilastmunge import copy_node_metadata

Expand Down Expand Up @@ -257,7 +257,7 @@ def make_node_call(
node_sibling: AST,

# Optional parameters.
nodes_args: ListNodes = LIST_EMPTY,
nodes_args: NodesList = LIST_EMPTY,
nodes_kwargs: List[keyword] = LIST_EMPTY,
) -> Call:
'''
Expand All @@ -272,11 +272,11 @@ def make_node_call(
Fully-qualified name of the module to import this attribute from.
node_sibling : AST
Sibling node to copy source code metadata from.
nodes_args : ListNodes, optional
nodes_args : NodesList, optional
List of zero or more **positional parameter AST nodes** comprising the
tuple of all positional parameters to be passed to this call. Defaults
to the empty list.
nodes_kwargs : ListNodes, optional
nodes_kwargs : NodesList, optional
List of zero or more **keyword parameter AST nodes** comprising the
dictionary of all keyword parameters to be passed to this call. Defaults
to the empty list.
Expand Down
140 changes: 90 additions & 50 deletions beartype/_util/ast/utilasttest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,9 @@
'''

# ....................{ IMPORTS }....................
from beartype.claw._clawtyping import NodeCallable
# from beartype.typing import (
# List,
# Union,
# )
from beartype._data.hint.datahinttyping import NodeCallable

# ....................{ TESTERS }....................
#FIXME: Unit test us up, please.
def is_node_callable_typed(node: NodeCallable) -> bool:
'''
:data:`True` only if the passed **callable node** (i.e., node signifying the
Expand All @@ -36,27 +31,23 @@ def is_node_callable_typed(node: NodeCallable) -> bool:
:data:`True` only if this callable node is typed.
'''

# True only if the passed callable is untyped (i.e., annotated by *NO* type
# hints), defaulting to whether that callable is annotated by *NO* return
# type hint.
#
# Note that this boolean is intentionally defined in an unintuitive order so
# as to increase the likelihood of efficiently defining this boolean in O(1)
# time. Specifically:
# Note that this algorithm is intentionally implemented in an unintuitive
# order so as to increase the likelihood of efficiently deciding this
# problem in best-case O(1) time. Specifically:
# * It is most efficient to test whether that callable is annotated by a
# return type hint.
# * It is next-most efficient to test whether that callable accepts a
# variadic positional argument annotated by a type hint.
# variadic positional or keyword argument annotated by a type hint.
# * It is least efficient to test whether that callable accepts a
# non-variadic argument annotated by a type hint, as doing so requires
# O(n) iteration for "n" the number of such arguments..
# non-variadic parameter annotated by a type hint, as doing so requires
# O(n) iteration for "n" the number of such arguments.
#
# Lastly, note that we could naively avoid doing this entirely and instead
# unconditionally decorate *ALL* callables by @beartype -- in which case
# @beartype would simply reduce to a noop for untyped callables annotated by
# *NO* type hints. Technically, that works. Pragmatically, that would almost
# certainly be slower than the current approach under the common assumption
# that any developer annotating one or more non-variadic arguments of a
# that any developer annotating one or more non-variadic parameters of a
# callable would also annotate the return of that callable -- in which case
# this detection reduces to O(1) time complexity. Even where this is *NOT*
# the case, however, this is still almost certainly slightly faster or of an
Expand All @@ -66,41 +57,90 @@ def is_node_callable_typed(node: NodeCallable) -> bool:
# "Name" child nodes performing untyped @beartype decorations.
# * Increase time complexity by instantiating, initializing, and inserting
# (the three dread i's) those nodes.
is_untyped = node.returns is None
#
# Admittedly, this approach is *CONSIDERABLY* slower for untyped callables,
# where this detection exhibits worst-case O(n) time complexity. In theory,
# the "beartype.claw" API that calls this tester function once per callable
# should *NEVER* be applied to untyped callables. In practice, that API
# almost certainly will be. We (largely) consider that the responsibility of
# the caller, however. Beartype can't be faulted for optimizing for the
# ideal case of well-typed packages... *CAN IT*!?!? o_O

# If that callable is possibly untyped...
if is_untyped:
# Child arguments node of all arguments accepted by that callable.
node_args = node.args
# If the return of that callable is typed, that callable is typed. In this
# case, immediately and efficiently return true.
if node.returns:
return True
# Else, the return of that callable is untyped.

# Variadic positional argument accepted by that callable if any.
#
# Note that @beartype currently prohibits type hints annotating
# variadic keyword arguments, since there currently appears to be no
# use case encouraging @beartype to support that.
node_arg_varpos = node_args.vararg
# Child arguments node of all arguments accepted by that callable.
node_arg_nodes = node.args

# If either...
#
# Note that PEP 484-compliant typed variadic positional arguments (e.g.,
# "*args: str") are considerably more common than PEP 692-compliant typed
# variadic keyword arguments (e.g., "**kwargs: SomeTypedDict", where
# "SomeTypedDict" is a user-defined "typing.TypedDict" subclass). Ergo, we
# intentionally detect typed variadic positional arguments *BEFORE* typed
# variadic keyword arguments.
if (
(
# That callable accepts a variadic positional argument *AND*...
node_arg_nodes.vararg and
# That parameter is typed...
node_arg_nodes.vararg.annotation
# *OR*...
) or
(
# That callable accepts a variadic keyword argument *AND*...
node_arg_nodes.kwarg and
# That parameter is typed...
node_arg_nodes.kwarg.annotation
)
# That callable is typed. In this case, return true.
):
return True
# Else, that callable is still possibly untyped.

# Fallback to deciding whether that callable accepts one or more typed
# non-variadic parameters. Since doing is considerably more computationally
# expensive, we do so *ONLY* as needed.
#
# Note that manual iteration is considerably more efficient than more
# syntactically concise any() and all() generator expressions.
#
# Specifically, if that callable accepts non-variadic flexible parameters...
if node_arg_nodes.args:
# For each non-variadic flexible parameter...
for node_arg_nonvar in node_arg_nodes.args:
# If this parameter is typed, that callable is typed. In this case,
# return true.
if node_arg_nonvar.annotation:
return True
# Else, this parameter is untyped. Continue to the next.
# Else, that callable is still possibly untyped.

# If that callable accepts a variadic positional argument...
if node_arg_varpos:
# That callable is typed if that argument is annotated by
# a type hint.
is_untyped = node_arg_varpos.annotation is None
# Else, that callable accepts *NO* variadic positional argument.
# If that callable accepts non-variadic keyword-only parameters...
if node_arg_nodes.kwonlyargs:
# For each non-variadic keyword-only parameter...
for node_arg_nonvar in node_arg_nodes.kwonlyargs:
# If this parameter is typed, that callable is typed. In this case,
# return true.
if node_arg_nonvar.annotation:
return True
# Else, this parameter is untyped. Continue to the next.
# Else, that callable is still possibly untyped.

# If that callable is still possibly untyped, fallback to deciding
# whether that callable accepts one or more non-variadic arguments
# annotated by type hints. Since doing is considerably more
# computationally expensive, we do so *ONLY* as needed.
#
# Note that manual iteration is considerably more efficient than more
# syntactically concise any() and all() generator expressions.
if is_untyped:
for node_arg_nonvar in node_args.args:
if node_arg_nonvar.annotation is not None:
is_untyped = False
break
# Else, that callable is now typed.
# Else, that callable is now typed.
# If that callable accepts non-variadic positional-only parameters...
if node_arg_nodes.posonlyargs:
# For each non-variadic positional-only parameter...
for node_arg_nonvar in node_arg_nodes.posonlyargs:
# If this parameter is typed, that callable is typed. In this case,
# return true.
if node_arg_nonvar.annotation:
return True
# Else, this parameter is untyped. Continue to the next.
# Else, that callable is now known to be untyped.

# Return true only if that callable is *NOT* untyped (i.e., is typed).
return not is_untyped
# Return false.
return False
2 changes: 1 addition & 1 deletion beartype/claw/_ast/_clawastutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
BEARTYPE_CLAW_STATE_OBJ_NAME,
BEARTYPE_DECORATOR_FUNC_NAME,
)
from beartype.claw._clawtyping import (
from beartype._data.hint.datahinttyping import (
NodeDecoratable,
)
from beartype._conf.confcls import (
Expand Down
2 changes: 1 addition & 1 deletion beartype/claw/_ast/clawastmain.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
from beartype.claw._ast.pep.clawastpep695 import (
BeartypeNodeTransformerPep695Mixin)
from beartype.claw._ast._clawastutil import BeartypeNodeTransformerUtilityMixin
from beartype.claw._clawtyping import (
from beartype._data.hint.datahinttyping import (
NodeCallable,
NodeT,
)
Expand Down
2 changes: 1 addition & 1 deletion beartype/claw/_ast/pep/clawastpep526.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Name,
)
from beartype.claw._clawmagic import BEARTYPE_RAISER_FUNC_NAME
from beartype.claw._clawtyping import NodeVisitResult
from beartype._data.hint.datahinttyping import NodeVisitResult
from beartype._conf.confcls import BEARTYPE_CONF_DEFAULT
from beartype._util.ast.utilastmake import (
make_node_call_expr,
Expand Down
2 changes: 1 addition & 1 deletion beartype/claw/_ast/pep/clawastpep695.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
)
from beartype.claw._clawmagic import (
BEARTYPE_HINT_PEP695_FORWARDREF_ITER_FUNC_NAME)
from beartype.claw._clawtyping import NodeVisitResult
from beartype._data.hint.datahinttyping import NodeVisitResult
from beartype._data.ast.dataast import NODE_CONTEXT_STORE
from beartype._util.ast.utilastmunge import copy_node_metadata
from beartype._util.ast.utilastmake import (
Expand Down

0 comments on commit 3926b09

Please sign in to comment.