-
-
Save terrdavis/1b23b7ff8023f55f627199b09cfa6b24 to your computer and use it in GitHub Desktop.
#!/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): | |
missing = object() | |
''' | |
# "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 = ( | |
"getattr(__beartype_func.__annotations__[{0!r}], '__origin__', None) " | |
"or __beartype_func.__annotations__[{0!r}]".format(func_arg.name)) | |
# String evaluating to this parameter's current value when | |
# passed as a keyword. | |
func_arg_value_key_expr = 'kwargs.get({!r}, missing)'.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 += ''' | |
{arg_name}_type = {arg_type_expr} | |
{arg_name}_value = {arg_value_key_expr} | |
if {arg_name}_value is not missing and not isinstance({arg_name}_value, {arg_name}_type): | |
raise TypeError( | |
'{func_name} keyword-only parameter {arg_name}={{}} not a {{!r}}'.format( | |
{arg_name}_value, {arg_name}_type)) | |
'''.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 += ''' | |
type_{arg_index} = {arg_type_expr} | |
value_{arg_index} = {arg_value_pos_expr} if {arg_index} < len(args) else {arg_value_key_expr} | |
if value_{arg_index} is not missing and not isinstance(value_{arg_index}, type_{arg_index}): | |
raise TypeError('{func_name} parameter {arg_name}={{}} not of {{!r}}'.format( | |
value_{arg_index}, type_{arg_index})) | |
'''.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 = ( | |
"getattr(__beartype_func.__annotations__['return'], '__origin__', None) " | |
"or __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) | |
return_type = {return_type} | |
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 ( | |
hasattr(member, '__origin__') or | |
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 ( | |
hasattr(annotation, '__origin__') or | |
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') | |
def test_type_check_pass_typing_module() -> None: | |
''' | |
Test bear typing of a function call successfully passed a parameter | |
annotated with an abstract type from the typing module. | |
''' | |
from beartype import beartype | |
import typing | |
MyMap = typing.Mapping | |
@beartype | |
def function(par: MyMap, ameter: MyMap) -> MyMap: | |
result = par.copy() | |
result.update(ameter) | |
return result | |
assert function({1:1}, {2:2}) == {1:1, 2:2} | |
def test_type_check_pass_parameterized_typing_module() -> None: | |
''' | |
Test bear typing of a function call successfully passed a parameter | |
annotated with a parametirized abstract type from the typing module. | |
''' | |
from beartype import beartype | |
import typing | |
MyMap = typing.Mapping[str, int] | |
@beartype | |
def function(par: MyMap, ameter: MyMap) -> MyMap: | |
result = par.copy() | |
result.update(ameter) | |
return result | |
assert function({1:1}, {2:2}) == {1:1, 2:2} | |
# ....................{ 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 |
...I no longer believe that poetry
is the only sane path forward for Python packaging. In fact, I now believe that poetry
is fundamentally insane. Why? Because poetry
itself is hostile to packaging by system package managers (e.g., apt
, brew
, emerge
, pacman
). Thanks to that, @beartype
will (at least initially) leverage the standard setuptools
workflow. I may hate setuptools
, but I hate poetry
even more. So much rage. Cue bloody-face Doom guy.
Expect sweet, sweet GitHub action on Monday. For now, we play video games and consume legal refreshments. Friday night, I summon you!
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:
typing.Optional
is already available with the@beartype
-specificNoneTypeOr
tuple factory.typing.Union
is already available with@beartype
-specific tuple unions.
Thanks again for all the interest, all! Let's make runtime type checking in pure Python the envy of the static type checking world.
Right? Now I know that
random
is so randomly balls-to-the-walls that I no longer trust it. Oh, precious ignorance: how I sorely miss thee.[Mildly on-topic] I'm currently packaging
poetry
for Gentoo Linux and faceplanting into unexpected caltrops. I still believe thatpoetry
is the only sane path forward for Python packaging, but... dey sure ain't makin' a brother's life any easier. Once I've dispatched that temporary villain, the cleansing flame of@beartype
will surely be ignited for all to bask in. 🔥