跳转到主要内容

Python合同的一个简单实现。

项目描述

简介

此模块提供了一组装饰器,使得编写使用合同编写软件变得简单。

合同是一种调试和验证工具。它们是关于程序在运行时必须处于何种状态的声明性语句,以被认为是“正确”的。它们类似于断言,并在程序中定义良好的各个点上自动验证。合同可以指定在函数和类上。

合同作为文档的一种形式,以及正式指定程序行为的一种方式。良好的实践通常包括首先编写所有合同,这些合同指定了每个函数或方法调用之前和之后的精确预期状态,以及给定类对象应该始终为真的事物。

合同由两部分组成:描述和条件。描述是简单地描述合同正在测试的内容的可读字符串,而条件是测试该条件的单个函数。条件将自动执行并传递某些参数(这些参数取决于合同类型),必须返回布尔值:如果条件已满足,则返回True,否则返回False。

对旧版Python的支持

此模块支持Python >= 3.5的版本;即支持“async def”函数的版本。此模块有一个分支与Python 3.5之前的版本(包括Python 2.7)尽可能保持兼容。

Python 2和<= 3.5的分支可在以下位置找到:https://github.com/deadpixi/contracts/tree/python2

此与旧版本兼容的版本也随0.5.x分支发布在PyPI上;这个分支将尽可能与较新版本保持兼容。

该分支是该模块的直接替代品,包含除了对“async def”函数的支持之外的所有功能。

前置条件和后置条件

函数的契约由前置条件和后置条件组成。前置条件使用requires装饰器声明,描述函数进入时必须满足的条件。条件函数传递一个参数对象,其属性为装饰的函数的参数

>>> @require("`i` must be an integer", lambda args: isinstance(args.i, int))
... @require("`j` must be an integer", lambda args: isinstance(args.j, int))
... def add2(i, j):
...   return i + j

注意,可以堆叠任意数量的前置条件。

这些装饰器已声明参数的类型必须是整数。用正确的参数类型调用add2函数可以正常工作

>>> add2(1, 2)
3

但是,使用错误的参数类型(违反了契约)将因PreconditionError(AssertionError的子类型)而失败

>>> add2("foo", 2)
Traceback (most recent call last):
PreconditionError: `i` must be an integer

函数还可以有后置条件,使用ensure装饰器指定。后置条件描述函数成功返回后必须满足的条件。与require装饰器一样,ensure装饰器也传递一个参数对象。它还传递一个额外的参数,即函数调用的结果。例如

>>> @require("`i` must be a positive integer",
...          lambda args: isinstance(args.i, int) and args.i > 0)
... @require("`j` must be a positive integer",
...          lambda args: isinstance(args.j, int) and args.j > 0)
... @ensure("the result must be greater than either `i` or `j`",
...         lambda args, result: result > args.i and result > args.j)
... def add2(i, j):
...     if i == 7:
...        i = -7 # intentionally broken for purposes of example
...     return i + j

我们现在可以调用该函数并确保一切正常工作

>>> add2(1, 3)
4

但该函数以意想不到的方式损坏了

>>> add2(7, 4)
Traceback (most recent call last):
PostconditionError: the result must be greater than either `i` or `j`

指定条件的函数不必是lambda;它可以是任何函数,前置条件和后置条件实际上不必引用函数的参数或结果。它们可以简单地检查函数的环境和效果

>>> names = set()
>>> def exists_in_database(x):
...   return x in names
>>> @require("`name` must be a string", lambda args: isinstance(args.name, str))
... @require("`name` must not already be in the database",
...          lambda args: not exists_in_database(args.name.strip()))
... @ensure("the normalized version of the name must be added to the database",
...         lambda args, result: exists_in_database(args.name.strip()))
... def add_to_database(name):
...     if name not in names and name != "Rob": # intentionally broken
...         names.add(name.strip())
>>> add_to_database("James")
>>> add_to_database("Marvin")
>>> add_to_database("Marvin")
Traceback (most recent call last):
PreconditionError: `name` must not already be in the database
>>> add_to_database("Rob")
Traceback (most recent call last):
PostconditionError: the normalized version of the name must be added to the database

支持Python的所有各种调用约定

>>> @require("`a` is an integer", lambda args: isinstance(args.a, int))
... @require("`b` is a string", lambda args: isinstance(args.b, str))
... @require("every member of `c` should be a boolean",
...          lambda args: all(isinstance(x, bool) for x in args.c))
... def func(a, b="Foo", *c):
...     pass
>>> func(1, "foo", True, True, False)
>>> func(b="Foo", a=7)
>>> args = {"a": 8, "b": "foo"}
>>> func(**args)
>>> args = (1, "foo", True, True, False)
>>> func(*args)
>>> args = {"a": 9}
>>> func(**args)
>>> func(10)

一个常见的契约是验证参数的类型。为此,还有一个额外的装饰器types,可以用来验证参数的类型

>>> class ExampleClass:
...     pass
>>> @types(a=int, b=str, c=(type(None), ExampleClass)) # or types.NoneType, if you prefer
... @require("a must be nonzero", lambda args: args.a != 0)
... def func(a, b, c=38):
...     return " ".join(str(x) for x in [a, b])
>>> func(1, "foo", ExampleClass())
'1 foo'
>>> func(1.0, "foo", ExampleClass) # invalid type for `a`
Traceback (most recent call last):
PreconditionError: the types of arguments must be valid
>>> func(1, "foo") # invalid type (the default) for `c`
Traceback (most recent call last):
PreconditionError: the types of arguments must be valid

类契约

requireensure装饰器可以用于类方法,而不仅仅是裸函数

>>> class Foo:
...     @require("`name` should be nonempty", lambda args: len(args.name) > 0)
...     def __init__(self, name):
...         self.name = name
>>> foo = Foo()
Traceback (most recent call last):
TypeError: __init__ missing required positional argument: 'name'
>>> foo = Foo("")
Traceback (most recent call last):
PreconditionError: `name` should be nonempty

类还可以有对其指定的一种额外的契约:不变量。不变量是通过使用invariant装饰器创建的,指定了该类实例必须始终满足的条件。在这种情况下,“始终”意味着“在任何方法调用之前和返回之后” - 方法可以违反不变量,只要在返回之前恢复即可。

不变量契约传递一个变量,即类的实例的引用。例如

>>> @invariant("inner list can never be empty", lambda self: len(self.lst) > 0)
... @invariant("inner list must consist only of integers",
...            lambda self: all(isinstance(x, int) for x in self.lst))
... class NonemptyList:
...     @require("initial list must be a list", lambda args: isinstance(args.initial, list))
...     @require("initial list cannot be empty", lambda args: len(args.initial) > 0)
...     @ensure("the list instance variable is equal to the given argument",
...             lambda args, result: args.self.lst == args.initial)
...     @ensure("the list instance variable is not an alias to the given argument",
...             lambda args, result: args.self.lst is not args.initial)
...     def __init__(self, initial):
...         self.lst = initial[:]
...
...     def get(self, i):
...         return self.lst[i]
...
...     def pop(self):
...         self.lst.pop()
...
...     def as_string(self):
...         # Build up a string representation using the `get` method,
...         # to illustrate methods calling methods with invariants.
...         return ",".join(str(self.get(i)) for i in range(0, len(self.lst)))
>>> nl = NonemptyList([1,2,3])
>>> nl.pop()
>>> nl.pop()
>>> nl.pop()
Traceback (most recent call last):
PostconditionError: inner list can never be empty
>>> nl = NonemptyList(["a", "b", "c"])
Traceback (most recent call last):
PostconditionError: inner list must consist only of integers

在以下情况下忽略违反不变量

  • 在调用__init__和__new__之前(因为对象仍在初始化中)

  • 在任何以“__”开头的方法的调用之前和之后(除了实现算术和比较操作以及容器类型模拟的方法,因为这些方法是私有的,并期望操作对象的内部状态,并且与某些__getattr(attribute)__的应用有关)

  • 在任何从初始类定义外部添加的方法的调用之前和之后(因为不变量仅在类定义时处理)

  • 在调用类方法之前和之后,因为它们适用于整个类而不是任何特定实例

例如

>>> @invariant("`always` should be True", lambda self: self.always)
... class Foo:
...     always = True
...
...     def get_always(self):
...         return self.always
...
...     @classmethod
...     def break_everything(cls):
...         cls.always = False
>>> x = Foo()
>>> x.get_always()
True
>>> x.break_everything()
>>> x.get_always()
Traceback (most recent call last):
PreconditionError: `always` should be True

注意,如果一个方法在同一个对象上调用另一个方法,所有的不变量都将再次测试

>>> nl = NonemptyList([1,2,3])
>>> nl.as_string() == '1,2,3'
True

在契约中转换数据

一般来说,你应该避免在契约内部转换数据;契约本身应该是无副作用的。

然而,在Python中这并不总是可能的。

以传递给函数的迭代器为例。我们可能希望验证给定集合中的每个元素都满足某些属性。显然的解决方案可能如下所示:

>>> @require("every item in `l` must be > 0", lambda args: all(x > 0 for x in args.l))
... def my_func(l):
...     return sum(l)

这在大多数情况下都有效

>>> my_func([1, 2, 3])
6
>>> my_func([0, -1, 2])
Traceback (most recent call last):
PreconditionError: every item in `l` must be > 0

但在生成器的情况下会失败

>>> def iota(n):
...     for i in range(1, n):
...         yield i
>>> sum(iota(5))
10
>>> my_func(iota(5))
0

my_func 调用的结果为 0,因为在合同中的 all 调用中已经消耗了生成器。显然,这存在问题。

遗憾的是,没有通用的解决方案。在静态类型语言中,编译器可以验证无限列表的一些属性(尽管不是全部,具体取决于类型系统)。

在这里,我们通过使用一个名为 transform 的额外装饰器来克服这一限制,该装饰器将函数的参数进行转换,以及一个名为 rewrite 的函数来重写参数元组。

例如

>>> @transform(lambda args: rewrite(args, l=list(args.l)))
... @require("every item in `l` must be > 0", lambda args: all(x > 0 for x in args.l))
... def my_func(l):
...     return sum(l)
>>> my_func(iota(5))
10

请注意,这并不能完全解决无限序列的问题,但它确实允许验证无限序列的任何前缀。

当然,这也适用于类方法

>>> class TestClass:
...     @transform(lambda args: rewrite(args, l=list(args.l)))
...     @require("every item in `l` must be > 0", lambda args: all(x > 0 for x in args.l))
...     def my_func(self, l):
...         return sum(l)
>>> TestClass().my_func(iota(5))
10

异步函数(即协程函数)上的合同

可以在协程(即异步函数)上放置合同

>>> import asyncio
>>> @require("`a` is an integer", lambda args: isinstance(args.a, int))
... @require("`b` is a string", lambda args: isinstance(args.b, str))
... @require("every member of `c` should be a boolean",
...          lambda args: all(isinstance(x, bool) for x in args.c))
... async def func(a, b="Foo", *c):
...     await asyncio.sleep(1)
>>> asyncio.get_event_loop().run_until_complete(
...     func( 1, "foo", True, True, False))

断言函数本身不能是协程,因为这可能会影响运行循环

>>> async def coropred_aisint(e):
...     await asyncio.sleep(1)
...     return isinstance(getattr(e, 'a'), int)
>>> @require("`a` is an integer", coropred_aisint)
... @require("`b` is a string", lambda args: isinstance(args.b, str))
... @require("every member of `c` should be a boolean",
...          lambda args: all(isinstance(x, bool) for x in args.c))
... async def func(a, b="Foo", *c):
...     await asyncio.sleep(1)
Traceback (most recent call last):
AssertionError: contract predicates cannot be coroutines

合同与调试

合同是文档和测试工具;它们不是用于验证用户输入或实现程序逻辑的。实际上,将 Python 的 __debug__ 设置为 False(例如,通过使用带有“-O”选项的 Python 解释器)将禁用合同。

测试此模块

当从命令行调用此模块时,该模块将运行嵌入的 doctests。只需直接运行模块即可运行测试。

联系信息和许可

此模块在 GitHub 上有一个主页:GitHub

此模块由 Rob King 编写(jking@deadpixi.com)。

此程序是免费软件:您可以在自由软件基金会的 GNU 较小通用公共许可证的条款下重新分配它和/或修改它,许可证版本 3 或(根据您的选择)任何后续版本。

此程序分发时希望它是有用的,但没有保证;甚至没有关于适销性或特定用途适用性的暗示保证。有关详细信息,请参阅 GNU 较小通用公共许可证。

您应该已经收到此程序的 GNU 较小通用公共许可证副本。如果没有,请参阅 <https://gnu.ac.cn/licenses/>。

项目详情


下载文件

下载适合您平台的自定义文件。如果您不确定选择哪个,请了解有关 安装包 的更多信息。

源分布

dpcontracts-0.6.0.tar.gz (11.2 kB 查看哈希)

上传时间:

支持者