Skip to content

Instantly share code, notes, and snippets.

@leycec
Last active August 4, 2022 18:22
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save leycec/3d48cc60e7d5fe8860b077acb067dc54 to your computer and use it in GitHub Desktop.
Save leycec/3d48cc60e7d5fe8860b077acb067dc54 to your computer and use it in GitHub Desktop.
`@beartype` Decorator and Unit Test Suite Thereof
#!/usr/bin/env python3
'''
`@beartype` decorator, implementing a rudimentary subset of PEP 484-style type
checking based on Python 3.x function annotations.
See Also
----------
https://stackoverflow.com/a/37961120/2809027
Stackoverflow answer introducing the `@beartype` decorator.
'''
# ....................{ MAIN }....................
# If the active Python interpreter is *NOT* optimized (e.g., option "-O" was
# *NOT* passed to this interpreter), enable type checking.
if __debug__:
import inspect
from functools import wraps
from inspect import Parameter, Signature
def beartype(func: callable) -> callable:
'''
Decorate the passed **callable** (e.g., function, method) to validate
both all annotated parameters passed to this callable _and_ the
annotated value returned by this callable if any.
This decorator performs rudimentary type checking based on Python 3.x
function annotations, as officially documented by PEP 484 ("Type
Hints"). While PEP 484 supports arbitrarily complex type composition,
this decorator requires _all_ parameter and return value annotations to
be either:
* Classes (e.g., `int`, `OrderedDict`).
* Tuples of classes (e.g., `(int, OrderedDict)`).
If optimizations are enabled by the active Python interpreter (e.g., due
to option `-O` passed to this interpreter), this decorator is a noop.
Raises
----------
NameError
If any parameter has the reserved name `__beartype_func`.
TypeError
If either:
* Any parameter or return value annotation is neither:
* A type.
* A tuple of types.
* The kind of any parameter is unrecognized. This should _never_
happen, assuming no significant changes to Python semantics.
'''
# Raw string of Python statements comprising the body of this wrapper,
# including (in order):
#
# * A "@wraps" decorator propagating the name, docstring, and other
# identifying metadata of the original function to this wrapper.
# * A private "__beartype_func" parameter initialized to this function.
# In theory, the "func" parameter passed to this decorator should be
# accessible as a closure-style local in this wrapper. For unknown
# reasons (presumably, a subtle bug in the exec() builtin), this is
# not the case. Instead, a closure-style local must be simulated by
# passing the "func" parameter to this function at function
# definition time as the default value of an arbitrary parameter. To
# ensure this default is *NOT* overwritten by a function accepting a
# parameter of the same name, this edge case is tested for below.
# * Assert statements type checking parameters passed to this callable.
# * A call to this callable.
# * An assert statement type checking the value returned by this
# callable.
#
# While there exist numerous alternatives (e.g., appending to a list or
# bytearray before joining the elements of that iterable into a string),
# these alternatives are either slower (as in the case of a list, due to
# the high up-front cost of list construction) or substantially more
# cumbersome (as in the case of a bytearray). Since string concatenation
# is heavily optimized by the official CPython interpreter, the simplest
# approach is (curiously) the most ideal.
func_body = '''
@wraps(__beartype_func)
def func_beartyped(*args, __beartype_func=__beartype_func, **kwargs):
'''
# "inspect.Signature" instance encapsulating this callable's signature.
func_sig = inspect.signature(func)
# Human-readable name of this function for use in exceptions.
func_name = func.__name__ + '()'
# For the name of each parameter passed to this callable and the
# "inspect.Parameter" instance encapsulating this parameter (in the
# passed order)...
for func_arg_index, func_arg in enumerate(func_sig.parameters.values()):
# If this callable redefines a parameter initialized to a default
# value by this wrapper, raise an exception. Permitting this
# unlikely edge case would permit unsuspecting users to
# "accidentally" override these defaults.
if func_arg.name == '__beartype_func':
raise NameError(
'Parameter {} reserved for use by @beartype.'.format(
func_arg.name))
# If this parameter is both annotated and non-ignorable for purposes
# of type checking, type check this parameter.
if (func_arg.annotation is not Parameter.empty and
func_arg.kind not in _PARAMETER_KIND_IGNORED):
# Validate this annotation.
_check_type_annotation(
annotation=func_arg.annotation,
label='{} parameter {} type'.format(
func_name, func_arg.name))
# String evaluating to this parameter's annotated type.
func_arg_type_expr = (
'__beartype_func.__annotations__[{!r}]'.format(
func_arg.name))
# String evaluating to this parameter's current value when
# passed as a keyword.
func_arg_value_key_expr = 'kwargs[{!r}]'.format(func_arg.name)
# If this parameter is keyword-only, type check this parameter
# only by lookup in the variadic "**kwargs" dictionary.
if func_arg.kind is Parameter.KEYWORD_ONLY:
func_body += '''
if {arg_name!r} in kwargs and not isinstance(
{arg_value_key_expr}, {arg_type_expr}):
raise TypeError(
'{func_name} keyword-only parameter '
'{arg_name}={{}} not a {{!r}}'.format(
{arg_value_key_expr}, {arg_type_expr}))
'''.format(
func_name=func_name,
arg_name=func_arg.name,
arg_type_expr=func_arg_type_expr,
arg_value_key_expr=func_arg_value_key_expr,
)
# Else, this parameter may be passed either positionally or as
# a keyword. Type check this parameter both by lookup in the
# variadic "**kwargs" dictionary *AND* by index into the
# variadic "*args" tuple.
else:
# String evaluating to this parameter's current value when
# passed positionally.
func_arg_value_pos_expr = 'args[{!r}]'.format(
func_arg_index)
func_body += '''
if not (
isinstance({arg_value_pos_expr}, {arg_type_expr})
if {arg_index} < len(args) else
isinstance({arg_value_key_expr}, {arg_type_expr})
if {arg_name!r} in kwargs else True):
raise TypeError(
'{func_name} parameter {arg_name}={{}} not of {{!r}}'.format(
{arg_value_pos_expr} if {arg_index} < len(args) else {arg_value_key_expr},
{arg_type_expr}))
'''.format(
func_name=func_name,
arg_name=func_arg.name,
arg_index=func_arg_index,
arg_type_expr=func_arg_type_expr,
arg_value_key_expr=func_arg_value_key_expr,
arg_value_pos_expr=func_arg_value_pos_expr,
)
# If this callable's return value is both annotated and non-ignorable
# for purposes of type checking, type check this value.
if func_sig.return_annotation not in _RETURN_ANNOTATION_IGNORED:
# Validate this annotation.
_check_type_annotation(
annotation=func_sig.return_annotation,
label='{} return type'.format(func_name))
# Strings evaluating to this parameter's annotated type and
# currently passed value, as above.
func_return_type_expr = (
"__beartype_func.__annotations__['return']")
# Call this callable, type check the returned value, and return this
# value from this wrapper.
func_body += '''
return_value = __beartype_func(*args, **kwargs)
if not isinstance(return_value, {return_type}):
raise TypeError(
'{func_name} return value {{}} not of {{!r}}'.format(
return_value, {return_type}))
return return_value
'''.format(func_name=func_name, return_type=func_return_type_expr)
# Else, call this callable and return this value from this wrapper.
else:
func_body += '''
return __beartype_func(*args, **kwargs)
'''
# Dictionary mapping from local attribute name to value. For efficiency,
# only those local attributes explicitly required in the body of this
# wrapper are copied from the current namespace. (See below.)
local_attrs = {'__beartype_func': func}
# Dynamically define this wrapper as a closure of this decorator. For
# obscure and presumably uninteresting reasons, Python fails to locally
# declare this closure when the locals() dictionary is passed; to
# capture this closure, a local dictionary must be passed instead.
exec(func_body, globals(), local_attrs)
# Return this wrapper.
return local_attrs['func_beartyped']
_PARAMETER_KIND_IGNORED = {
Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD,
}
'''
Set of all `inspect.Parameter.kind` constants to be ignored during
annotation- based type checking in the `@beartype` decorator.
This includes:
* Constants specific to variadic parameters (e.g., `*args`, `**kwargs`).
Variadic parameters cannot be annotated and hence cannot be type checked.
* Constants specific to positional-only parameters, which apply to non-pure-
Python callables (e.g., defined by C extensions). The `@beartype`
decorator applies _only_ to pure-Python callables, which provide no
syntactic means of specifying positional-only parameters.
'''
_RETURN_ANNOTATION_IGNORED = {Signature.empty, None}
'''
Set of all annotations for return values to be ignored during annotation-
based type checking in the `@beartype` decorator.
This includes:
* `Signature.empty`, signifying a callable whose return value is _not_
annotated.
* `None`, signifying a callable returning no value. By convention, callables
returning no value are typically annotated to return `None`. Technically,
callables whose return values are annotated as `None` _could_ be
explicitly checked to return `None` rather than a none-`None` value. Since
return values are safely ignorable by callers, however, there appears to
be little real-world utility in enforcing this constraint.
'''
def _check_type_annotation(annotation: object, label: str) -> None:
'''
Validate the passed annotation to be a valid type supported by the
`@beartype` decorator.
Parameters
----------
annotation : object
Annotation to be validated.
label : str
Human-readable label describing this annotation, interpolated into
exceptions raised by this function.
Raises
----------
TypeError
If this annotation is neither a new-style class nor a tuple of
new-style classes.
'''
# If this annotation is a tuple, raise an exception if any member of
# this tuple is not a new-style class. Note that the "__name__"
# attribute tested below is not defined by old-style classes and hence
# serves as a helpful means of identifying new-style classes.
if isinstance(annotation, tuple):
for member in annotation:
if not (
isinstance(member, type) and hasattr(member, '__name__')):
raise TypeError(
'{} tuple member {} not a new-style class'.format(
label, member))
# Else if this annotation is not a new-style class, raise an exception.
elif not (
isinstance(annotation, type) and hasattr(annotation, '__name__')):
raise TypeError(
'{} {} neither a new-style class nor '
'tuple of such classes'.format(label, annotation))
# Else, the active Python interpreter is optimized. In this case, disable type
# checking by reducing this decorator to the identity decorator.
else:
def beartype(func: callable) -> callable:
return func
#!/usr/bin/env python3
'''
`py.test`-driven unit test suite for the `@beartype` decorator, implementing a
rudimentary subset of PEP 484-style type checking based on Python 3.x function
annotations.
Usage
----------
These tests assume the `@beartype` decorator and all utility functions (e.g.,
`_check_type_annotation()`) and globals (e.g., `_PARAMETER_KIND_IGNORED`)
required by this decorator to reside in a top-level module named `beartype`. If
this is the case, these tests may be run as is with:
$ py.test -k test_beartype
See Also
----------
https://stackoverflow.com/a/37961120/2809027
Stackoverflow answer introducing the `@beartype` decorator.
'''
# ....................{ IMPORTS }....................
import pytest
# ....................{ TESTS }....................
def test_beartype_noop() -> None:
'''
Test bear typing of a function with no function annotations, reducing to
_no_ type checking.
'''
# Import this decorator.
from beartype import beartype
# Unannotated function to be type checked.
@beartype
def khorne(gork, mork):
return gork + mork
# Call this function and assert the expected return value.
assert khorne('WAAAGH!', '!HGAAAW') == 'WAAAGH!!HGAAAW'
# ....................{ TESTS ~ pass : param }....................
def test_beartype_pass_param_keyword_and_positional() -> None:
'''
Test bear typing of a function call successfully passed both annotated
positional and keyword parameters.
'''
# Import this decorator.
from beartype import beartype
# Function to be type checked.
@beartype
def slaanesh(daemonette: str, keeper_of_secrets: str) -> str:
return daemonette + keeper_of_secrets
# Call this function with both positional and keyword arguments and assert
# the expected return value.
assert slaanesh(
'Seeker of Decadence', keeper_of_secrets="N'Kari") == (
"Seeker of DecadenceN'Kari")
def test_beartype_pass_param_keyword_only() -> None:
'''
Test bear typing of a function call successfully passed an annotated
keyword-only parameter following an `*` or `*args` parameter.
'''
# Import this decorator.
from beartype import beartype
# Function to be type checked.
@beartype
def changer_of_ways(sky_shark: str, *, chaos_spawn: str) -> str:
return sky_shark + chaos_spawn
# Call this function with keyword arguments and assert the expected return
# value.
assert changer_of_ways(
'Screamers', chaos_spawn="Mith'an'driarkh") == (
"ScreamersMith'an'driarkh")
def test_beartype_pass_param_tuple() -> None:
'''
Test bear typing of a function call successfully passed a parameter
annotated as a tuple.
'''
# Import this decorator.
from beartype import beartype
# Function to be type checked.
@beartype
def genestealer(tyranid: str, hive_fleet: (str, int)) -> str:
return tyranid + str(hive_fleet)
# Call this function with each of the two types listed in the above tuple.
assert genestealer(
'Norn-Queen', hive_fleet='Behemoth') == 'Norn-QueenBehemoth'
assert genestealer(
'Carnifex', hive_fleet=0xDEADBEEF) == 'Carnifex3735928559'
def test_type_check_pass_param_custom() -> None:
'''
Test bear typing of a function call successfully passed a parameter
annotated as a user-defined rather than builtin type.
'''
# Import this decorator.
from beartype import beartype
# User-defined type.
class CustomTestStr(str):
pass
# Function to be type checked.
@beartype
def hrud(gugann: str, delphic_plague: CustomTestStr) -> str:
return gugann + delphic_plague
# Call this function with each of the two types listed in the above tuple.
assert hrud(
'Troglydium hruddi', delphic_plague=CustomTestStr('Delphic Sink')) == (
'Troglydium hruddiDelphic Sink')
# ....................{ TESTS ~ pass : return }....................
def test_type_check_pass_return_none() -> None:
'''
Test bear typing of a function call successfully returning `None` and
annotated as such.
'''
# Import this decorator.
from beartype import beartype
# Function to be type checked.
@beartype
def xenos(interex: str, diasporex: str) -> None:
interex + diasporex
# Call this function and assert no value to be returned.
assert xenos(
'Luna Wolves', diasporex='Iron Hands Legion') is None
# ....................{ TESTS ~ fail }....................
def test_beartype_fail_keyword_unknown() -> None:
'''
Test bear typing of an annotated function call passed an unrecognized
keyword parameter.
'''
# Import this decorator.
from beartype import beartype
# Annotated function to be type checked.
@beartype
def tau(kroot: str, vespid: str) -> str:
return kroot + vespid
# Call this function with an unrecognized keyword parameter and assert the
# expected exception.
with pytest.raises(TypeError) as exception:
tau(kroot='Greater Good', nicassar='Dhow')
# For readability, this should be a "TypeError" synopsizing the exact issue
# raised by the Python interpreter on calling the original function rather
# than a "TypeError" failing to synopsize the exact issue raised by the
# wrapper type-checking the original function. Since the function
# annotations defined above guarantee that the exception message of the
# latter will be suffixed by "not a str", ensure this is *NOT* the case.
assert not str(exception.value).endswith('not a str')
def test_beartype_fail_param_name() -> None:
'''
Test bear typing of a function accepting a parameter name reserved for
use by the `@beartype` decorator.
'''
# Import this decorator.
from beartype import beartype
# Define a function accepting a reserved parameter name and assert the
# expected exception.
with pytest.raises(NameError):
@beartype
def jokaero(weaponsmith: str, __beartype_func: str) -> str:
return weaponsmith + __beartype_func
# ....................{ TESTS ~ fail : type }....................
def test_beartype_fail_param_type() -> None:
'''
Test bear typing of an annotated function call failing a parameter type
check.
'''
# Import this decorator.
from beartype import beartype
# Annotated function to be type checked.
@beartype
def eldar(isha: str, asuryan: (str, int)) -> str:
return isha + asuryan
# Call this function with an invalid type and assert the expected exception.
with pytest.raises(TypeError):
eldar('Mother of the Eldar', 100.100)
def test_beartype_fail_return_type() -> None:
'''
Test bear typing of an annotated function call failing a return type
check.
'''
# Import this decorator.
from beartype import beartype
# Annotated function to be type checked.
@beartype
def necron(star_god: str, old_one: str) -> str:
return 60e6
# Call this function and assert the expected exception.
with pytest.raises(TypeError):
necron("C'tan", 'Elder Thing')
# ....................{ TESTS ~ fail : annotation }....................
def test_beartype_fail_annotation_param() -> None:
'''
Test bear typing of a function with an unsupported parameter annotation.
'''
# Import this decorator.
from beartype import beartype
# Assert the expected exception from attempting to type check a function
# with a parameter annotation that is *NOT* a type.
with pytest.raises(TypeError):
@beartype
def nurgle(nurgling: str, great_unclean_one: 'Bringer of Poxes') -> str:
return nurgling + great_unclean_one
def test_beartype_fail_annotation_return() -> None:
'''
Test bear typing of a function with an unsupported return annotation.
'''
# Import this decorator.
from beartype import beartype
# Assert the expected exception from attempting to type check a function
# with a return annotation that is *NOT* a type.
with pytest.raises(TypeError):
@beartype
def tzeentch(disc: str, lord_of_change: str) -> 'Player of Games':
return disc + lord_of_change
@terrdavis
Copy link

I have updated this for compatibility with Python 3.7.
Feel free to update your copy too.
See lines 270 and 277:
https://gist.github.com/terrdavis/1b23b7ff8023f55f627199b09cfa6b24

@terrdavis
Copy link

I have made another update for python 3.5, 3.7 and 3.8 compatibility. It tests for the new error "TypeError: Subscripted generics cannot be used with class and instance checks" by always attempting to use the __origin__ attribute of Generic types for instance checks.
I also did some refactoring.

@MiaoDX
Copy link

MiaoDX commented Apr 8, 2019

Hi, do you prepare to support Union or Optional typing?

@beartype
def test_union(a: Optional[str], b:Union[str, int]):
    print(a, b)

test_union('42', 42)

Will give TypeError: test_union() parameter a type typing.Union[str, NoneType] neither a new-style class nor tuple of such classes

@leycec
Copy link
Author

leycec commented May 21, 2020

beartype 0.1.0 is now live (and maybe worky) on PyPi, much to the astonishment of my weary brainpan: 😪

pip3 install beartype

Partial PEP 484 compliance (e.g., typing.Union, typing.Optional) is planned for the eventual 1.0.x release cycle. Although that may be just a mote in God's eye at the moment, equivalent functionality to:

Thanks again for all the interest, all! Let's make runtime type checking in pure Python the envy of the static type checking world.

@Saphyel
Copy link

Saphyel commented Mar 12, 2021

Could be possible to add an argument to be more spefiic about the type?
I mean like if you want to check if is a word with length between 3 and 12, or if is a positive number, maybe if it's a country code or UUID?

@leycec
Copy link
Author

leycec commented Mar 13, 2021

Excellent questions, @Saphyel. It could and should be! Value constraints like those are common validation concerns, so it'd be great if beartype supported your use case. Would you mind opening a feature request for this on our issue tracker?

Beartype is very PEP-compliant, which means we only support type hints standardized by the Python community. Unfortunately, value constraints have yet to be standardized, which means beartype does not support value constraints...

Yet. Standardization is critical, because:

  • Using PEP-compliant type hints means you can port your code between different static and runtime type checkers without your code breaking.
  • PEP 563 deprecated all PEP-noncompliant type hints (i.e., type hints not compliant with a PEP). To quote PEP 563:

With this in mind, uses for annotations incompatible with the aforementioned PEPs should be considered deprecated.

  • Fortunately, PEP 593 defined a new typing.Annotated type hint, which is now the official standardized way to circumvent PEP 563. The way this works is you list one or more PEP-noncompliant type hints as the second, third, and so on arguments to typing.Annotated. (See below for an example addressing your exact concerns.)

Waiting on the CPython developer community to standardize value constraints could take ages – if it happens at all. Instead, here's what we can do in the meanwhile.

Beartype can add official support for one or more third-party PEP 593-compliant Python packages that provide value constraints. "PEP 593-compliant" is the keyword here. Here's how that might look in code:

from beartype import beartype
from someotherpackage import IntAtLeast, StrLenRange, CountryCode, UUID
from typing import Annotated

@beartype
def check_params(
    word: Annotated[str, StrLenRange[3, 12]],
    number: Annotated[int, IntAtLeast(0)],
    country: Annotated[str, CountryCode],
    uuid: Annotated[str, UUID],
) -> None:
    ...

Is that the sort of thing you were thinking of? Since this will be work, we should probably only support the most popular value constraint package(s). Sadly, I don't actually know what those are. If you'd like to list me a few of your favourites, I'd be happy to see what we can do for you.

At beartype, we aim to please. 🐻

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment