Skip to content

Commit

Permalink
Official nptyping support.
Browse files Browse the repository at this point in the history
This commit adds official support for the third-party `nptyping` package
to @beartype, resolving feature requests #303 and #304 kindly submitted
by shades-wearing Serbian superhero @peske (Aleksandar Pesic).

Note this significant caveat to this support: **`nptyping` is currently
unmaintained, suffers over 20 severe issues, and emits 7 severe NumPy
deprecation warnings.** In other words, `nptyping` is a ticking time
bomb likely to explode your codebase into tiny fragments that are
steaming and shredding your fragile keyboard. All @beartype users are
strongly advised to immediately transition from `nptyping` to
@patrick-kidger's `jaxtyping`. Unlike `nptyping`, `jaxtyping` provides
well-maintained type hints covering NumPy, JAX, PyTorch, and TensorFlow.
It's pretty intense. It's also @beartype's [official FAQ recommendation
for type-checking NumPy
arrays](https://beartype.readthedocs.io/en/latest/faq/#numpy-arrays).

Deprecations emitted by merely importing `nptyping` include:

```
  /usr/lib/python3.12/site-packages/nptyping/typing_.py:51: DeprecationWarning: `np.bool8` is a deprecated alias for `np.bool_`.  (Deprecated NumPy 1.24)
    Bool8 = np.bool8
  /usr/lib/python3.12/site-packages/nptyping/typing_.py:54: DeprecationWarning: `np.object0` is a deprecated alias for ``np.object0` is a deprecated alias for `np.object_`. `object` can be used instead.  (Deprecated NumPy 1.24)`.  (Deprecated NumPy 1.24)
    Object0 = np.object0
  /usr/lib/python3.12/site-packages/nptyping/typing_.py:66: DeprecationWarning: `np.int0` is a deprecated alias for `np.intp`.  (Deprecated NumPy 1.24)
    Int0 = np.int0
  /usr/lib/python3.12/site-packages/nptyping/typing_.py:80: DeprecationWarning: `np.uint0` is a deprecated alias for `np.uintp`.  (Deprecated NumPy 1.24)
    UInt0 = np.uint0
  /usr/lib/python3.12/site-packages/nptyping/typing_.py:107: DeprecationWarning: `np.void0` is a deprecated alias for `np.void`.  (Deprecated NumPy 1.24)
    Void0 = np.void0
  /usr/lib/python3.12/site-packages/nptyping/typing_.py:112: DeprecationWarning: `np.bytes0` is a deprecated alias for `np.bytes_`.  (Deprecated NumPy 1.24)
    Bytes0 = np.bytes0
  /usr/lib/python3.12/site-packages/nptyping/typing_.py:114: DeprecationWarning: `np.str0` is a deprecated alias for `np.str_`.  (Deprecated NumPy 1.24)
    Str0 = np.str0
```

Devbros don't let devbros import `nptyping`. *Not even once.*
(*Casual dual assignation of a mutual assassination of casuistry!*)
  • Loading branch information
leycec committed Nov 14, 2023
1 parent d6b107b commit e5a3915
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 33 deletions.
89 changes: 77 additions & 12 deletions beartype/_check/convert/convcoerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@
from beartype._check.forward.fwdhint import resolve_hint
from beartype._util.cache.map.utilmapbig import CacheUnboundedStrong
from beartype._util.hint.utilhinttest import is_hint_uncached
from beartype._util.hint.pep.utilpepget import get_hint_pep_args
from beartype._util.hint.pep.proposal.pep484.utilpep484union import (
make_hint_pep484_union)
from beartype._util.hint.pep.proposal.utilpep604 import is_hint_pep604

# ....................{ COERCERS ~ root }....................
#FIXME: Document mypy-specific coercion in the docstring as well, please.
Expand Down Expand Up @@ -358,21 +360,21 @@ def coerce_hint_any(hint: object) -> Any:
This function *cannot* be meaningfully memoized, since the passed type hint
is *not* guaranteed to be cached somewhere. Only functions passed cached
type hints can be meaningfully memoized. Since this high-level function
internally defers to unmemoized low-level functions that are ``O(n)`` for
``n`` the size of the inheritance hierarchy of this hint, this function
should be called sparingly.
internally defers to unmemoized low-level functions that are :math:`O(n)`
for :math:`n` the size of the inheritance hierarchy of this hint, this
function should be called sparingly.
This function intentionally does *not* cache :pep:`484`-compliant generics
subscripted by type variables under Python < 3.9. Those hints are
technically uncached but silently treated by this function as self-cached
and thus preserved as is. Why? Because correctly detecting those hints as
uncached would require an unmemoized ``O(n)`` search across the inheritance
hierarchy of *all* passed objects and thus all type hints annotating
callables decorated by :func:`beartype.beartype`. Since this failure only
affects obsolete Python versions *and* since the only harms induced by this
failure are a slight increase in space and time consumption for edge-case
type hints unlikely to actually be used in real-world code, this tradeoff
is more than acceptable. We're not the bad guy here. Right?
uncached would require an unmemoized :math:`O(n)` search across the
inheritance hierarchy of *all* passed objects and thus all type hints
annotating callables decorated by :func:`beartype.beartype`. Since this
failure only affects obsolete Python versions *and* since the only harms
induced by this failure are a slight increase in space and time consumption
for edge-case type hints unlikely to actually be used in real-world code,
this tradeoff is more than acceptable. We're not the bad guy here. Right?
Parameters
----------
Expand All @@ -390,6 +392,70 @@ def coerce_hint_any(hint: object) -> Any:
'''

# ..................{ NON-SELF-CACHING }..................
# If this hint is PEP 604-compliant new union (e.g., "int | str"), this hint
# is *NOT* self-caching (e.g., "int | str is not int | str") and *MUST* thus
# be explicitly cached here.
#
# Ideally, new unions would *NOT* require explicit caching here but could
# instead simply be implicitly cached below by the existing "elif
# is_hint_uncached(hint):" conditional. Indeed, that is exactly how new
# unions were once cached. Tragically, that approach no longer suffices.
# Why? Because poorly implemented third-party packages like "nptyping"
# dynamically generate in-memory classes that all share the same
# fully-qualified names despite being fundamentally different classes: e.g.,
# $ python3.12
# >>> from nptyping import Float64, NDArray, Shape
# >>> foo = NDArray[Shape["N, N"], Float64]
# >>> bar = NDArray[Shape["*"], Float64]
#
# >>> foo == bar
# False # <-- this is sane
# >>> foo.__name__
# NDArray # <-- this is insane
# >>> bar.__name__
# NDArray # <-- still insane after all these years
#
# >>> foo | None == bar | None
# False # <-- this is sane
# >>> repr(foo | None)
# NDArray | None # <-- big yikes
# >>> repr(bar | None)
# NDArray | None # <-- yikes intensifies
#
# Alternately, it could be argued that the implementation of the
# types.UnionType.__repr__() dunder method underlying the repr() strings
# printed above is at fault. Ideally, that method should embed the repr()
# strings of its child hints in the string it returns; instead, that method
# merely embeds the classnames of its child hints in the string it returns.
# Since the implementation of this method is unlikely to improve, however,
# the burden remains on third-party authors to correctly name classes.
#
# In either case, reducing new unions on the overly simplistic basis of
# their repr() strings fails in the general case of poorly implemented
# third-party packages that violate Python standards.
#
# Instead, this new approach transforms PEP 604-compliant new unions to
# equivalent PEP 484-compliant old unions (e.g., from "int | str" to
# "typing.Union[int, str]"). While substantially slower than the old
# approach, the new approach is substantially more resilient against bad
# behaviour in third-party packages. Ultimately, worky >>>>> speed.
if is_hint_pep604(hint):
# Tuple of the two or more child type hints subscripting this new union.
hint_args = get_hint_pep_args(hint)

# Reduce this hint the equivalent PEP 484-compliant old union. Why?
# Because old unions implicitly self-cache, whereas new unions do *NOT*:
# >>> int | str is int | str
# False
#
# >>> from typing import Union
# >>> Union[int, str] is Union[int, str]
# True
#
# Note that this factory function is memoized and thus optimized.
hint = make_hint_pep484_union(hint_args)
# Else, this hint is *NOT* a PEP 604-compliant new union.
#
# If this hint is *NOT* self-caching, this hint *MUST* thus be explicitly
# cached here. Failing to do so would disable subsequent memoization,
# reducing decoration- and call-time efficiency when decorating callables
Expand All @@ -401,11 +467,10 @@ def coerce_hint_any(hint: object) -> Any:
# * Else, one or more prior copies of this hint have already been passed to
# this function. In this case, replace this subsequent copy by the first
# copy of this hint originally passed to a prior call of this function.
if is_hint_uncached(hint):
elif is_hint_uncached(hint):
# print(f'Self-caching type hint {repr(hint)}...')
return _HINT_REPR_TO_SINGLETON.cache_or_get_cached_value(
key=repr(hint), value=hint)
# return _HINT_REPR_TO_SINGLETON.cache_or_get_cached_value(key=repr(hint), value=hint)
# Else, this hint is (hopefully) self-caching.

# Return this uncoerced hint as is.
Expand Down
15 changes: 11 additions & 4 deletions beartype/_util/hint/pep/proposal/pep484/utilpep484union.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

# ....................{ IMPORTS }....................
from beartype.typing import Union
from beartype._util.cache.utilcachecall import callable_cached

# ....................{ MAKERS }....................
# ....................{ FACTORIES }....................
@callable_cached
def make_hint_pep484_union(hints: tuple) -> object:
'''
:pep:`484`-compliant **union type hint** (:attr:`typing.Union`
Expand All @@ -23,9 +25,14 @@ def make_hint_pep484_union(hints: tuple) -> object:
PEP-compliant type hint in this tuple if this tuple contains only one item,
*or* raise an exception otherwise (i.e., if this tuple is empty).
This maker is intentionally *not* memoized (e.g., by the
:func:`callable_cached` decorator), as the :attr:`typing.Union` type hint
factory already caches its subscripted arguments.
This factory is memoized for efficiency. Technically, the
:attr:`typing.Union` type hint factory already caches its subscripted
arguments. Pragmatically, that caching is slow and thus worth optimizing
with trivial optimization on our end. Moreover, this factory is called by
the performance-sensitive
:func:`beartype._check.convert.convcoerce.coerce_hint_any` coercer in an
early-time code path of the :func:`beartype.beartype` decorator. Optimizing
this factory thus optimizes :func:`beartype.beartype` itself.
Parameters
----------
Expand Down
11 changes: 8 additions & 3 deletions beartype/_util/hint/pep/utilpeptest.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,14 @@ def is_hint_pep(hint: object) -> bool:
from beartype._util.hint.pep.utilpepget import (
get_hint_pep_sign_or_none)

# Return true only if this object is uniquely identified by a sign and thus
# a PEP-compliant type hint.
return get_hint_pep_sign_or_none(hint) is not None
# Sign uniquely identifying this hint if this hint is PEP-compliant *OR*
# "None" otherwise (i.e., if this hint is *NOT* PEP-compliant).
hint_sign = get_hint_pep_sign_or_none(hint)
# print(f'hint: {repr(hint)}; sign: {repr(hint_sign)}')

# Return true *ONLY* if this hint is uniquely identified by a sign and thus
# PEP-compliant.
return hint_sign is not None


def is_hint_pep_deprecated(hint: object) -> bool:
Expand Down
8 changes: 4 additions & 4 deletions beartype/_util/hint/utilhinttest.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,15 @@ def die_unless_hint(
# BEGIN: Synchronize changes here with is_hint() below.
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

# If this hint is PEP-compliant, raise an exception only if this hint is
# currently unsupported by @beartype.
# If this hint is PEP-compliant *AND* currently unsupported by @beartype,
# raise an exception.
if is_hint_pep(hint):
die_if_hint_pep_unsupported(
hint=hint, exception_prefix=exception_prefix)
# Else, this hint is *NOT* PEP-compliant. In this case...

# Raise an exception only if this hint is also *NOT* PEP-noncompliant. By
# definition, all PEP-noncompliant type hints are supported by @beartype.
# If this PEP-noncompliant hint but still currently unsupported by
# @beartype, raise an exception.
die_unless_hint_nonpep(hint=hint, exception_prefix=exception_prefix)

# ....................{ TESTERS }....................
Expand Down
22 changes: 14 additions & 8 deletions beartype_test/a00_unit/a60_check/a20_convert/test_convcoerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def test_coerce_hint_any() -> None:

# ..................{ IMPORTS }..................
# Defer test-specific imports.
from beartype.typing import Union
from beartype._check.convert.convcoerce import coerce_hint_any
from beartype._util.py.utilpyversion import (
IS_PYTHON_AT_LEAST_3_10,
Expand Down Expand Up @@ -103,15 +104,20 @@ def test_coerce_hint_any() -> None:
# PEP 604...
if IS_PYTHON_AT_LEAST_3_10:
# Arbitrary PEP 604-compliant union.
hint_pep604 = int | str | None
union_pep604 = int | str | None

# Assert this coercer preserves the first passed instance of a PEP
# 604-compliant union as is.
assert coerce_hint_any(hint_pep604) is hint_pep604
# Equivalent PEP 484-compliant union
union_pep484 = Union[int, str, type(None)]

# Assert this coercer returns the first passed instance of a PEP
# 604-compliant type hint when passed a copy of that instance. PEP
# 604-compliant type hints are *NOT* self-caching: e.g.,
# Assert this coercer transforms the first passed instance of a PEP
# 604-compliant union into the equivalent PEP 484-compliant union.
assert coerce_hint_any(union_pep604) is union_pep484

# Assert this coercer returns the same PEP 484-compliant union when
# passed a copy of the same PEP 604-compliant union. PEP 484-compliant
# unions are self-caching; PEP 604-compliant unions are *NOT*: e.g.,
# >>> Union[int, str] is Union[int, str]
# True
# >>> int | str is int | str
# False
assert coerce_hint_any(int | str | None) is hint_pep604
assert coerce_hint_any(int | str | None) is union_pep484
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# --------------------( LICENSE )--------------------
# Copyright (c) 2014-2023 Beartype authors.
# See "LICENSE" for further details.

'''
**Beartype decorator PEP-noncompliant** :mod:`nptyping` **type hint unit
tests.**
This submodule unit tests the :func:`beartype.beartype` decorator with respect
to **PEP-noncompliant** :mod:`nptyping` **type hints** (i.e.,
:mod:`nptyping`-specific annotations *not* compliant with annotation-centric
PEPs).
'''

# ....................{ 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_if_python_version_less_than,
skip_unless_package,
)

# ....................{ TESTS }....................
# If the active Python interpreter targets Python < 3.10.0, this interpreter
# fails to support PEP 604-compliant new unions (e.g., "int | str") and thus the
# entire point of this unit test. In this case, skip this test.
@skip_if_python_version_less_than('3.10.0')
@skip_unless_package('nptyping')
def test_decor_nptyping() -> None:
'''
Test that the :func:`beartype.beartype` decorator successfully type-checks
callables annotated by :mod:`nptyping` type hints.
:mod:`nptyping` type hints violate Python typing standards in various ways.
Notably, :mod:`nptyping` type hint factories dynamically generate unique
classes that nonetheless share the same fully-qualified names, complicating
caching in the :func:`beartype.beartype` decorator.
Whereas this test suite automates testing of PEP-compliant type hints via
the :mod:`beartype_test.a00_unit.data` subpackage, :mod:`nptyping` type
hints are fundamentally non-standard and thus *cannot* be automated in this
standard manner. These hints can *only* be tested with a non-standard
workflow implemented by this unit test.
'''

# ....................{ IMPORTS }....................
# Defer test-specific imports.
#
# Note that nptyping requires NumPy. Ergo, NumPy is safely importable here.
from beartype import beartype
from beartype.roar import BeartypeCallHintParamViolation
from numpy import (
array,
float64,
int64,
sum as numpy_sum,
)
from nptyping import (
Float64,
Int64,
NDArray,
Shape,
)
from pytest import raises

# ....................{ LOCALS }....................
# Arbitrary NumPy arrays satisfied by "nptyping" type hints defined below.
flashed_like_strong_inspiration = array(
[[1., 2.], [3., 4.]], dtype=float64)
till_meaning_on_his_vacant_mind = array(
[1, 2, 3, 4, 5, 6], dtype=int64)

# ....................{ FUNCTIONS }....................
@beartype
def suspended_he_that_task(
but_ever_gazed: NDArray[Shape['N, N'], Float64] | None = None,
and_gazed: NDArray[Shape['*' ], Int64] | None = None,
) -> int64 | None:
'''
Arbitrary callable annotated by two :pep:`604`-compliant new unions of
distinct :mod:`nptyping` type hints and arbitrary other types.
This callable exercises a `prominent edge case`_.
.. _prominent edge case:
https://github.com/beartype/beartype/issues/304
'''

# Bend it like Bender.
return None if and_gazed is None else numpy_sum(and_gazed)

# ....................{ PASS }....................
# Assert that this callable returns the expected value when passed NumPy
# arrays satisfying the "nptyping" type hints annotating this callable.
assert suspended_he_that_task(
but_ever_gazed=flashed_like_strong_inspiration,
and_gazed=till_meaning_on_his_vacant_mind,
) == 21

# ....................{ FAIL }....................
# Assert that this callable raises the expected exception when passed NumPy
# arrays violating the "nptyping" type hints annotating this callable.
with raises(BeartypeCallHintParamViolation):
suspended_he_that_task(till_meaning_on_his_vacant_mind)
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ def hints_pep_meta_numpy() -> 'List[HintPepMetadata]':
from beartype_test._util.module.pytmodtest import (
is_package_numpy_typing_ndarray_deep)

# ..................{ LOCALS }..................
# ..................{ LOCALS }..................
# List of all PEP-specific type hint metadata to be returned.
hints_pep_meta = []

# ..................{ UNSUPPORTED }..................
# ..................{ UNSUPPORTED }..................
# If beartype does *NOT* deeply support "numpy.typing.NDArray" type hints
# under the active Python interpreter, return the empty list.
if not is_package_numpy_typing_ndarray_deep():
Expand Down
2 changes: 2 additions & 0 deletions doc/src/_links.rst
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@
https://www.sympy.org
.. _TensorFlow:
https://www.tensorflow.org
.. _nptyping:
https://github.com/ramonhagenaars/nptyping
.. _numerary:
https://github.com/posita/numerary
.. _pyenv:
Expand Down
2 changes: 2 additions & 0 deletions doc/src/pep.rst
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ you into stunned disbelief that somebody typed all this. [#rsi]_
+------------------------+-----------------------------------------------------------+--------------------------+---------------------------+
| nuitka_ | *all* || **0.12.0**\ \ *current* |
+------------------------+-----------------------------------------------------------+--------------------------+---------------------------+
| nptyping_ | *all* || **0.17.0**\ \ *current* |
+------------------------+-----------------------------------------------------------+--------------------------+---------------------------+
| numpy.typing_ | numpy.typing.NDArray_ || **0.8.0**\ \ *current* |
+------------------------+-----------------------------------------------------------+--------------------------+---------------------------+
| pandera_ | *all* | **0.13.0**\ \ *current* ||
Expand Down

0 comments on commit e5a3915

Please sign in to comment.