2
\$\begingroup\$

Answering a question on S/O about type annotations led to me writing a little decorator that adds type checking to functions with annotated arguments. It supports the meta types Any, Optional[], and Union[], as well as combinations such as Optional[Union[...]]. It takes no arguments itself, for ease of use.

I'm fairly new to non-SQL programming in general so I'd love feedback. There's a short README in its Github repo.

from typing import Callable, Union, Optional
from functools import wraps
from itertools import zip_longest
import re


def enforce_types(func: Callable) -> Callable:
    """Adds run-time type checking to a fully-annotated function"""

    @wraps(func)
    def wrapper(*args, **kwargs):

        for argument, annotation, value in zip_longest(
            *zip(*func.__annotations__.items()), args,
            fillvalue=""
        ):
            # If we have Optional args, we could have more annotations than supplied arguments
            if value == "":
                break
            # Type Any can take... any type, so continue
            elif annotation == "Any":
                continue

            value_type = type(value).__name__

            # Check for meta types
            is_optional: bool = "Optional" in str(annotation)
            is_union: bool = "Union" in str(annotation)

            # If Optional[], get the optional type
            if is_optional:
                annotation = re.search("Optional\[(.*)\]", str(annotation)).group(1)

            # If Union[], get the list of types
            if is_union:
                annotation = re.search("Union\[(.*)\]", str(annotation)).group(1)

            # If there's just one type allowed, check against it
            if not is_union and value_type != annotation.__name__:
                raise TypeError(
                    f"Argument {argument} supplied wrong type: expected {annotation.__name__}, got {value_type}."
                )
            # If it's not a Union and it passed the last check,
            # or if it's a Union and a valid type was passed in, continue
            elif (not is_union) or (is_union and value_type in annotation):
                continue
            # If there's a type Union, check against the possible types
            elif is_union and value_type in annotation:
                continue
            # If we get here, the argument isn't any of the appropriate values
            else:
                raise TypeError(
                    f"Argument {argument} supplied wrong type: expected one of [{annotation}], got {value_type}"
                )

        return func(*args, **kwargs)

    return wrapper

Example of usage:

>>> from type_enforcer import enforce_types
>>> from typing import Union, Optional

>>> @enforce_types
... def foo(x: Union[int, float], y: Optional[int] = 1) -> int:
...     return int(x * y)

>>> foo(5, 2)
10

>>> foo(5)
5

>>> foo(5.)
5

>>> foo(5., 2)
10

>>> foo('5')
Traceback (most recent call last):
  ...
TypeError: Argument x supplied wrong type: expected one of [int, float], got str

>>> foo(5, 2.)
Traceback (most recent call last):
  ...
TypeError: Argument y supplied wrong type: expected one of [int, NoneType], got float
\$\endgroup\$
3
  • 1
    \$\begingroup\$ I once did something like this - to implement overloads. Thats even more unpythonic than this. A great help for this is the inspect module. It may do a few things for you, with it's functions, but my greatest gain was reading it's source code for gotcha's and how iteration over perhaps-positional-perhaps keyword arguments can be done. \$\endgroup\$
    – Gloweye
    Oct 30, 2019 at 16:01
  • 1
    \$\begingroup\$ For anything more than a POC you should use either a dedicated library such as pytypes. Or if you want to roll your own use typing_inspect. Rolling your own typing_inspect is not simple if you want to support all versions of Python. \$\endgroup\$
    – Peilonrayz
    Oct 30, 2019 at 16:02
  • \$\begingroup\$ @Peilonrayz I'm of course not going to use this in production, it's an experiment/learning exercise :) But I'd like to not have learned the wrong things while doing it, so here I am. \$\endgroup\$
    – Andy
    Oct 30, 2019 at 16:07

1 Answer 1

4
\$\begingroup\$

This is a very very poor implementation.

  • Firstly the code has little care for nested datatypes, and makes them a pain to add in a future version.

    To add support for this all that needs to be done is change the, per type, control flow to use recursion. The option to use standard loops rather than recursion is available too, however that will likely make the code harder to read.

  • The code just looks very error prone. Whilst RegEx has it's place in some limited scenarios, this is not then. The following snippet is almost never a good idea.

    is_optional: bool = "Optional" in str(annotation)
    if is_optional:
        annotation = re.search("Optional\[(.*)\]", str(annotation)).group(1)
    

    This is the kind of nonsense I would expect of a junior that just learnt what RegEx is.

  • Whilst 'proper' low level typing inspection isn't that readable, mangling it with strings makes the code harder to read for people used to interacting with typing.

    Also, when performing low level changes on core Python libraries read the PEPs that accompany them. They're their for a reason. For instance, PEP 484 states how to remove the need for using strings.

    The string literal should contain a valid Python expression (i.e., compile(lit, '', 'eval') should be a valid code object) and it should evaluate without errors once the module has been fully loaded. The local and global namespace in which it is evaluated should be the same namespaces in which default arguments to the same function would be evaluated.

    Furthermore, reading the PEP is not required, as the typing documentation shows a function that does this out of the box.

    typing.get_type_hints(obj[, globals[, locals]])
    Return a dictionary containing type hints for a function, method, module or class object.

Whilst I can understand that hacking a solution may be the most fun way to implement something. It's how I learnt the typing library. You should at least read the documentation of the module. Python's documentation is very high-quality, and ignoring it leads to poor code.

Here's a naive implementation:

import typing
import inspect


def is_type(instance, type_info):
    if type_info == typing.Any:
        return True
    if hasattr(type_info, '__origin__'):
        if type_info.__origin__ in {typing.Union, typing.Optional}:
            return any(is_type(instance, arg) for arg in type_info.__args__)
    return isinstance(instance, type_info)


def enforce_types(func):
    types_info = typing.get_type_hints(func)
    signature = inspect.signature(func)

    def inner(*args, **kwargs):
        sig = signature.bind(*args, **kwargs)
        sig.apply_defaults()
        for name, value in sig.arguments.items():
            if not is_type(value, types_info.get(name, typing.Any)):
                raise TypeError(
                    '{name} is not of the correct type'
                    .format(name=name)
                )
        return func(*args, **kwargs)

    return inner
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.