Welcome to design-by-contract

A minimalistic decorator for the design by contract pattern written in a just little more than 100 lines of modern Python 3.10 code (not counting documentation and logging).

Contracts are useful to impose restrictions and constraints on function arguments in a way that

  • reduces boilerplate for argument validation in the function body (no more if blocks that raise value errors),

  • are exposed in the function signature, that is, they serve as a means of documentation that is always up-to-date,

  • allow relations between arguments.

Install with

pip install design-by-contract

Warning

This project started as a weekend project to learn recent additions to the language (typing.Annotated and typing.ParamSpec, the walrus operator, pattern matching and others). This means also that this package and its documentation should be considered as work in progress. You probably shouldn’t use it in production yet! But if you do, let me know how it went. Please leave a star if you like this project!

Application

The decorator has been mainly designed with numpy arrays and pandas DataFrames in mind but can be universally applied. Contracts are defined as lambda functions that are attached to the function arguments via the new Annotated type that allows adding additional information to the arguments’ and return value’s type hint. Arguments are inserted into the lambda via dependency injection and working with symbols to increase readability is supported.

Let’s look at an example for for matrix multiplication!

from typing import Annotated
import numpy as np
from design_by_contract import contract

@contract
def spam(
    first: Annotated[np.NDArray[np.floating[Any]], lambda first, m, n: (m, n) == first.shape], # symbols m and n represent the shape of `a`
    second: Annotated[np.NDArray[np.floating[Any]], lambda second, n, o: (n, o) == second.shape], # `b` number of columns matches the number of rows of `a`
) -> Annotated[np.NDArray[np.floating[Any]], lambda x, m, o: x.shape == (m, o)]: # `x` holds the return value. The shape of `x` must equal `x` times `o`
    """Matrix multiplication"""
    return first @ second

Contracts are lambdas with one argument named like the annotated argument. Alternatively, x can be used as a shortcut which means that you cannot use x as a function argument unless you choose another reserved (using the reserved argument contractor decorator).

@contract(reserved='y')
def spam(
    first: Annotated[np.NDArray[np.floating[Any]], lambda y, m, n: (m, n) == y.shape],
    second: Annotated[np.NDArray[np.floating[Any]], lambda y, n, o: (n, o) == y.shape],
) -> Annotated[np.NDArray[np.floating[Any]], lambda y, m, o: y.shape == (m, o)]:
    """Matrix multiplication"""
    return first @ second

Symbolic calculus is supported to certain degree to make your life easier. The symbols m, n and o are defined in a way that

\[ \text spam: R^{m \times x} \times R^{n\times o} \rightarrow R^{m\times o} \]

Note however, that this package does not intend to be a symbolic calculus package and therefore, there are some strong limitations.

Python does not allow for assignments (=) in a lambda expression and therefore, the equality operator (==) is chosen to act a replacement. Unknown arguments are replaced under the hood by an instance of UnresolvedSymbol that overload this operator. As a consequence, each symbol, therefore has to be first appear in an equality before it can be used in a different lambda expression!

The following example will raise an error for instance:

@contract
def spam(
    a: Annotated[np.NDArray[np.floating[Any]], lambda x, m, n: (m, n) == x.shape and m > 2], # you cannot "assign" and use `m` in the same lambda
    #  Annotated[np.NDArray[np.floating[Any]], lambda x, m, n: (m, n) == x.shape, lambda x, m:  m > 2] # this would work
    b: Annotated[np.NDArray[np.floating[Any]], lambda x, n, o: (n, o) == x.shape],
) -> Annotated[np.NDArray[np.floating[Any]], lambda x, m, o: x.shape == (m, o)]:
    return a @ b

spam(a, b) # raises: '>' not supported between instances of 'UnresolvedSymbol' and 'int'

This design decision is arguably unclean but allows for elegant contract expressions and a very clean and compact implementation. Different approaches involving symbolic algebra packages like sympy or parsing a syntax trees were considered but turned out to be too complex to implement. The next best alternative is using a domain-specific language (DLS) as done in the excellent pycontracts package, which actually inspired this project. By using python, calculus in the contract can be arbitrarily complex without the need for extending the DSL (i.e., including python functions):

@contract
def spam(
    a: Annotated[np.NDArray[np.floating[Any]], lambda x, m, o: (m, o) == x.shape],
    b: Annotated[np.NDArray[np.floating[Any]], lambda x, n, o: (n, o) == x.shape],
) -> Annotated[np.NDArray[np.floating[Any]], lambda x, m,n,o: x.shape == (m+n, o)]:
    print(np.vstack((a,b)).shape)
    return np.vstack((a,b))
spam(np.zeros((3, 2)), np.zeros(( 4, 2)))

The decorator is also quite handy for being used with pandas data frames:

@contract
def spam(a: Annotated[pd.DataFrame,
                      lambda x, c: c == {'C','B'}, # `x` or the argument name must be passed to the lambda
                      lambda x, c: c.issubset(x.columns) # Remember, we need to use two lambdas here!
                     ],
         b: Annotated[pd.DataFrame,
                      lambda x, c: c <= set(x.columns) # equivalent to `issubset` but more elegant
                     ]
        ) -> Annotated[pd.DataFrame,
                       lambda x, c: c <= set(x.columns)]:
    """Matrix multiplication"""
    return pd.merge(a,b,on=['B','C'])

spam(a, b)

Note that evaluation is not optimized. In production, you might consider disabling evaluation by passing evaluate=False as a parameter to the contract decorator.

Features

  • Simple to used design by contract. Does not require you to learn a domain specific language necessary.

    • Uses python language features only. Some of them recently introduced (i.e., in Python 3.10)

    • Preconditions written as lambda functions

    • Additional symbols can be used to achieve compact contracts

    • Dependency injection based on argument names

    • Pre- and Postconditions

  • Encourages static typing

    • Does not break your type checking & code completion (tested with mypy and visual studio code)

    • Uses annotations for defining conditions

    • Optional dynamic type checking

  • Preserves your docstrings (thanks to decorator). Plays well with Sphinx

  • Small, clean (opinionated) code base

    • Implementation in a single file with ~100 lines of code!

    • Currently only one runtime dependency!

    • Documentation using sphinx, myst and sphinx book

    • Tested with pytest

    • Type annotations

    • code formatted (black), linted (pylint). Linting with mypy does not support pattern matching yet.

    • GitHub action for GitHub pages

    • GitHub action for linting and formatting

    • Precommit for linting and formatting

  • Speed. Well.. maybe. I haven’t tested it yet.

Why?

I had the idea a while ago when reading about typing.Annotated in the release notes of Python 3.9. Eventually, it turned out to be a nice, small Weekend project and a welcomed opportunity to experiment with novel features in Python 3.10. In addition, it has been a good exercise to practice several aspects of modern and clean Python development and eventually might serve as an example for new Python developers:

If you think it’s cool, please leave a star. And who knows, it might actually be useful.

Contributions

Pull requests are welcome!

Changelog

  • v0.3.0 (2022-06-17): Remove dependency to untyped decorator, add fully typed replacement

  • v0.2.2 (2022-06-16): Bug Fixes and passing Mypy in strict mode (thanks Alex Povel)

  • v0.2 (2022-03-05): Simple symbolic support

  • v0.1.1 (2022-01-30): Better documentation

  • v0.1.0 (2022-01-29): Initial release

License

MIT License, Copyright 2022 Stefan Ulbrich

API

exception design_by_contract.ContractLogicError[source]

Raised when there is a syntactical error

exception design_by_contract.ContractViolationError[source]

Raised when a contract is violated

class design_by_contract.UnresolvedSymbol(name: str, value: Optional[Any] = None)[source]

Placeholder for unknown symbols in contracts.

Overrides the equality operator to behave like an assignment.

design_by_contract.contract(func: Callable[[P], design_by_contract.R]) Callable[[P], design_by_contract.R][source]
design_by_contract.contract(*, reserved: str = "'x'", evaluate: bool = 'True') Callable[[Callable[[P], design_by_contract.R]], Callable[[P], design_by_contract.R]]

A decorator for enabling design by contract using typing.Annotated.

Define contract conditions as lambdas together with their type annotation. The decorator is overloaded so you can call it eigher with @contract or @contract(…) with our without arguments. Note that positional keywords are not allowed (i.e., you need to use keyword arguments)

Parameters
  • reserved (str, optional) – This symbol gets always replaced by the current argument name, by default “x”. This is a keyword only argument.

  • evaluate (bool, optional) – If False, the contracts are not evaluated, by default True. This is a keyword only argument.