Handy decorator to define contracts with dependency injection in Python 3.10 and above
Project description
Welcome to design-by-contract
Handy decorator to define contracts with dependency injection in Python 3.10 and above without the need of a domain specific language. It helps following the design by contract paradigm.
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.
Possible use cases are asserting mutual columns in data frames, limiting the value range or checking data types in its columns, checking the dimensions of arrays and tensors, and much more. Note that validation can only occur at runtime!
The first version has been developed in a single afternoon and therefore, this package and more importantly, this documentation, are still 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!
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
- Scope variables can be defined to simplify definition of conditions
- Dependency injection based on argument names
- Postconditions (planned)
- 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 (planned)
- Preserves your docstrings (thanks to
decorator
). Plays well with Sphinx- Method to insert contracts to docstrings (planned). Probably using Jinja templates.
- Small, clean (opinionated) code base
- Implementation in a single file with ~100 lines of code!
- Currently only one runtime dependency!
- Speed. Well.. maybe it is fast, I haven't tested it yet
Usage
Installation
The package is available (or will be shortly) on pypi. Install it with
pip install design-by-contract
To build the package from sources, you need Poetry.
Design-by-contract depends only on the decorator package at runtime!
Dependency injection
The decorator in this package uses dependency injection make the definition of contracts as simple and natural as possible. That means that identifiers used in conditions and must match either argument or contract variable names.
Conditions
Unlike the excellent pycontracts package, no domain specific language is required. Their definition requires Lambda expressions instead which arguments are filled by dependency injection. This way of defining contracts is very powerful and easy to use.
Conditions are defined as lambdas so imagine
a function spam(a: List[int],b: List[str])
, a condition that enforces the same length of both
arguments looks like:
lambda a, b: len(a) == len(b)
Note that the arguments to the lambda have to match the arguments of spam
in order to be injected.
If they cannot be resolved, then a ValueError
will be raised.
Conditions are associated with arguments. Therefore, they have to be specified
together with the type annotations. Since Python 3.10, this is supported with
typing.Annotated
:
@contract
def spam(
a: List[int],
b: Annotated[List[str], lambda a, b: len(a) == len(b)]
)
Important: The argument that is annotated has to appear in the lambda arguments in order to be recognized as a condition! Also, conditions should return a boolean value.
Currently, it is not possible to define conditions in the decorator itself. The pre
and
post
identifiers are reserved for this purpose but are not supported yet.
Contract variables
To organize contracts and increase readability, contract variables that can be used in the
conditions are supported. In above example, the contract variables m
could be assigned to
len(a)
and then be used in the conditions. Contract variables are defined as
keyword arguments to the contract
decorator:
@contract(
m=lambda a: len(a),
)
def spam(
a: Annotated[List[int], lambda a, m: m <= 5], # needs to contain the argument even if unused!
b: Annotated[List[str], lambda b, m: m == len(b)]
)
Complete working example
Consider a function that accepts two numpy arrays as parameters but requires that both have exactly the same numbers of rows. With this package, this can be achieved by the following code.
from typing import Annotated
from design_by_contract import contract
@contract(m=lambda a: a.shape[0])
def spam(
a: np.ndarray,
b: Annotated[np.ndarray, lambda b, m: b.shape == (m, 3)]
) -> None: pass
array1 = np.array([[4, 5, 6, 8]])
array2 = np.array([[1, 2, 3]])
spam(array1, array2) # or (arguments are resolved correctly)
spam(a=array1,b=array2) # or
spam(b=array2,a=array1) # but not
spam(a=array2,b=array1) # raises ValueError
Here, the decorator is initialized with a contract variable definition of m
. It holds the number
of rows of the array a
, the first argument of spam
.
This is achieved by passing a m
as a keyword argument with a lambda expression that takes a single
argument named a
. The lambda's argument(s) have to match argument names of spam
. The contract decorator
will then inject the value of the argument a
into the lambda expression when spam
is eventually evaluated.
The arguments of spam
can be annotated by using typing.Annotated
if there is a condition for them.
Annotated
first requires a type definition. Any following lambda expression that contains the
same argument name (in this case, b
) is interpreted as a contract. The lambdas should return a boolean value!
Note that there can be multiple conditions in the same annotation.
All the expressions arguments must have the same name as either an argument of spam
or a contract variable (i.e., a
,b
or m
). Again, the respective values are injected by the decorator when the function is evaluated.
What's missing?
Currently, contracts for return types (i.e., post conditions) cannot be specified.
The identifier post
is reserved already but using it throws a NotImplementedError
for now.
Implementation, however, is straight forward
(I am accepting pull requests). Documentation can certainly be improved.
In the future, optional run-time type checking might be worth considering.
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 Saturday afternoon 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 Python development and eventually
might serve as an example for new Python developers:
- Recent python features:
typing.Annotation
(3.9),typing.ParamSpec
(3.10) andtyping.get_annotations()
(3.10) - Clean decorator design with the decorator package
- Project management with Poetry
- Clean code (opinionated), commented code, type annotations and unit tests (pytest). Open for criticism.
- Leveraging logging facilities
- Sensible exceptions
- Good documentation (ok, only half a check)
- GitHub Actions
- Sphinx documentation
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.2 (TBP): add Postconditions
- v0.1.1 (2022-01-30): Better documentation
- v0.1.0 (2022-01-29): Initial release
License
MIT License, Copyright 2022 Stefan Ulbrich
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Hashes for design_by_contract-0.1.1-py3-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | dd26e101be5e275f0645eebff97b31309609a0713c0be2abd162d37d4a9d3635 |
|
MD5 | bd3db1190e96fedc9f85cffe99fe0c23 |
|
BLAKE2b-256 | 33a8fb496cd37960ec4118ca3a6ecf3909b9cc1411180946c3c7606849dbb53a |