7

I'm aware there's this new typing format Annotated where you can specify some metadata to the entry variables of a function. From the docs, you could specify the maximum length of a incoming list such as:

  • Annotated can be used with nested and generic aliases:
T = TypeVar('T')
Vec = Annotated[list[tuple[T, T]], MaxLen(10)]
V = Vec[int]

V == Annotated[list[tuple[int, int]], MaxLen(10)]

But I cannot finish to comprehend what MaxLen is. Are you supposed to import a class from somewhere else? I've tried importing typing.MaxLen but doesn't seems to exists (I'm using Python 3.9.6, which I think it should exist here...?).

Example code of what I imagined it should have worked:

from typing import List, Annotated, MaxLen

def function(foo: Annotated[List[int], MaxLen(10)]):
    # ...
    return True

Where can one find MaxLen?

EDIT:

It seems like MaxLen is some sort of class you have to create. The problem is that I cannot see how you should do it. Are there public examples? How can someone implement this function?

Alex Waygood
  • 6,304
  • 3
  • 24
  • 46
Daniel Azemar
  • 478
  • 7
  • 19
  • 3
    Those are just examples, demonstrating what can be done. See [this question](https://stackoverflow.com/questions/65385585/where-are-the-type-annotation-constraints-valuerange-minlen-etc-in-python) for a similar discussion. – Selcuk Jul 20 '21 at 11:42

1 Answers1

9

As stated by AntiNeutronicPlasma, Maxlen is just an example so you'll need to create it yourself.

Here's an example for how to create and parse a custom annotation such as MaxLen to get you started.

First, we define the annotation class itself. It's a very simple class, we only need to store the relevant metadata, in this case, the max value:

class MaxLen:
    def __init__(self, value):
        self.value = value

Now, we can define a function that uses this annotation, such as the following:

def sum_nums(nums: Annotated[List[int], MaxLen(10)]):
    return sum(nums)

But it's going to be of little use if nobody checks for it. So, one option could be to implement a decorator that checks your custom annotations at runtime. The functions get_type_hints, get_origin and get_args from the typing module are going to be your best friends. Below is an example of such a decorator, which parses and enforces the MaxLen annotation on list types:

from functools import wraps
from typing import get_type_hints, get_origin, get_args, Annotated

def check_annotations(func):
    @wraps(func)
    def wrapped(**kwargs):
        # perform runtime annotation checking
        # first, get type hints from function
        type_hints = get_type_hints(func, include_extras=True)
        for param, hint in type_hints.items():
            # only process annotated types
            if get_origin(hint) is not Annotated:
                continue
            # get base type and additional arguments
            hint_type, *hint_args = get_args(hint)
            # if a list type is detected, process the args
            if hint_type is list or get_origin(hint_type) is list:
                for arg in hint_args:
                    # if MaxLen arg is detected, process it
                    if isinstance(arg, MaxLen):
                        max_len = arg.value
                        actual_len = len(kwargs[param])
                        if actual_len > max_len:
                            raise ValueError(f"Parameter '{param}' cannot have a length "
                                             f"larger than {max_len} (got length {actual_len}).")
        # execute function once all checks passed
        return func(**kwargs)

    return wrapped

(Note that this particular example only works with keyword arguments, but you could probably find a way to make it work for normal arguments too).

Now, you can apply this decorator to any function, and your custom annotation will get parsed:

from typing import Annotated, List

@check_annotations
def sum_nums_strict(nums: Annotated[List[int], MaxLen(10)]):
    return sum(nums)

Below is an example of the code in action:

>>> sum_nums(nums=list(range(5)))
10
>>> sum_nums(nums=list(range(15)))
105
>>> sum_nums_strict(nums=list(range(5)))
10
>>> sum_nums_strict(nums=list(range(15)))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "annotated_test.py", line 29, in wrapped
    raise ValueError(f"Parameter '{param}' cannot have a length "
ValueError: Parameter 'nums' cannot have a length larger than 10 (got length 15).
Alex Waygood
  • 6,304
  • 3
  • 24
  • 46
kikones34
  • 371
  • 9
  • 13
  • The whole point of the "typing" module is static hints. What you are showing is dynamic (runtime) checks. Can you provide an evidence that it's the intended way of use (maybe a link to docs, pep or something)? – MMM Mar 22 '22 at 13:41
  • 3
    PEP 593: "This PEP adds an Annotated type to the typing module to decorate existing types with context-specific metadata. (...) This metadata can be used for either static analysis or at runtime." As far as I am aware, no IDE currently implements any static analysis using Annotated type hints, nor does it seem feasible for the given examples (list length, integer range...), since their values will generally only be known at runtime. – kikones34 Mar 22 '22 at 14:15
  • Thanks for the clarification. I think it would be possible to implement static type checks for things like list length for simple cases where we know the length for sure. For example, when we define a list using a literal. Anyway, this is indeed too much to ask from Python (and its type checkers), unfortunately. – MMM Mar 24 '22 at 13:24