Skip to content

Commit

Permalink
PEP 563 + PEP 604 + Python <= 3.9 = exception.
Browse files Browse the repository at this point in the history
This commit improves the `@beartype` decorator to raise a human-readable
`beartype.roar.BeartypeDecorHintPep604Exception` on detecting a
discrepancy between PEP 563 (i.e., `from __future__ import annotations`)
and PEP 604 (i.e., new-style unions like `int | str`) under Python <=
3.9, which supports PEP 563 but fails to support PEP 604,
kinda-but-not-really resolving unlucky issue #300 kindly submitted by
the explosive supernova that suspiciously resembles the Eye of Sauron
@tvdboom (Mavs). For example, `@beartype` now raises exceptions
resembling the following when detecting attempts to enable both PEP 563
*and* PEP 604 under Python <= 3.19:

```bash
$ python3.9
>>> from __future__ import annotations
>>> from beartype import beartype
>>> @beartype
... def ohgods(OHGODS: str | bytes) -> str | bytes:
...     return OHGODS
beartype.roar.BeartypeDecorHintPep604Exception: Function
__main__.ohgods() parameter "OHGODS" stringified PEP 604 type hint 'str
| bytes' syntactically invalid under Python < 3.10 (i.e.,
TypeError("unsupported operand type(s) for |: 'type' and 'type'")).
Consider either:
* Requiring Python >= 3.10. Abandon Python < 3.10 all ye who code here.
* Refactoring PEP 604 type hints into equivalent PEP 484 type hints:
  e.g.,
    # Instead of this...
    from __future__ import annotations
    def bad_func() -> int | str: ...

    # Do this. Ugly, yet it works. Worky >>>> pretty.
    from typing import Union
    def bad_func() -> Union[int, str]: ...
```

(*Lumbering plumbing of bars in lower lumbars!*)
  • Loading branch information
leycec committed Nov 9, 2023
1 parent 744182f commit 6e64bdd
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 34 deletions.
123 changes: 106 additions & 17 deletions beartype/_check/forward/fwdhint.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@
'''

# ....................{ IMPORTS }....................
from beartype.roar import BeartypeDecorHintForwardRefException
from __future__ import annotations
from beartype.roar import (
BeartypeDecorHintForwardRefException,
BeartypeDecorHintPep604Exception,
)
from beartype.roar._roarexc import _BeartypeUtilCallableScopeNotFoundException
from beartype.typing import Optional
from beartype._check.checkcall import BeartypeCall
from beartype._check.forward.fwdscope import BeartypeForwardScope
from beartype._data.hint.datahinttyping import TypeException
Expand All @@ -27,6 +32,7 @@
get_func_locals,
)
from beartype._util.module.utilmodget import get_object_module_name
from beartype._util.py.utilpyversion import IS_PYTHON_AT_MOST_3_9
from builtins import __dict__ as func_builtins # type: ignore[attr-defined]

# ....................{ RESOLVERS }....................
Expand Down Expand Up @@ -488,23 +494,106 @@ def resolve_hint(
assert isinstance(exception_prefix, str), (
f'{repr(exception_prefix)} not string.')

# Human-readable message to be raised.
exception_message = (
f'{exception_prefix}stringified type hint '
f'{repr(hint)} syntactically invalid '
f'(i.e., {repr(exception)}).'
)

# If the beartype configuration associated with the decorated callable
# enabled debugging, append debug-specific metadata to this message.
if bear_call.conf.is_debug:
exception_message += (
f' Composite global and local scope enclosing this hint:\n\n'
f'{repr(bear_call.func_wrappee_scope_forward)}'
# Human-readable message to be raised if this message has been defined
# *OR* "None" otherwise (i.e., if this message has yet to be defined).
exception_message: Optional[str] = None

# If the following conditions all hold:
# * The active Python interpreter targets Python < 3.10 *AND*...
# * The external module defining this stringified type hint was prefixed
# by the "from __future__ import annotations" pragma enabling PEP 563
# *AND*...
# * This hint contains one or more PEP 604-compliant new unions (e.g.,
# "int | str")...
#
# ...then this interpreter fails to syntactically support this hint at
# runtime (because only Python >= 3.10 supports PEP 604) but nonetheless
# superficially appears to do so under PEP 563 by simply stringifying
# this otherwise unsupported hint into a string. Indeed, PEP 563
# superficially appears to support a countably infinite set of
# syntactically and semantically invalid type hints -- including but
# certainly not limited to PEP 604 under Python < 3.10: e.g.,
# from __future__ import annotations # <-- enable PEP 563
# def bad() -> int | str: # <-- invalid under Python < 3.10, but
# pass # silently ignored by PEP 563
# def BAD() -> int ** str: # <-- invalid under all Python versions,
# pass # but silently ignored by PEP 563
#
# Clearly, exponentiating one type by another is both syntactically and
# semantically invalid -- but PEP 563 blindly accepts and stringifies
# that invalid type hint into the string "int ** str". This is nonsense.
#
# This branch detects this discrepancy between PEP 563 and 604 and, when
# detected, raises a human-readable exception advising the caller with
# recommendations of how to resolve this. Although we could also simply
# do nothing, doing nothing results in non-human-readable exceptions
# resembling the following, which only generates confusion: e.g.,
# $ python3.9
# >>> int | str
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# TypeError: unsupported operand type(s) for |: 'type' and 'type'
#
# Specifically, if...
if (
# The active Python interpreter targets Python <= 3.9 *AND*...
IS_PYTHON_AT_MOST_3_9 and
# Evaluating this stringified type hint raised a "TypeError"...
isinstance(exception, TypeError)
):
# If the exception message raised by this "TypeError" is prefixed by
# a well-known substring implying this exception to have been
# produced by a discrepancy between PEP 563 and 604...
if str(exception).startswith(
'unsupported operand type(s) for |: '):
# PEP 604-specific exception type, forcefully overriding the
# passed exception type (for disambiguity).
exception_cls = BeartypeDecorHintPep604Exception

# Human-readable message providing various recommendations.
exception_message = (
f'{exception_prefix}stringified PEP 604 type hint '
f'{repr(hint)} syntactically invalid under Python < 3.10 '
f'(i.e., {repr(exception)}). Consider either:\n'
f'* Requiring Python >= 3.10. Abandon Python < 3.10 all '
f'ye who code here.\n'
f'* Refactoring PEP 604 type hints into '
f'equivalent PEP 484 type hints: e.g.,\n'
f' # Instead of this...\n'
f' from __future__ import annotations\n'
f' def bad_func() -> int | str: ...\n'
f'\n'
f' # Do this. Ugly, yet it works. Worky >>>> pretty.\n'
f' from typing import Union\n'
f' def bad_func() -> Union[int, str]: ...'
)
# Else, this another kind of "TypeError" entirely. In this case,
# defer to the default message defined below.
# Else, either the active Python interpreter targets Python >= 3.10 *OR*
# another type of exception was raised. In either case, defer to the
# default message defined below.

# If a human-readable message has yet to be defined, fallback to a
# default message generically applicable to *ALL* stringified hints.
if exception_message is None:
# Human-readable message to be raised.
exception_message = (
f'{exception_prefix}stringified type hint '
f'{repr(hint)} syntactically invalid '
f'(i.e., {repr(exception)}).'
)
# Else, the beartype configuration associated with the decorated
# callable disabled debugging. In this case, avoid appending
# debug-specific metadata to this message.

# If the beartype configuration associated with the decorated callable
# enabled debugging, append debug-specific metadata to this message.
if bear_call.conf.is_debug:
exception_message += (
f' Composite global and local scope enclosing this hint:\n\n'
f'{repr(bear_call.func_wrappee_scope_forward)}'
)
# Else, the beartype configuration associated with the decorated
# callable disabled debugging. In this case, avoid appending
# debug-specific metadata to this message.
# Else, a human-readable message has already been defined.

# Raise a human-readable exception wrapping the typically
# non-human-readable exception raised above.
Expand Down
3 changes: 2 additions & 1 deletion beartype/peps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@
# than merely "from argparse import ArgumentParser").
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
from beartype.peps._pep563 import (
resolve_pep563 as resolve_pep563)
resolve_pep563 as resolve_pep563,
)
8 changes: 4 additions & 4 deletions beartype/peps/_pep563.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
# See "LICENSE" for further details.

'''
Beartype :pep:`563` support (i.e., public callables resolving stringified
:pep:`563`-compliant type hints implicitly postponed by the active Python
interpreter via a ``from __future__ import annotations`` statement at the head
of the external user-defined module currently being introspected).
Beartype :pep:`563` **resolvers** (i.e., public high-level callables resolving
stringified :pep:`563`-compliant type hints implicitly postponed by the active
Python interpreter via a ``from __future__ import annotations`` statement at the
head of the external user-defined module currently being introspected).
This private submodule is *not* intended for importation by downstream callers.
'''
Expand Down
1 change: 1 addition & 0 deletions beartype/roar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
BeartypeDecorHintPep586Exception as BeartypeDecorHintPep586Exception,
BeartypeDecorHintPep591Exception as BeartypeDecorHintPep591Exception,
BeartypeDecorHintPep593Exception as BeartypeDecorHintPep593Exception,
BeartypeDecorHintPep604Exception as BeartypeDecorHintPep604Exception,
BeartypeDecorHintPep647Exception as BeartypeDecorHintPep647Exception,
BeartypeDecorHintPep673Exception as BeartypeDecorHintPep673Exception,
BeartypeDecorHintPep3119Exception as BeartypeDecorHintPep3119Exception,
Expand Down
12 changes: 12 additions & 0 deletions beartype/roar/_roarexc.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,18 @@ class BeartypeDecorHintPep593Exception(BeartypeDecorHintPepException):
pass


class BeartypeDecorHintPep604Exception(BeartypeDecorHintPepException):
'''
**Beartype decorator** :pep:`604`-compliant **type hint exception.**
This exception is raised at decoration time from the
:func:`beartype.beartype` decorator on receiving a callable annotated with
one or more PEP-compliant type hints either violating :pep:`604` *or* this
decorator's implementation of :pep:`604`.
'''

pass

class BeartypeDecorHintPep647Exception(BeartypeDecorHintPepException):
'''
**Beartype decorator** :pep:`647`-compliant **type hint exception.**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,19 +153,26 @@ def test_pep563_module() -> None:
:func:`beartype.beartype` decorator.
'''

# ....................{ IMPORTS }....................
# Defer test-specific imports.
from beartype import beartype
from beartype.roar import BeartypeDecorHintPep604Exception
from beartype._util.py.utilpyversion import IS_PYTHON_AT_LEAST_3_10
from beartype_test.a00_unit.data.pep.pep563.data_pep563_poem import (
get_minecraft_end_txt,
get_minecraft_end_txt_pep604,
get_minecraft_end_txt_stanza,
)
from pytest import raises

# ....................{ LOCALS }....................
# Dictionary of these callables' annotations, localized to enable debugging
# in the likely event of unit test failure. *sigh*
GET_MINECRAFT_END_TXT_ANNOTATIONS = get_minecraft_end_txt.__annotations__
GET_MINECRAFT_END_TXT_STANZA_ANNOTATIONS = (
get_minecraft_end_txt_stanza.__annotations__)

# ....................{ ASSERTS }....................
# Assert that all annotations of a callable *NOT* decorated by @beartype
# are postponed under PEP 563 as expected.
assert all(
Expand Down Expand Up @@ -203,6 +210,23 @@ def test_pep563_module() -> None:
# Assert that this callable works under PEP 563.
assert isinstance(get_minecraft_end_txt_typed(player_name='Notch'), str)

# ....................{ ASSERTS ~ pep 604 }....................
# If the active Python interpreter targets Python >= 3.10 and thus supports
# PEP 604...
if IS_PYTHON_AT_LEAST_3_10:
# Manually decorate a PEP 604-compliant callable with @beartype.
get_minecraft_end_txt_typed = beartype(get_minecraft_end_txt_pep604)

# Assert that this callable works under PEP 563.
assert isinstance(get_minecraft_end_txt_typed(player_name='Notch'), str)
# Else, the active Python interpreter targets Python < 3.10 and thus fails
# to support PEP 604. In this case..
else:
# Assert that attempting to manually decorate a PEP 604-compliant
# callable with @beartype raises the expected exception.
with raises(BeartypeDecorHintPep604Exception):
beartype(get_minecraft_end_txt_pep604)


def test_pep563_class() -> None:
'''
Expand Down
38 changes: 28 additions & 10 deletions beartype_test/a00_unit/data/pep/pep563/data_pep563_poem.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
# ....................{ IMPORTS }....................
from __future__ import annotations
from beartype import beartype
from beartype.typing import List
from beartype.typing import (
List,
Union,
)
from beartype._cave._cavefast import IntType
from beartype_test.a00_unit.data.data_type import decorator
from collections.abc import Callable
from typing import Union

# ....................{ CONSTANTS }....................
_MINECRAFT_END_TXT_STANZAS = (
Expand Down Expand Up @@ -102,22 +104,38 @@
'Wake up.',
)

# ....................{ CALLABLES ~ module }....................
# Callables exercising module-scoped edge cases under PEP 563.
# ....................{ CALLABLES ~ module : undecorated }....................
# Callables exercising module-scoped PEP 563 edge cases.

def get_minecraft_end_txt(player_name: str) -> str:
'''
Callable *not* decorated by :func:`beartype.beartype`.
The ``test_pep_563()`` unit test tests that :func:`beartype.beartype`
silently accepts callables with one or more non-postponed annotations under
PEP 563 by manually resolving all postponed annotations on this callable
and then manually passing this callable to :func:`beartype.beartype`.
Callable *not* decorated by :func:`beartype.beartype`, exercising that
:func:`beartype.beartype` silently accepts callables with one or more
non-postponed annotations under :pep:`563` by manually resolving all
postponed annotations on this callable and then manually passing this
callable to :func:`beartype.beartype`.
'''

return ''.join(_MINECRAFT_END_TXT_STANZAS).format(player_name=player_name)


def get_minecraft_end_txt_pep604(player_name: str | int) -> str | int:
'''
Callable *not* decorated by :func:`beartype.beartype`, exercising that
:func:`beartype.beartype` either:
* Under Python < 3.10, raises an exception when decorating a callable
annotated by one or more :pep:`604`-compliant new unions postponed by
:pep:`563`.
* Under Python >= 3.10, decorates this callable as expected *without*
raising an exception.
'''

# Defer to the existing getter defined above, decorated by @beartype.
return beartype(get_minecraft_end_txt)(player_name)

# ....................{ CALLABLES ~ module : decorated }....................
# @beartype-decorated callables exercising module-scoped PEP 563 edge cases.
@beartype
def get_minecraft_end_txt_stanza(
player_name: str, stanza_index: IntType) -> str:
Expand Down
4 changes: 2 additions & 2 deletions pytest
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ set -e
# Array of all shell words with which to invoke Python. Dismantled, this is:
# * "-X dev", enabling the Python Development Mode (PDM). See also commentary
# for the ${PYTHONDEVMODE} shell variable in the "tox.ini" file.
PYTHON_ARGS=( command python3 -X dev )
# PYTHON_ARGS=( command python3 -X dev )
# PYTHON_ARGS=( command python3.8 -X dev )
# PYTHON_ARGS=( command python3.9 -X dev )
# PYTHON_ARGS=( command python3.10 -X dev )
# PYTHON_ARGS=( command python3.11 -X dev )
# PYTHON_ARGS=( command python3.12 -X dev )
PYTHON_ARGS=( command python3.12 -X dev )
# PYTHON_ARGS=( command pypy3.7 -X dev )

# Array of all shell words to be passed to "python3" below.
Expand Down

0 comments on commit 6e64bdd

Please sign in to comment.