Skip to content

Commit

Permalink
Improve documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
StefanUlbrich committed Jan 30, 2022
1 parent cca7ad6 commit 35a480d
Show file tree
Hide file tree
Showing 5 changed files with 225 additions and 86 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,5 @@ cython_debug/
# Support for Project snippet scope

# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode

publish.sh
185 changes: 139 additions & 46 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,46 @@ without the need of a domain specific language. It helps following the
[design by contract](https://en.wikipedia.org/wiki/Design_by_contract)
paradigm.

This package and more importantly, this documentation, are still **work in progress**.
Don't use it in production yet!
Contracts are useful to impose restrictions and constraints on function arguments in a way that

## Usage
* 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.

The decorator in this package uses
[dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) make the definition of
contracts as simple and natural as possible. Unlike the excellent
[pycontracts](https://github.com/AndreaCensi/contracts) package,
no domain specific language is required. Their definition requires Lambda expressions
which argument names match either the decorated function's arguments or injectable variables.
This way of defining contracts is very powerful and easy to use.
Possible use cases are asserting mutual columns in
[data frames](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html),
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

* [x] Simple to used design by contract. Does not require you to learn a domain specific language necessary.
* [x] Uses python language features only. Some of them recently introduced (i.e., in Python 3.10)
* [x] Preconditions written as lambda functions
* [x] Scope variables can be defined to simplify definition of conditions
* [x] [Dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) based on argument names
* [ ] Postconditions (planned)
* [x] Encourages static typing
* [x] Does not break your type checking & code completion (tested with [mypy](https://mypy.readthedocs.io/en/stable/) and [visual studio code](https://code.visualstudio.com/))
* [x] Uses annotations for defining conditions
* [ ] Optional dynamic type checking (planned)
* [x] Preserves your docstrings (thanks to [`decorator`](https://github.com/micheles/decorator)).
Plays well with [Sphinx](https://www.sphinx-doc.org/en/master/)
* [ ] Method to insert contracts to docstrings (planned). Probably using Jinja templates.
* [x] Small, clean (opinionated) code base
* [x] Implementation in a single file with ~100 lines of code!
* [x] Currently only one runtime dependency!
* [ ] Speed. Well.. maybe it is fast, I haven't tested it yet

## Usage

### Installation

Expand All @@ -34,7 +62,68 @@ To build the package from sources, you need [Poetry](https://python-poetry.org/)
Design-by-contract depends only on the [decorator](https://github.com/micheles/decorator)
package at runtime!

### Quick example
### Dependency injection

The decorator in this package uses
[dependency injection](https://en.wikipedia.org/wiki/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](https://github.com/AndreaCensi/contracts) 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:

```python
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`:

```python
@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:

```python
@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](https://numpy.org/doc/stable/reference/generated/numpy.array.html) as
Expand All @@ -56,63 +145,67 @@ def spam(
array1 = np.array([[4, 5, 6, 8]])
array2 = np.array([[1, 2, 3]])

spam(array1, array2) # or
spam(array1, array2) # or (arguments are resolved correctly)
spam(a=array1,b=array2) # or
spam(b=array2,a=array1)
spam(b=array2,a=array1) # but not
spam(a=array2,b=array1) # raises ValueError
```

The decorator is initialized with a variable `m` that is defined to hold the number of rows of `a`, the first
argument of `spam`. This is achieved by passing a `m` as a keyword with a lambda expression that takes a single
argument `a`. The lambda's argument(s) have to match argument names of `spam`. The contract will then pass
the value of the argument to the lambda expression when `spam` is eventually evaluated.
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`. `Annotated` first accepts the type of
the argument and any following lambda expression that contains the same argument name (in this case, `b`) will
be interpreted as a contract. It must return a boolean value!
All the expressions arguments must have the same name as either an argument of `spam`
or a variable defined in the decorator initialization (i.e., `a`,`b` or `m`). Again, the respective values
are injected by the decorator when the function is evaluated.

### Features

* Full type checking support ([mypy](https://mypy.readthedocs.io/en/stable/) and [visual studio code](https://code.visualstudio.com/))
* No domain specific language necessary
* Contracts are written as Lambda functions
* [Dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) is used to make arguments and variables available in the contracts.
* Implementation in a single file with less than 100 lines!
* Only one runtime dependency!
* Leveraging logging facilities
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
## What's missing?

Currently, contracts for return types (i.e., post conditions) cannot be specified.
The dependency `returns` is reserved already
but using it throws a `NotImplementedError` for now. Implementation, however, is straight forward
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, run-time type checking can be easily integrated.
In the future, optional run-time type checking might be worth considering.

## Why?

I had the idea a while ago when reading the release notes of Python 3.9. It turned out to be a
nice, small Saturday afternoon project and a good opportunity to experiment with features in Python 3.10.
In addition, it has been a good exercise for several aspects of modern Python development and might
serve as an example for new Python developers:
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`](https://docs.python.org/3/library/typing.html#typing.Annotated) (3.9),
* [x] Recent python features: [`typing.Annotation`](https://docs.python.org/3/library/typing.html#typing.Annotated) (3.9),
[`typing.ParamSpec`](https://docs.python.org/3/library/typing.html#typing.ParamSpec) (3.10)
and [`typing.get_annotations()`](get_annotations) (3.10)
* Clean decorator design with the [decorator](https://github.com/micheles/decorator) package
* Project management with [Poetry](https://python-poetry.org/)
* GitHub Actions (TBD)
* Clean code (opinionated), type annotations and unit tests ([pytest](https://docs.pytest.org/en/6.2.x/))
* [x] Clean decorator design with the [decorator](https://github.com/micheles/decorator) package
* [x] Project management with [Poetry](https://python-poetry.org/)
* [x] Clean code (opinionated), commented code, type annotations and unit tests ([pytest](https://docs.pytest.org/en/6.2.x/)). Open for criticism.
* [x] Leveraging logging facilities
* [x] Sensible exceptions
* [x] 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
Expand Down
11 changes: 8 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
[tool.poetry]
name = "design-by-contract"
version = "0.1.0"
description = "Handy decorators to define contracts with dependency injection in Python 3.10 and above"
version = "0.1.1"
description = "Handy decorator to define contracts with dependency injection in Python 3.10 and above"
authors = ["Stefan Ulbrich"]
license = "MIT License"
license = "MIT"
repository = "https://github.com/StefanUlbrich/design-by-contract"
readme = "Readme.md"
packages = [
{ include = "design_by_contract.py", from = "src" },
]
classifiers = [
"Topic :: Software Development :: Documentation",
"Topic :: Software Development :: Quality Assurance"
]

[tool.poetry.dependencies]
python = "^3.10"
Expand Down
43 changes: 24 additions & 19 deletions src/design_by_contract.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import logging
from inspect import get_annotations, getfullargspec
from typing import Annotated, Callable, Dict, TypeVar, ParamSpec, Optional

from typing import Annotated, Callable, Dict, TypeVar, ParamSpec, Optional, Sequence
from decorator import decorator

logger = logging.getLogger(__name__)


_RESERVED = {"pre", "post"}
P = ParamSpec("P")
R = TypeVar("R")


def _contract(
func: Callable[P, R],
definitions: Optional[Dict[str, Callable[..., bool]]] = None,
returns: Callable[..., bool] = None,
variables: Optional[Dict[str, Callable[..., bool]]] = None,
pre: Optional[Sequence[Callable[..., bool]]] = None,
post: Optional[Callable[..., bool]] = None,
*args,
**kw,
) -> R:
Expand All @@ -25,18 +25,23 @@ def _contract(

annotations = get_annotations(func)

if len(_RESERVED.intersection(annotations.keys())) > 0:
raise ValueError(
f"Argument names are not allowed be `{_RESERVED}: {_RESERVED.intersection(annotations.keys())}`"
)

# Resolved function arguments passed to func
argv = {k: v for k, v in zip(annotations.keys(), args)}

injectables = {}

# Definitions are variables extracted from the arguments
# They rules are given to the `contract`` factory
if definitions is not None:
for var, definition in definitions.items():
if variables is not None:
for name, definition in variables.items():
# FIXME mypy error
if not isinstance(definition, Callable): # type: ignore
raise ValueError(f"Expected callable for dependency `{var}`")
if not isinstance(definition, Callable): # type: ignore
raise ValueError(f"Expected callable for dependency `{name}`")

definition_args = getfullargspec(definition).args
# Only argument names that appear in the decorated function are allowed
Expand All @@ -45,7 +50,7 @@ def _contract(
raise ValueError(f"Unkown argument names `{unresolved}`")

# inject arguments into definition function
injectables[var] = definition(*[argv[i] for i in definition_args])
injectables[name] = definition(*[argv[i] for i in definition_args])

# together with the arguments, definitions form the injectables
injectables |= argv
Expand All @@ -57,7 +62,7 @@ def _contract(
if hasattr(annotation, "__metadata__"):
for meta in annotation.__metadata__:
# Only consider lambdas/callables
if isinstance(meta, Callable): # type: ignore
if isinstance(meta, Callable): # type: ignore
meta_args = getfullargspec(meta).args
# Only if the original argument's name is among its argument names
if arg_name in meta_args:
Expand All @@ -83,21 +88,21 @@ def _contract(

result = func(*args, **kw)

if returns is not None:
#TODO
if pre is not None or post is not None:
raise NotImplementedError("Checking return values not yet supported")

return result


def contract(returns: Optional[Callable[...,bool]] = None, **definitions) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Factory that generates the decorator.
Necessary to support arbitrary keyword arguments to the decorator.
See `documentation <https://github.com/micheles/decorator/blob/master/docs/documentation.md#decorator-factories>`_"""
def contract(
pre: Optional[Sequence[Callable[..., bool]]] = None,
post: Optional[Callable[..., bool]] = None,
**variables,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Factory that generates the decorator (necessary for decorator keywords args)"""

def caller(func: Callable[P, R], *args, **kw) -> R:
return _contract(func, definitions, returns, *args, **kw)
return _contract(func, variables, pre, post, *args, **kw)

return decorator(caller)

Expand Down
Loading

0 comments on commit 35a480d

Please sign in to comment.