Skip to content

Commit

Permalink
PEP 563 + typing.NoReturn.
Browse files Browse the repository at this point in the history
This commit resolves a critical failure with respect to @beartype's
handling of PEP 563 (i.e., `from __future__ import annotations`) in
concert with the PEP 484-compliant `typing.NoReturn` type hint,
resolving issue #282 kindly submitted by prehistoric aquatic dinosaur
MIT horror @dcharatan (David Charatan) as well as a variety of related
ML horror shows throughout the PyTorch ecosystem. Thanks so much to
@justinchuby and the fearless PyTorch crew for patiently tolerating
@beartype's pawful growing pains. Dare we pretend that @beartype 0.16.0
never happened, Microsoft? We dare. (*Salient tent in a salacious salad!?*)
  • Loading branch information
leycec committed Sep 19, 2023
1 parent b66ae1d commit c8feb04
Show file tree
Hide file tree
Showing 10 changed files with 145 additions and 37 deletions.
20 changes: 19 additions & 1 deletion beartype/_check/convert/convsanify.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@
)
from beartype._check.convert.convreduce import reduce_hint
from beartype._conf.confcls import BeartypeConf
from beartype._data.func.datafuncarg import ARG_NAME_RETURN
from beartype._data.hint.datahinttyping import TypeStack
from beartype._util.cache.map.utilmapbig import CacheUnboundedStrong
from beartype._util.error.utilerror import EXCEPTION_PLACEHOLDER
from beartype._util.hint.pep.proposal.pep484585.utilpep484585func import (
reduce_hint_pep484585_func_return)

# ....................{ SANIFIERS ~ root }....................
#FIXME: Unit test us up, please.
def sanify_hint_root_func(
# Mandatory parameters.
hint: object,
#FIXME: Rename to "pith_name" for orthogonality with everything else.
arg_name: str,
bear_call: BeartypeCall,

Expand Down Expand Up @@ -132,6 +134,22 @@ class variable or method annotated by this hint *or* :data:`None`).
)
)

# If this hint annotates the return, reduce this hint to a simpler hint if
# this hint is either PEP 484- or 585-compliant *AND* requires reduction
# (e.g., from "Coroutine[None, None, str]" to just "str"). Raise an
# exception if this hint is contextually invalid for this callable (e.g.,
# generator whose return is *NOT* annotated as "Generator[...]").
#
# Perform this reduction *BEFORE* performing subsequent tests (e.g., to
# accept "Coroutine[None, None, typing.NoReturn]" as expected).
#
# Note that this logic *ONLY* pertains to callables (rather than statements)
# and is thus *NOT* performed by the sanify_hint_root_statement() sanitizer.
if arg_name == ARG_NAME_RETURN:
hint = reduce_hint_pep484585_func_return(
func=bear_call.func_wrappee, exception_prefix=EXCEPTION_PLACEHOLDER)
# Else, this hint annotates a parameter.

# Reduce this hint to a lower-level PEP-compliant type hint if this hint is
# reducible *OR* this hint as is otherwise. Reductions simplify subsequent
# logic elsewhere by transparently converting non-trivial hints (e.g.,
Expand Down
49 changes: 20 additions & 29 deletions beartype/_decor/wrap/wrapmain.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@
ArgKind,
iter_func_args,
)
from beartype._util.hint.pep.proposal.pep484585.utilpep484585func import (
reduce_hint_pep484585_func_return)
from beartype._util.hint.pep.proposal.pep484585.utilpep484585ref import (
get_hint_pep484585_forwardref_classname_relative_to_object)
from beartype._util.hint.utilhinttest import (
Expand Down Expand Up @@ -557,16 +555,20 @@ def _code_check_return(bear_call: BeartypeCall) -> str:

# Attempt to...
try:
# This hint reduced to a simpler hint if this hint is either PEP 484-
# *OR* 585-compliant *AND* requires reduction (e.g., from
# "Coroutine[None, None, str]" to just "str"), raising an exception if
# this hint is contextually invalid for this callable (e.g., generator
# whose return is *NOT* annotated as "Generator[...]").
# Preserve the original unsanitized type hint for subsequent reference
# *BEFORE* sanitizing this type hint.
hint_insane = hint

# Sanitize this hint to either:
# * If this hint is PEP-noncompliant, the PEP-compliant type hint
# converted from this PEP-noncompliant type hint.
# * If this hint is both PEP-compliant and supported, this hint as
# is.
# * Else, raise an exception.
#
# Perform this reduction *BEFORE* performing subsequent tests (e.g., to
# accept "Coroutine[None, None, typing.NoReturn]" as expected).
hint = reduce_hint_pep484585_func_return(
func=bear_call.func_wrappee, exception_prefix=EXCEPTION_PLACEHOLDER)
# Do this first *BEFORE* passing this hint to any further callables.
hint = sanify_hint_root_func(
hint=hint, arg_name=ARG_NAME_RETURN, bear_call=bear_call)

# If this is the PEP 484-compliant "typing.NoReturn" type hint permitted
# *ONLY* as a return annotation...
Expand All @@ -577,30 +579,19 @@ def _code_check_return(bear_call: BeartypeCall) -> str:
func_call_prefix=bear_call.func_wrapper_code_call_prefix)
# Else, this is *NOT* "typing.NoReturn". In this case...
else:
# Sanitize this hint to either:
# * If this hint is PEP-noncompliant, the PEP-compliant type hint
# converted from this PEP-noncompliant type hint.
# * If this hint is both PEP-compliant and supported, this hint as
# is.
# * Else, raise an exception.
#
# Do this first *BEFORE* passing this hint to any further callables.
hint_sane = sanify_hint_root_func(
hint=hint, arg_name=ARG_NAME_RETURN, bear_call=bear_call)

# If this PEP-compliant hint is unignorable, generate and return a
# snippet type-checking this return against this hint.
if not is_hint_ignorable(hint_sane):
if not is_hint_ignorable(hint):
# Type stack if required by this hint *OR* "None" otherwise. See
# the is_hint_needs_cls_stack() tester for further discussion.
#
# Note that the original unsanitized "hint" (e.g.,
# "typing.Self") rather than the new sanitized "hint_sane"
# (e.g., the class currently being decorated by @beartype) is
# passed to that tester. See _code_check_args() for details.
# Note that the original unsanitized "hint_insane" (e.g.,
# "typing.Self") rather than the new sanitized "hint" (e.g., the
# class currently being decorated by @beartype) is passed to
# that tester. See _code_check_args() for details.
cls_stack = (
bear_call.cls_stack
if is_hint_needs_cls_stack(hint) else
if is_hint_needs_cls_stack(hint_insane) else
None
)
# print(f'return hint {repr(hint)} cls_stack: {repr(cls_stack)}')
Expand All @@ -615,7 +606,7 @@ def _code_check_return(bear_call: BeartypeCall) -> str:
code_return_check_pith,
func_wrapper_scope,
hint_forwardrefs_class_basename,
) = make_func_wrapper_code(hint_sane, bear_call.conf, cls_stack) # type: ignore[assignment]
) = make_func_wrapper_code(hint, bear_call.conf, cls_stack) # type: ignore[assignment]

# Merge the local scope required to type-check this return into
# the local scope currently required by the current wrapper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,13 @@ def _die_of_hint_return_invalid(
Decorated callable whose return is annotated by an invalid type hint.
exception_cls : TypeException
Type of exception to be raised. Defaults to
:exc:`BeartypeDecorHintPep484585Exception`.
:exc:`.BeartypeDecorHintPep484585Exception`.
exception_suffix : str
Substring suffixing the exception message to be raised.
Raises
----------
:exc:`exception_cls`
exception_cls
Exception explaining the invalidity of this return type hint.
'''
assert callable(func), f'{repr(func)} not callable.'
Expand Down
2 changes: 1 addition & 1 deletion beartype/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
'''


PYTHON_VERSION_MINOR_MAX = 12
PYTHON_VERSION_MINOR_MAX = 13
'''
Maximum minor stable version of this major version of Python currently released
(e.g., ``5`` if Python 3.5 is the most recent stable version of Python 3.x).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def test_pep563_class_self_reference_reloaded() -> None:
https://github.com/beartype/beartype/issues/152
'''

# .....................{ IMPORTS }....................
# Defer test-specific imports.
#
# Note that the "data_pep563_club" submodule is intentionally imported as
Expand All @@ -66,6 +67,7 @@ def test_pep563_class_self_reference_reloaded() -> None:
from beartype_test.a00_unit.data.pep.pep563 import data_pep563_club
from importlib import reload

# .....................{ PASS }....................
# Assert that a @beartype-decorated class method whose circular return
# annotation is postponed under PEP 563 returns the expected value.
assert data_pep563_club.Chameleon.like_my_dreams().colors == (
Expand Down Expand Up @@ -123,6 +125,27 @@ def test_pep563_class_self_reference_override() -> None:
# expected value.
assert Karma.if_your_colors().dreams == DREAMS


def test_pep563_func_return() -> None:
'''
Test module-scoped :pep:`563` support implemented in the
:func:`beartype.beartype` decorator with respect to callables with returns
annotated by the :pep:`484`-compliant :obj:`typing.NoReturn` type hint.
'''

# Defer test-specific imports.
from beartype_test.a00_unit.data.pep.pep563.data_pep563_club import (
HeWouldLingerLong,
to_love_and_wonder,
)
from pytest import raises

# Assert that a @beartype-decorated function "typing.NoReturn" return
# annotation is postponed under PEP 563 to a string raises the expected
# exception.
with raises(HeWouldLingerLong):
to_love_and_wonder()

# ....................{ TESTS ~ poem }....................
def test_pep563_module() -> None:
'''
Expand Down
29 changes: 28 additions & 1 deletion beartype_test/a00_unit/data/pep/pep563/data_pep563_club.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
# ....................{ IMPORTS }....................
from __future__ import annotations
from beartype import beartype
from beartype.typing import Union
from beartype.typing import (
NoReturn,
Union,
)

# ....................{ CONSTANTS }....................
COLORS = 'red, gold, and green'
Expand Down Expand Up @@ -49,6 +52,14 @@
that of a subsequently declared class.
'''

# ....................{ EXCEPTIONS }....................
class HeWouldLingerLong(Exception):
'''
Arbitrary exception subclass.
'''

pass

# ....................{ CLASSES }....................
@beartype
class Chameleon(object):
Expand Down Expand Up @@ -175,3 +186,19 @@ def if_your_colors(cls) -> Karma:
'''

return Karma(DREAMS)

# ....................{ FUNCTIONS }....................
@beartype
def to_love_and_wonder() -> NoReturn:
'''
Arbitrary function unconditionally raising a unique function-specific
exception annotated as such, exercising an edge case in the
:func:`beartype.beartype` decorator.
Raises
----------
HeWouldLingerLong
Unconditionally.
'''

raise HeWouldLingerLong('In lonesome vales, making the wild his home,')
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class FrequentWith(object):
Arbitrary class defining various problematic methods.
'''

def crystal_column(self, and_clear_shrines: OfPearl) -> NoReturn:
def crystal_column(self, and_clear_shrines: OfPearl) -> OfPearl:
'''
Arbitrary method both accepting and returning a value annotated as a
**missing forward reference** (i.e., :pep:`563`-postponed type hint
Expand Down
5 changes: 4 additions & 1 deletion beartype_test/a90_func/package/test_package_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def test_package_import_isolation() -> None:
:mod:`beartype` package itself remains low -- if not ideally negligible.
'''

# ....................{ IMPORTS }....................
# Defer test-specific imports.
from beartype._util.py.utilpyinterpreter import (
get_interpreter_command_words)
Expand All @@ -37,6 +38,7 @@ def test_package_import_isolation() -> None:
search as re_search,
)

# ....................{ LOCAL }....................
#FIXME: *FRAGILE.* Manually hardcoding module names here invites
#desynchronization, particularly with optional third-party dependencies.
#Instead, the names of optional third-party packages should be dynamically
Expand All @@ -55,8 +57,8 @@ def test_package_import_isolation() -> None:
# importation costs here. See also @posita's stunning profiling work at:
# https://github.com/beartype/beartype/pull/103#discussion_r815027198
_HEAVY_MODULE_NAME_RAW_REGEXES = (
r'beartype\.abby',
r'beartype\.cave',
r'beartype\.door',
r'beartype\.vale',
r'numpy',
)
Expand Down Expand Up @@ -88,6 +90,7 @@ def test_package_import_isolation() -> None:
_CODE_PRINT_IMPORTS_AFTER_IMPORTING_BEARTYPE,
)

# ....................{ PASS }....................
# Run this code isolated to a Python subprocess, raising an exception on
# subprocess failure while both forwarding all standard error output by this
# subprocess to the standard error file handle of the active Python process
Expand Down
2 changes: 1 addition & 1 deletion beartype_test/a90_func/z90_lib/a00_sphinx/test_sphinx.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# See "LICENSE" for further details.

'''
**Project-wide functional Sphinx tests.**
Project-wide **Sphinx** integration tests.
This submodule functionally tests the :func:`beartype.beartype` decorator with
respect to the third-party Sphinx documentation build toolchain.
Expand Down
46 changes: 46 additions & 0 deletions beartype_test/a90_func/z90_lib/test_torch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
# --------------------( LICENSE )--------------------
# Copyright (c) 2014-2023 Beartype authors.
# See "LICENSE" for further details.

'''
Project-wide **PyTorch** integration tests.
This submodule functionally tests the :mod:`beartype` package against the
third-party PyTorch package.
'''

# ....................{ IMPORTS }....................
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# WARNING: To raise human-readable test errors, avoid importing from
# package-specific submodules at module scope.
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
from beartype_test._util.mark.pytskip import skip_unless_package

# ....................{ TESTS }....................
@skip_unless_package('torch')
def test_torch() -> None:
'''
Functional test validating that the :mod:`beartype` package raises *no*
unexpected exceptions when imported by the third-party PyTorch package.
To do so, this test externally imports the :mod:`torch` package against a
minimal-length Python snippet exercising all known edge cases.
'''

# ....................{ IMPORTS }....................
# Defer test-specific imports.
from beartype._util.py.utilpyinterpreter import (
get_interpreter_command_words)
from beartype_test._util.command.pytcmdrun import run_command_forward_output

# ....................{ LOCAL }....................
# Tuple of all arguments to be passed to the active Python interpreter rerun
# as an external command.
_PYTHON_ARGS = get_interpreter_command_words() + ('-c', 'import torch',)

# ....................{ PASS }....................
# Run this command, raising an exception on subprocess failure while
# forwarding all standard output and error output by this subprocess to the
# standard output and error file handles of the active Python process.
run_command_forward_output(command_words=_PYTHON_ARGS)

0 comments on commit c8feb04

Please sign in to comment.