Skip to content

← Interfaces with Protocols and ABCs step 15 of 26

Medium Framework

Check Number Ranges at Runtime with Annotated

A server's bind(port, backlog) takes two integers. mypy checks that both are int, but not that the port is between 1 and 65535. Annotated lets you attach that range to the type, and a decorator can check it on every call:

Port = Annotated[int, Bounds(1, 65535)]
Backlog = Annotated[int, Bounds(0, 128)]


@check_bounds
def bind(port: Port, backlog: Backlog) -> str: ...


bind(70000, 10)  # mypy accepts this, and check_bounds raises ValueError

Annotated[int, Bounds(1, 65535)] is the type int with a metadata object attached. mypy treats it as a plain int. The Bounds object is there for your own code to read at runtime. bind only builds a string such as "0.0.0.0:80/10". It opens no socket.

Read the metadata

get_type_hints(fn) returns the type of each parameter with the metadata removed. get_type_hints(fn, include_extras=True) keeps it. An Annotated hint then has a __metadata__ tuple that holds the metadata objects in the order you wrote them.

Keep Port and Backlog as plain assignments. A type Port = ... statement would hide the metadata from get_type_hints.

What to write

Write check_bounds with the ParamSpec signature from the previous exercise:

def check_bounds[**P, R](fn: Callable[P, R]) -> Callable[P, R]: ...

When it decorates a function, read the hints with include_extras=True and get inspect.signature(fn). Apply functools.wraps(fn) to the wrapper. On every call, the wrapper:

  1. Binds the arguments with signature.bind(*args, **kwargs), then calls apply_defaults(). If binding fails, its TypeError propagates.
  2. Goes through the bound arguments in the order of the signature, and through the metadata of each one in order. It skips anything that is not a Bounds.
  3. Raises ValueError at the first int value outside the range, both ends included, without calling fn. The message is <name>=<value> outside [<lo>, <hi>], for example port=70000 outside [1, 65535].
  4. Otherwise calls fn once and returns its result.

So when both values are bad, the error names the port, even if the caller passes backlog first as a keyword.

Then write metadata_visible(param). It returns two booleans. They say whether the hint for param on bind has __metadata__ in get_type_hints(bind), and in get_type_hints(bind, include_extras=True). Both "port" and "backlog" give (False, True). An unknown name raises KeyError.

Write the driver

solve(ports, backlogs) pairs the lists with zip(..., strict=True) and calls bind on each pair, in order. It appends the result, or f"rejected:{exc}" when bind raises ValueError, and moves on. Lists of different lengths must still raise the ValueError from zip. Return the results and the two flags from metadata_visible("port"):

assert solve([80, 0, 443], [10, 5, 129]) == {
    "results": ["0.0.0.0:80/10", "rejected:port=0 outside [1, 65535]",
                "rejected:backlog=129 outside [0, 128]"],
    "plain_has_metadata": False,
    "extras_has_metadata": True,
}

See Annotated in the Python documentation.