Skip to content

Commit

Permalink
PEP 563 + PEP 673 + dunder methods.
Browse files Browse the repository at this point in the history
This commit resolves a subtle interaction between PEP 563 (i.e.,
`from __future__ import annotations`), PEP 673 (i.e.,
`typing{_extension}.Self`), and standard dunder methods (e.g.,
`__add__()`), partially resolving issue #367 kindly submitted by Dark
Typing Priestess of Darkness @iamrecursion (Ara Adkins). Specifically,
this commit ensures that the type stack encapsulating the current
`@beartype`-decorated class is now preserved throughout the
type-checking process for standard dunder methods annotated by one or
more PEP 673-compliant `typing{_extension}.Self` type hints that are
stringified under PEP 563. For example, @beartype now transparently
supports pernicious edge cases resembling:

```python
from beartype import beartype
from typing_extensions import Self

@beartype
class MyClass:
    attribute: int

    def __init__(self, attr: int) -> None:
        self.attribute = attr

    def __add__(self, other: int) -> Self:
        self.__class__(self.attribute + other)
```

(*Rubicons of iconic rubes!*)
  • Loading branch information
leycec committed Apr 16, 2024
1 parent 1204de4 commit e408dc3
Show file tree
Hide file tree
Showing 8 changed files with 68 additions and 16 deletions.
10 changes: 9 additions & 1 deletion beartype/_cave/_cavefast.py
Expand Up @@ -200,7 +200,15 @@ class by that name. To circumvents this obvious oversight, this global globally
'''


NotImplementedType: type = type(NotImplemented) # type: ignore[misc]
# Define this type as either...
NotImplementedType: type = (
# If the active Python interpreter targets at least Python >= 3.10 and thus
# exposes this type in the standard "types" module, this type;
_types.NotImplementedType # type: ignore[assignment,attr-defined]
if IS_PYTHON_AT_LEAST_3_10 else
# Else, this type manually introspected from this builtin singleton.
type(NotImplemented) # type: ignore[misc]
)
'''
Type of the :data:`NotImplemented` singleton.
'''
Expand Down
2 changes: 1 addition & 1 deletion beartype/_check/checkcache.py
Expand Up @@ -41,7 +41,7 @@ def clear_checker_caches() -> None:
:data:`beartype._check.convert.convcoerce._hint_repr_to_hint`
dictionary).
'''
print('Clearing all \"beartype._check\" caches...')
# print('Clearing all \"beartype._check\" caches...')

# Clear all relevant caches used throughout this subpackage.
_forwardref_to_referee.clear()
Expand Down
10 changes: 5 additions & 5 deletions beartype/_check/convert/convcoerce.py
Expand Up @@ -193,11 +193,11 @@ def coerce_func_hint_root(
# mypy idiosyncrasies merely to annotate an otherwise normal binary
# dunder method is one expectation too far.
#
# Ideally, official CPython developers would resolve this by declaring a
# new "types.NotImplementedType" type global resembling the existing
# "types.NoneType" type global. Since that has yet to happen, mypy has
# instead taken the surprisingly sensible course of silently ignoring
# this edge case by effectively performing the same type expansion as
# In theory, official CPython developers have already resolved this
# under Python >= 3.10 by defining the "types.NotImplementedType" type.
# In practice, that fails to assist older Python versions. Mypy has
# thus taken the surprisingly sensible course of silently ignoring this
# edge case by effectively performing the same type expansion as
# performed here. *applause*
return Union[hint, NotImplementedType] # pyright: ignore[reportGeneralTypeIssues]

Expand Down
10 changes: 7 additions & 3 deletions beartype/_decor/wrap/_wrapargs.py
Expand Up @@ -264,11 +264,15 @@ def code_check_args(bear_call: BeartypeCall) -> str:
# "typing.Self") rather than the new sanitized "hint" (e.g., the
# class currently being decorated by @beartype) is passed to
# that tester. Why? Because the latter may already have been
# reduced above to a different and seemingly innocuous type hint
# that does *NOT* appear to require a type stack but actually
# does. Only the original unsanitized "hint_insane" is truth.
# reduced above to a different (and seemingly innocuous) type
# hint that does *NOT* appear to require a type stack at late
# *EXCEPTION RAISING TIME* (i.e., the
# beartype._check.error.errorget.get_func_pith_violation()
# function) but actually does. Only the original unsanitized
# "hint_insane" is truth.
cls_stack = (
bear_call.cls_stack
# if is_hint_needs_cls_stack(hint) else
if is_hint_needs_cls_stack(hint_insane) else
None
)
Expand Down
3 changes: 2 additions & 1 deletion beartype/_decor/wrap/_wrapreturn.py
Expand Up @@ -121,6 +121,7 @@ def code_check_return(bear_call: BeartypeCall) -> str:
# Do this first *BEFORE* passing this hint to any further callables.
hint = sanify_hint_root_func(
hint=hint, pith_name=ARG_NAME_RETURN, bear_call=bear_call)
# print(f'Sanified {repr(bear_call.func_wrappee)} return hint {repr(hint_insane)} to {repr(hint)}...')

# If this is the PEP 484-compliant "typing.NoReturn" type hint
# permitted *ONLY* as a return annotation...
Expand Down Expand Up @@ -159,7 +160,7 @@ def code_check_return(bear_call: BeartypeCall) -> str:
if is_hint_needs_cls_stack(hint_insane) else
None
)
# print(f'return hint {repr(hint)} cls_stack: {repr(cls_stack)}')
# print(f'return hint {repr(hint_insane)} -> {repr(hint)} cls_stack: {repr(cls_stack)}')

# Empty tuple, passed below to satisfy the
# _unmemoize_func_wrapper_code() API.
Expand Down
2 changes: 1 addition & 1 deletion beartype/_util/hint/pep/proposal/utilpep673.py
Expand Up @@ -41,7 +41,7 @@ def reduce_hint_pep673(
cls_stack : TypeStack, optional
**Type stack** (i.e., either tuple of zero or more arbitrary types *or*
:data:`None`). Defaults to :data:`None`. See also the
:func:`.beartype_object` decorator for further commentary.
:func:`beartype._decor.decormain.beartype_object` decorator.
exception_prefix : str, optional
Human-readable substring prefixing exception messages raised by this
reducer.
Expand Down
7 changes: 6 additions & 1 deletion beartype/_util/hint/utilhinttest.py
Expand Up @@ -326,6 +326,11 @@ class variables or methods annotated by this hint to generate code
# *NOT* a PEP 673-compliant self type hint but erroneously matched as one
# by this heuristic). This is non-ideal but thankfully ignorable. See the
# "Motivation" section of the docstring for further commentary.
# * This string is intentionally *NOT* preceded by a "." delimiter (e.g., as
# ".Self" rather than "Self"). Previously, this string was intentionally
# preceded by a "." delimiter; doing so satisfied most edge cases while
# reducing the likelihood of a false positive. Sadly, doing so also failed
# to match "Self" type hints stringified by PEP 563. Grrr!
# * That the "in" operator is known to be the fastest means of performing
# substring matching in Python. Indeed:
# * "in" is faster than the str.find() method *SUBSTANTIALLY* faster than
Expand All @@ -336,4 +341,4 @@ class variables or methods annotated by this hint to generate code
#
# See also the extensive timings documented at this StackOverflow question:
# https://stackoverflow.com/questions/4901523/whats-a-faster-operation-re-match-search-or-str-find
return '.Self' in hint_repr
return 'Self' in hint_repr
Expand Up @@ -50,6 +50,31 @@ class GoodClassIsGood(object):
Arbitrary class decorated by :func:`.beartype`.
'''

# ...................{ DUNDERS }....................
def __add__(self: Self, other: object) -> 'Self':
'''
Arbitrary dunder method annotated by one or more
:pep:`673`-compliant self type hints such that the return type hint
is the stringified representation of a self type hint.
This edge case effectively exercises the intersection of:
* :pep:`563` (i.e., ``from __future__ import annotations``).
* Mypy-based **implicit dunder return expansion,** an automatic type
hint transformation applied by mypy (and presumably other static
type-checkers) in which mypy expands all type hints annotating the
returns of standardized dunder methods matching the form
``{type}`` to ``typing.Union[{type}, types.NotImplementedType]``.
This edge case exercises this issue:
https://github.com/beartype/beartype/issues/367
'''

# One-liners in the 21st-and-a-half century!
return (
other if isinstance(other, GoodClassIsGood) else NotImplemented)

# ...................{ METHODS }....................
def wonderful_method_is_wonderful(self: Self, count: int) -> List[Self]:
'''
Arbitrary method annotated by one or more :pep:`673`-compliant self
Expand All @@ -67,10 +92,19 @@ def horrible_method_is_horrible(self: Self) -> Self:

return 'Cleave themselves into chasms, while far below'


# Do not taunt Super Happy Fun Instance.
super_happy_fun_instance = GoodClassIsGood()
super_sad_unfun_instance = GoodClassIsGood()

# ....................{ PASS }....................
# Assert that a dunder method of a @beartype-decorated class satisfying a
# PEP 673-compliant self type hint returns the expected object.
assert super_happy_fun_instance + super_sad_unfun_instance is (
super_sad_unfun_instance)
with raises(TypeError):
super_happy_fun_instance + 'Do not taunt Super Happy Fun Instance.'

# Assert that a method of a @beartype-decorated class satisfying a PEP
# 673-compliant self type hint returns the expected object.
avoid_prolonged_exposure = (
Expand All @@ -79,9 +113,9 @@ def horrible_method_is_horrible(self: Self) -> Self:
assert avoid_prolonged_exposure[ 0] is super_happy_fun_instance
assert avoid_prolonged_exposure[-1] is super_happy_fun_instance

# Assert that @beartype raises the expected violation when calling a
# method of a @beartype-decorated class erroneously violating a PEP
# 673-compliant self type hint.
# Assert that @beartype raises the expected violation when calling a method
# of a @beartype-decorated class erroneously violating a PEP 673-compliant
# self type hint.
with raises(BeartypeCallHintReturnViolation):
super_happy_fun_instance.horrible_method_is_horrible()

Expand Down

0 comments on commit e408dc3

Please sign in to comment.