Package: design-by-contract
Welcome todesign-by-contractA minimalistic decorator for thedesign by contract patternwritten 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 thatreduces 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 withpipinstalldesign-by-contractWarningThis project started as a weekend project to learn recent additions to the language (typing.Annotatedandtyping.ParamSpec, thewalrus operator,pattern matchingand others). This means also that this package and its documentation should be considered aswork 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!ApplicationThe decorator has been mainly designed withnumpy arraysandpandas DataFramesin mind but can be universally applied.
Contracts are defined as lambda functions that are attached to the function arguments via thenew Annotated typethat allows adding additional information
to the arguments' and return value's type hint. Arguments are inserted into the lambda viadependency injectionand working with
symbols to increase readability is supported.Let's look at an example for for matrix multiplication!fromtypingimportAnnotatedimportnumpyasnpfromdesign_by_contractimportcontract@contractdefspam(first:Annotated[np.ndarray,lambdafirst,m,n:(m,n)==first.shape],# symbols m and n represent the shape of `a`second:Annotated[np.ndarray,lambdasecond,n,o:(n,o)==second.shape],# `b` number of columns matches the number of rows of `a`)->Annotated[np.ndarray,lambdax,m,o:x.shape==(m,o)]:# `x` holds the return value. The shape of `x` must equal `x` times `o`"""Matrix multiplication"""returna@bContracts are lambdas with one argument named like the annotated argument. Alternatively,xcan be used as a shortcut which means
that you cannot usexas a function argument unless you choose another reserved (using thereservedargumentcontractordecorator).@contract(reserved='y')defspam(first:Annotated[np.ndarray,lambday,m,n:(m,n)==y.shape],second:Annotated[np.ndarray,lambday,n,o:(n,o)==y.shape],)->Annotated[np.ndarray,lambday,m,o:y.shape==(m,o)]:"""Matrix multiplication"""returna@bSymbolic  calculus is supported to certain degree to make your life easier. The symbolsm,nandoare 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 doesnotintend 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 ofUnresolvedSymbolthat overload this operator. As a consequence, each symbol, therefore has to be first appear in an equality before it can be usedin a differentlambda expression!The following example will raise an error for instance:@contractdefspam(a:Annotated[np.ndarray,lambdax,m,n:(m,n)==x.shapeandm>2],# you cannot "assign" and use `m` in the same lambda#  Annotated[np.ndarray, lambda x, m, n: (m, n) == x.shape, lambda x, m:  m > 2] # this would workb:Annotated[np.ndarray,lambdax,n,o:(n,o)==x.shape],)->Annotated[np.ndarray,lambdax,m,o:x.shape==(m,o)]:returna@bspam(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 likesympyor 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 excellentpycontractspackage, 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):@contractdefspam(a:Annotated[np.ndarray,lambdax,m,o:(m,o)==x.shape],b:Annotated[np.ndarray,lambdax,n,o:(n,o)==x.shape],)->Annotated[np.ndarray,lambdax,m,n,o:x.shape==(m+n,o)]:print(np.vstack((a,b)).shape)returnnp.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:@contractdefspam(a:Annotated[pd.DataFrame,lambdax,c:c=={'C','B'},# `x` or the argument name must be passed to the lambdalambdax,c:c.issubset(x.columns)# Remember, we need to use two lambdas here!],b:Annotated[pd.DataFrame,lambdax,c:c<=set(x.columns)# equivalent to `issubset` but more elegant])->Annotated[pd.DataFrame,lambdax,c:c<=set(x.columns)]:"""Matrix multiplication"""returnpd.merge(a,b,on=['B','C'])spam(a,b)Note that evaluation is not optimized. In production, you might consider disabling evaluation by passingevaluate=Falseas a parameter to thecontractdecorator.FeaturesSimple 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 functionsAdditional symbols can be used to achieve compact contractsDependency injectionbased on argument namesPre- and PostconditionsEncourages static typingDoes not break your type checking & code completion (tested withmypyandvisual studio code)Uses annotations for defining conditionsOptional dynamic type checkingPreserves your docstrings (thanks todecorator).
Plays well withSphinxSmall, clean (opinionated) code baseImplementation in a single file with ~100 lines of code!Currently only one runtime dependency!Documentation usingsphinx,mystandsphinx bookTested with pytestType annotationscode formatted (black), linted (pylint). Linting withmypydoes not support pattern matching yet.GitHub action for GitHub pagesGitHub action for linting and formattingPrecommit for linting and formattingSpeed. Well.. maybe. I haven't tested it yet.Why?I had the idea a while ago when reading abouttyping.Annotatedin 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.Related (active) projectsIt appears that the related (still active) projects have significantly larger code bases
(include parsers for a domain-specific language, automated testing, etc.) but also try to achieve
additional and wider goals (automated testing, pure functions, etc.). The main strength
of this project, in my opinion, lies in its compact codebase and intuitiveness of the
dependency injection.PyContracts.
Originally inspired this project. Although it requires a domain specific language, it supports implicitly defining variables for array shapes (see below). This package tries to achieve
a similar goal in pure Python but it requires a formal definition of variables.@contract@contract(a='list[ M ](type(x))',b='list[ N ](type(x))',returns='list[M+N](type(x))')defmy_cat_equal(a,b):''' Concatenate two lists together. '''returna+bicontractanddeal:
Rely on conditions defined as lambdas much like this Project. They don't use theAnnotatedsyntax
and their codebases are significantly larger.ContributionsPull requests are welcome!Changelogv0.3.0 (2022-06-17): Remove dependency to untypeddecorator, add fully typed replacementv0.2.2 (2022-06-16): Bug Fixes and passing Mypy in strict mode (thanks Alex Povel)v0.2 (2022-03-05): Simple symbolic supportv0.1.1 (2022-01-30): Better documentationv0.1.0 (2022-01-29): Initial releaseLicenseMIT License, Copyright 2022 Stefan Ulbrich