-
-
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.Optionalis already available with the@beartype-specificNoneTypeOrtuple factory.typing.Unionis 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
randomis 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
poetryfor Gentoo Linux and faceplanting into unexpected caltrops. I still believe thatpoetryis 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@beartypewill surely be ignited for all to bask in. 🔥