contractme 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {contractme-2.2.0 → contractme-2.3.0}/CHANGELOG.md +43 -0
- {contractme-2.2.0 → contractme-2.3.0}/PKG-INFO +1 -1
- contractme-2.3.0/docs/comparison-facts.yaml +73 -0
- {contractme-2.2.0 → contractme-2.3.0}/pyproject.toml +1 -1
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/annotations.py +2 -1
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/contracting.py +87 -25
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/skills/contractme/SKILL.md +41 -9
- contractme-2.3.0/src/contractme/testing.py +141 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/typecheck.py +24 -7
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/predicates.py +17 -10
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/typeshape.py +53 -6
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_annotations.py +65 -375
- contractme-2.3.0/tests/test_async.py +479 -0
- contractme-2.3.0/tests/test_hypothesis_async.py +200 -0
- contractme-2.3.0/tests/test_literal.py +73 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_typechecks.py +213 -2
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_types_extra.py +76 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_typeshape.py +58 -1
- {contractme-2.2.0 → contractme-2.3.0}/uv.lock +1 -1
- contractme-2.2.0/release_notes.md +0 -7
- contractme-2.2.0/src/contractme/testing.py +0 -67
- {contractme-2.2.0 → contractme-2.3.0}/.flake8 +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/.gitignore +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/.gitlab-ci.yml +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/.pre-commit-config.yaml +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/.python-version +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/.vscode/settings.json +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/CLAUDE.md +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/LICENSE +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/README.md +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/context7.json +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/docs/comparison.md +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/docs/types.md +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/justfile +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/release/prerelease.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/release/pypi_mint_token.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/release/release_notes.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/__init__.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/config.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/policy.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/probing.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/py.typed +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/records.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/result.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/__init__.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/containers.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/data.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/identifiers.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/network.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/numeric.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/paths.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/system.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/temporal.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/text.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/conftest.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_annotations_future.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_canaries.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_contracting.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_contracting_optimized_python.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_hypothesis.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_layering.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_optimized_policy.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_policy.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_probing.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_records.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_reentrancy.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_show_source.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_types.py +0 -0
- {contractme-2.2.0 → contractme-2.3.0}/tests/test_types_advanced.py +0 -0
|
@@ -7,6 +7,49 @@ section whose heading matches the tag via
|
|
|
7
7
|
and GitLab release description — so every `## vX.Y.Z` heading must match its
|
|
8
8
|
tag exactly.
|
|
9
9
|
|
|
10
|
+
## v2.3.0
|
|
11
|
+
|
|
12
|
+
* Contracts now work on `async def`. `@precondition`, `@postcondition` and
|
|
13
|
+
`@annotated` check across the await: postconditions and the return type check
|
|
14
|
+
see the **awaited value**, never the coroutine, and `old` captures before the
|
|
15
|
+
await. `inspect.iscoroutinefunction()` holds on the decorated form, so
|
|
16
|
+
pytest-asyncio, anyio and FastAPI route it correctly. This was silently
|
|
17
|
+
broken, not merely missing: `@annotated` used to raise on every *valid* call
|
|
18
|
+
while never awaiting the coroutine — so the body never ran — and the same
|
|
19
|
+
code worked under `CONTRACTME_OPTIMIZED`. Predicates must stay synchronous;
|
|
20
|
+
an `async def` predicate is refused at decoration time.
|
|
21
|
+
* `autotest` / `property_test` drive an async target: each drawn example runs
|
|
22
|
+
under `asyncio.run`, no new dependency. What is out of reach now refuses
|
|
23
|
+
loudly instead of passing empty — an already-running event loop is rejected
|
|
24
|
+
before any coroutine exists, and an async generator raises. `accepts` /
|
|
25
|
+
`rejects` stay synchronous and argument-only on async targets: they answer
|
|
26
|
+
"is this call legal?", which never requires running a body or an event loop.
|
|
27
|
+
* `typing.Literal` is now checked, as a sum of singletons: `Literal[1]` rejects
|
|
28
|
+
`True` and `Literal[True]` rejects `1`, since membership tests the type
|
|
29
|
+
before the value. It used to raise `NotImplementedError` *even for a valid
|
|
30
|
+
member*, so the annotation was unusable rather than merely unchecked;
|
|
31
|
+
`autotest` covers it for free, Hypothesis already samples `Literal`.
|
|
32
|
+
`dataclasses.Field[T]` is checked as an `isinstance` leaf (`T` is
|
|
33
|
+
static-only and is not claimed to be verified), which unblocks a `Protocol`
|
|
34
|
+
declaring `__dataclass_fields__`.
|
|
35
|
+
* `int` is accepted where `float` is declared, and `int`/`float` where
|
|
36
|
+
`complex` is (PEP 484 numeric tower, matching pyright). An alias such as
|
|
37
|
+
`Seconds = Annotated[float, Gt(0)]` used to reject `60`, making duration
|
|
38
|
+
annotations unusable — a function could be refused by its own integer
|
|
39
|
+
default. The reverse stays an error, constraints still apply to the promoted
|
|
40
|
+
value, and no value is ever converted.
|
|
41
|
+
* `is_signal_number` no longer raises `TypeError` on an unhashable value: it
|
|
42
|
+
answers `False`, as every predicate in the module promises to.
|
|
43
|
+
* Tighter string predicates: `is_semver`, `is_e164`, `is_byte_size` and
|
|
44
|
+
`is_duration` matched Unicode digits and case-folded outside ASCII, accepting
|
|
45
|
+
`"+١٢٣٤٥٦٧٨"` as a phone number and `"10KB"` (KELVIN SIGN) as a byte size.
|
|
46
|
+
* `is_iana_timezone` caches the tzdata scan instead of redoing it (~9 ms) on
|
|
47
|
+
every check; `property_test` builds its Hypothesis strategies once at
|
|
48
|
+
construction rather than inside the draw path.
|
|
49
|
+
* Internal: violation construction deduplicated, a stale `sys.version_info`
|
|
50
|
+
assert dropped, value-walker signatures annotated, constraint tests
|
|
51
|
+
parametrized.
|
|
52
|
+
|
|
10
53
|
## v2.2.0
|
|
11
54
|
|
|
12
55
|
* `ClassVar[T]` and `Final[T]` now check everywhere a type does: the marker is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contractme
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Executable specifications for python. Keep control of the code AI writes for you.
|
|
5
5
|
Project-URL: Repository, https://gitlab.com/leogermond/contractme
|
|
6
6
|
Author-email: Leo Germond <leo.germond@gmail.com>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Cached upstream facts for docs/comparison.md.
|
|
2
|
+
#
|
|
3
|
+
# Verifying competitors is the expensive half of a comparison pass, and it is
|
|
4
|
+
# also the half that barely moves between passes. So it is cached here instead
|
|
5
|
+
# of being re-derived every time: each entry carries its source and the date it
|
|
6
|
+
# was last checked. Refresh an entry when it ages out or when upstream ships a
|
|
7
|
+
# release — never re-verify the whole file by reflex.
|
|
8
|
+
#
|
|
9
|
+
# Facts about ContractMe itself are NOT cached: they are cheap to check by
|
|
10
|
+
# running them, and they must be re-checked every pass.
|
|
11
|
+
#
|
|
12
|
+
# Rule: nothing enters docs/comparison.md that is not backed by an entry here
|
|
13
|
+
# or by an execution done during that pass. "Not verified" is a publishable
|
|
14
|
+
# state; an unsourced claim is not.
|
|
15
|
+
|
|
16
|
+
beartype:
|
|
17
|
+
version: "0.22.9"
|
|
18
|
+
version_published: 2025-12-13
|
|
19
|
+
requires_python: ">=3.10"
|
|
20
|
+
checked: 2026-07-28
|
|
21
|
+
source: https://pypi.org/pypi/beartype/json
|
|
22
|
+
maintenance: |
|
|
23
|
+
Daily commits (latest 2026-07-27) but no PyPI release in over 7 months.
|
|
24
|
+
Development is active, the release cadence is not. Its readthedocs "latest"
|
|
25
|
+
documents a 0.23.0 that cannot be installed — the published docs run ahead
|
|
26
|
+
of the published package.
|
|
27
|
+
claims:
|
|
28
|
+
- claim: "beartype.claw installs an import hook that instruments a whole package or all dependencies"
|
|
29
|
+
verdict: confirmed
|
|
30
|
+
source: doc/src/api_claw.rst in the 0.22.9 sdist
|
|
31
|
+
- claim: "is_bearable(value, hint) is a boolean probe that never raises"
|
|
32
|
+
verdict: confirmed
|
|
33
|
+
note: |
|
|
34
|
+
Covers any PEP-compliant hint (generics, unions, Annotated), not just
|
|
35
|
+
bare classes — but it answers "does this match a type hint?", never an
|
|
36
|
+
arbitrary predicate.
|
|
37
|
+
source: README and doc/src/api_door.rst in the 0.22.9 sdist
|
|
38
|
+
- claim: "can warn instead of raise"
|
|
39
|
+
verdict: confirmed
|
|
40
|
+
detail: BeartypeConf(violation_type=UserWarning)
|
|
41
|
+
source: README in the 0.22.9 sdist
|
|
42
|
+
- claim: "claims O(1) checking by random sampling"
|
|
43
|
+
verdict: confirmed
|
|
44
|
+
note: This is beartype's own claim about itself, quoted as such.
|
|
45
|
+
source: doc/src/faq.rst in the 0.22.9 sdist
|
|
46
|
+
- claim: "silently ignores non-beartype Annotated metadata such as annotated-types Ge/Le"
|
|
47
|
+
verdict: confirmed_by_execution_only
|
|
48
|
+
note: |
|
|
49
|
+
True, but NOT documented by beartype. A grep for annotated_types across
|
|
50
|
+
the whole 0.22.9 sdist — code, tests and docs — returns nothing, and the
|
|
51
|
+
documented validators are only beartype.vale.Is/IsAttr/IsEqual. The
|
|
52
|
+
behaviour follows from PEP 593 (unrecognised metadata is ignored).
|
|
53
|
+
The comparison must therefore say "verified by running it", never
|
|
54
|
+
"documented by beartype".
|
|
55
|
+
source: exhaustive grep of the 0.22.9 sdist (the finding is an absence)
|
|
56
|
+
|
|
57
|
+
typeguard:
|
|
58
|
+
version: "4.6.0"
|
|
59
|
+
version_published: 2026-07-26
|
|
60
|
+
requires_python: ">=3.10"
|
|
61
|
+
checked: 2026-07-28
|
|
62
|
+
source: https://pypi.org/pypi/typeguard/json
|
|
63
|
+
maintenance: |
|
|
64
|
+
Actively maintained; commits on the day of the release. Note that 4.5.2,
|
|
65
|
+
which docs/comparison.md still cites, went stale two days before this check
|
|
66
|
+
— a version number in a comparison ages faster than anything else in it.
|
|
67
|
+
claims:
|
|
68
|
+
- claim: "runtime checking via decorator, import hook, or pytest plugin"
|
|
69
|
+
verdict: confirmed
|
|
70
|
+
detail: |
|
|
71
|
+
_decorators.py (@typechecked), _importhook.py (install_import_hook),
|
|
72
|
+
and _pytest_plugin.py, registered under the pytest11 entry point.
|
|
73
|
+
source: pyproject.toml and src/typeguard/ in the 4.6.0 sdist
|
|
@@ -18,7 +18,8 @@ def annotated[**F_Params, F_Ret](
|
|
|
18
18
|
both the type structure and any ``annotated-types`` constraints (``Ge``,
|
|
19
19
|
``Lt``, ``MinLen``, ``Predicate``, ...). Every type shape :mod:`typeshape`
|
|
20
20
|
models is supported — scalars, containers, unions, tuples, dataclasses
|
|
21
|
-
(field-by-field), enums, ``Protocol`` duck typing,
|
|
21
|
+
(field-by-field), enums, ``Literal`` alternatives, ``Protocol`` duck typing,
|
|
22
|
+
and recursive types.
|
|
22
23
|
|
|
23
24
|
Validation is two-phase: structure first ("is this a list?"), then
|
|
24
25
|
constraints ("are all elements ≥ 0?"). On violation the raised
|
|
@@ -6,6 +6,13 @@ freely call contracted functions — even self-referentially — without recursi
|
|
|
6
6
|
or double-checking cost. Calls made by the wrapped function body are checked
|
|
7
7
|
as usual.
|
|
8
8
|
|
|
9
|
+
An ``async def`` gets the same treatment through a second, mirrored path (issue
|
|
10
|
+
#58): the checks run *inside* the coroutine, around ``await``\\ ing the body, so
|
|
11
|
+
a postcondition sees the awaited value and never the coroutine object. Only
|
|
12
|
+
creating the coroutine stays eager, exactly as for an undecorated ``async def``.
|
|
13
|
+
Predicates themselves are always synchronous — an ``async def`` predicate is
|
|
14
|
+
refused at decoration time.
|
|
15
|
+
|
|
9
16
|
The engine cannot verify itself: helpers that run while checking is *not*
|
|
10
17
|
suspended are marked ``@no_contracts`` and carry plain asserts, backed by the
|
|
11
18
|
canaries in tests/test_canaries.py.
|
|
@@ -340,6 +347,13 @@ def condition_decorator[**F_Params, F_Ret](
|
|
|
340
347
|
message: FailureMessage | None = None,
|
|
341
348
|
policy: PolicyLike | None = None,
|
|
342
349
|
) -> Callable[[Callable[F_Params, F_Ret]], ContractedFunction[F_Params, F_Ret]]:
|
|
350
|
+
if inspect.iscoroutinefunction(conditional):
|
|
351
|
+
raise RuntimeError(
|
|
352
|
+
f"{getattr(conditional, '__name__', conditional)!r} is an async predicate: "
|
|
353
|
+
"contract predicates must be synchronous. A probe (accepts/rejects) has to "
|
|
354
|
+
"stay total, cheap and loop-free — it answers without an event loop — and a "
|
|
355
|
+
"predicate awaiting external state is TOCTOU by construction (issue #57)"
|
|
356
|
+
)
|
|
343
357
|
handler = resolve_handler(policy)
|
|
344
358
|
|
|
345
359
|
def deco(f: Callable[F_Params, F_Ret]) -> ContractedFunction[F_Params, F_Ret]:
|
|
@@ -348,6 +362,9 @@ def condition_decorator[**F_Params, F_Ret](
|
|
|
348
362
|
if kind is PRE and f.__name__ == "__init__":
|
|
349
363
|
raise RuntimeError("__init__ cannot have precondition")
|
|
350
364
|
original_call, preconditions, postconditions = get_wrapped(f)
|
|
365
|
+
# detection is on the *unwrapped* callable, so stacked decorators
|
|
366
|
+
# re-detect correctly at every level
|
|
367
|
+
is_coroutine = inspect.iscoroutinefunction(original_call)
|
|
351
368
|
|
|
352
369
|
# decorators apply bottom-up: prepend, so conditions run in source order
|
|
353
370
|
conditions = preconditions if kind is PRE else postconditions
|
|
@@ -389,23 +406,27 @@ def condition_decorator[**F_Params, F_Ret](
|
|
|
389
406
|
def accepts(*a, **kw) -> bool:
|
|
390
407
|
return not rejects(*a, **kw)
|
|
391
408
|
|
|
409
|
+
def report_failed(kind: ConditionKind, cond: Condition, cond_kw: dict) -> None:
|
|
410
|
+
"""Build the ``ContractViolation`` for a failed condition and dispatch
|
|
411
|
+
it. An enforcing handler raises here, halting; advisory ones
|
|
412
|
+
(warn/observe) report and let execution proceed."""
|
|
413
|
+
blame = "**caller** of " if kind is PRE else ""
|
|
414
|
+
violation = ContractViolation(
|
|
415
|
+
f"{blame}{original_call.__name__}() bug: "
|
|
416
|
+
+ format_failed_condition(kind, cond.predicate, cond.message, cond_kw),
|
|
417
|
+
kind=kind,
|
|
418
|
+
function=original_call.__qualname__,
|
|
419
|
+
condition_source=show_source(cond.predicate),
|
|
420
|
+
captured_values=cond_kw,
|
|
421
|
+
call_site=_definition_site(original_call),
|
|
422
|
+
)
|
|
423
|
+
report_violation(violation, cond.handler)
|
|
424
|
+
|
|
392
425
|
def precondition_check(call_kw: dict) -> None:
|
|
393
426
|
failures = failing_preconditions(call_kw, stop_at_first=False)
|
|
394
427
|
with evaluating_contracts():
|
|
395
428
|
for cond, cond_kw in failures:
|
|
396
|
-
|
|
397
|
-
f"**caller** of {original_call.__name__}() bug: "
|
|
398
|
-
+ format_failed_condition(PRE, cond.predicate, cond.message, cond_kw),
|
|
399
|
-
kind=PRE,
|
|
400
|
-
function=original_call.__qualname__,
|
|
401
|
-
condition_source=show_source(cond.predicate),
|
|
402
|
-
captured_values=cond_kw,
|
|
403
|
-
call_site=_definition_site(original_call),
|
|
404
|
-
)
|
|
405
|
-
# an enforcing handler raises here, halting; advisory ones
|
|
406
|
-
# (warn/observe) report and let the next failure — and the
|
|
407
|
-
# body — proceed.
|
|
408
|
-
report_violation(violation, cond.handler)
|
|
429
|
+
report_failed(PRE, cond, cond_kw)
|
|
409
430
|
|
|
410
431
|
def postcondition_prepare(call_kw: dict) -> list[dict]:
|
|
411
432
|
# `old` capture deep-copies the arguments, which may run user code
|
|
@@ -421,16 +442,7 @@ def condition_decorator[**F_Params, F_Ret](
|
|
|
421
442
|
for cond, cond_kw in zip(postconditions, post_kw):
|
|
422
443
|
cond_kw.update(get_kw_for_postcondition_after(result, cond.predicate))
|
|
423
444
|
if not call_conditional(cond.predicate, cond_kw):
|
|
424
|
-
|
|
425
|
-
f"{original_call.__name__}() bug: "
|
|
426
|
-
+ format_failed_condition(POST, cond.predicate, cond.message, cond_kw),
|
|
427
|
-
kind=POST,
|
|
428
|
-
function=original_call.__qualname__,
|
|
429
|
-
condition_source=show_source(cond.predicate),
|
|
430
|
-
captured_values=cond_kw,
|
|
431
|
-
call_site=_definition_site(original_call),
|
|
432
|
-
)
|
|
433
|
-
report_violation(violation, cond.handler)
|
|
445
|
+
report_failed(POST, cond, cond_kw)
|
|
434
446
|
|
|
435
447
|
def call_and_run_contracts(wrapper: _ContractWrapper[F_Params, F_Ret], *a, **kw) -> F_Ret:
|
|
436
448
|
if _evaluating_contracts.get():
|
|
@@ -456,6 +468,49 @@ def condition_decorator[**F_Params, F_Ret](
|
|
|
456
468
|
# Python's canonical TypeError (see get_kw_from_call)
|
|
457
469
|
return original_call(*a, **kw)
|
|
458
470
|
|
|
471
|
+
async def await_body_within_contracts(call_kw: dict, a: tuple, kw: dict):
|
|
472
|
+
"""The awaited half of the async path: everything the sync
|
|
473
|
+
`call_and_run_contracts` does *around* the body, run inside the
|
|
474
|
+
coroutine. So the value postconditions judge is the awaited result,
|
|
475
|
+
never the coroutine object, `old` is captured at body entry (Eiffel),
|
|
476
|
+
and the ambient policy is the one live when the body actually runs."""
|
|
477
|
+
post_kw = None
|
|
478
|
+
if is_checked():
|
|
479
|
+
precondition_check(call_kw)
|
|
480
|
+
post_kw = postcondition_prepare(call_kw)
|
|
481
|
+
|
|
482
|
+
result = await original_call(*a, **kw)
|
|
483
|
+
|
|
484
|
+
if post_kw is not None:
|
|
485
|
+
postcondition_check(result, post_kw)
|
|
486
|
+
|
|
487
|
+
return result
|
|
488
|
+
|
|
489
|
+
def async_call_and_run_contracts(
|
|
490
|
+
wrapper: _ContractWrapper[F_Params, F_Ret], *a, **kw
|
|
491
|
+
) -> F_Ret:
|
|
492
|
+
"""Contract path of an `async def`, mirroring the sync one. It is a
|
|
493
|
+
*sync* entry returning the checking coroutine, so calling the
|
|
494
|
+
decorated function is exactly as eager as calling the bare one: a
|
|
495
|
+
malformed call raises Python's canonical `TypeError` right here, and
|
|
496
|
+
nothing else happens before the caller awaits."""
|
|
497
|
+
if _evaluating_contracts.get():
|
|
498
|
+
# assertion evaluation rule: a coroutine created while a
|
|
499
|
+
# predicate is being evaluated runs unchecked, like a sync call
|
|
500
|
+
return original_call(*a, **kw)
|
|
501
|
+
|
|
502
|
+
match get_kw_from_call(original_call, a, kw):
|
|
503
|
+
case Ok(call_kw):
|
|
504
|
+
# this coroutine *is* the call's return value, but an
|
|
505
|
+
# `async def` types as `Coroutine[...]` and the checker
|
|
506
|
+
# cannot see that this is exactly the wrapped `F_Ret`
|
|
507
|
+
return cast(F_Ret, await_body_within_contracts(call_kw, a, kw))
|
|
508
|
+
case _:
|
|
509
|
+
# malformed call (`Err`): defer to the real function for
|
|
510
|
+
# Python's canonical TypeError, raised *before* a coroutine
|
|
511
|
+
# exists — so nothing is left un-awaited (see get_kw_from_call)
|
|
512
|
+
return original_call(*a, **kw)
|
|
513
|
+
|
|
459
514
|
if _OPTIMIZED: # pragma: no cover
|
|
460
515
|
# only reachable when CONTRACTME_OPTIMIZED is set at import — a
|
|
461
516
|
# separate invocation, exercised by tests/test_optimized_policy.py
|
|
@@ -478,14 +533,21 @@ def condition_decorator[**F_Params, F_Ret](
|
|
|
478
533
|
# the attach is invisible statically; the subprocess test asserts it
|
|
479
534
|
return cast(ContractedFunction[F_Params, F_Ret], original_call)
|
|
480
535
|
|
|
481
|
-
|
|
482
|
-
call_and_run_contracts,
|
|
536
|
+
wrapper = _ContractWrapper[F_Params, F_Ret](
|
|
537
|
+
async_call_and_run_contracts if is_coroutine else call_and_run_contracts,
|
|
483
538
|
original_call,
|
|
484
539
|
preconditions,
|
|
485
540
|
postconditions,
|
|
486
541
|
accepts,
|
|
487
542
|
rejects,
|
|
488
543
|
)
|
|
544
|
+
if is_coroutine:
|
|
545
|
+
# the wrapper is a callable object, not an `async def`: without the
|
|
546
|
+
# mark, framework dispatch (pytest-asyncio, anyio, FastAPI) would
|
|
547
|
+
# misroute a contracted coroutine function as a sync one. Bound
|
|
548
|
+
# methods inherit the mark through `__func__`.
|
|
549
|
+
inspect.markcoroutinefunction(wrapper)
|
|
550
|
+
return wrapper
|
|
489
551
|
|
|
490
552
|
return deco
|
|
491
553
|
|
|
@@ -153,6 +153,36 @@ def square_root(x: float, y: str = "") -> float:
|
|
|
153
153
|
diagnose the actual values.
|
|
154
154
|
- `@annotated` and explicit contracts compose freely on the same function.
|
|
155
155
|
|
|
156
|
+
## Async support
|
|
157
|
+
|
|
158
|
+
`@precondition`, `@postcondition`, and `@annotated` all work on `async def`.
|
|
159
|
+
Postconditions and return-type checks see the **awaited** value, never the coroutine
|
|
160
|
+
object, and `old` deep-copies arguments before the coroutine body starts — same
|
|
161
|
+
guarantee as a sync function, even if the mutation happens after an `await`:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
import asyncio
|
|
165
|
+
|
|
166
|
+
@precondition(lambda x: x >= 0)
|
|
167
|
+
@postcondition(lambda result, old: result == old.x * 2)
|
|
168
|
+
async def double(x: int) -> int:
|
|
169
|
+
await asyncio.sleep(0)
|
|
170
|
+
return x * 2
|
|
171
|
+
|
|
172
|
+
asyncio.run(double(-1))
|
|
173
|
+
# ContractViolation: **caller** of double() bug: precondition 'x >= 0' failed: x = -1
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The decorated function stays `inspect.iscoroutinefunction()`-true, so pytest-asyncio,
|
|
177
|
+
anyio, and FastAPI route it correctly. Predicates themselves must stay synchronous: an
|
|
178
|
+
`async def` predicate is refused at decoration time (`RuntimeError`) — a probe has to
|
|
179
|
+
answer without an event loop, and an awaiting predicate is TOCTOU by construction.
|
|
180
|
+
`autotest`/`property_test` drive an async target by running each Hypothesis-generated
|
|
181
|
+
example under `asyncio.run` — no new dependency. `accepts()`/`rejects()` stay
|
|
182
|
+
synchronous and argument-only even on an async target: they answer "is this call
|
|
183
|
+
legal?" without ever running the body or requiring a loop. Async **generators** are out
|
|
184
|
+
of scope — `property_test` raises `NotImplementedError` naming the target.
|
|
185
|
+
|
|
156
186
|
## Free property-based tests
|
|
157
187
|
|
|
158
188
|
`autotest` builds a Hypothesis strategy from the type annotations, filters generated
|
|
@@ -268,8 +298,9 @@ Measured on a real placement search: ~5× end-to-end (46 s → 10 s).
|
|
|
268
298
|
|
|
269
299
|
## Ground rules
|
|
270
300
|
|
|
271
|
-
- **Validate constructor inputs with
|
|
272
|
-
|
|
301
|
+
- **Validate constructor inputs with a `@record` or a factory function** —
|
|
302
|
+
`@precondition` *and* `@annotated` on `__init__` both raise
|
|
303
|
+
`RuntimeError('__init__ cannot have precondition')` at decoration time.
|
|
273
304
|
- **Account for `old`'s deep copy** — it copies every argument before the call. On
|
|
274
305
|
large arguments that cost is real; postconditions can be disabled in production
|
|
275
306
|
(see below).
|
|
@@ -277,11 +308,8 @@ Measured on a real placement search: ~5× end-to-end (46 s → 10 s).
|
|
|
277
308
|
guard) and are togglable at runtime: `set_policy("off")` globally, or
|
|
278
309
|
`local_policy("off")` for a `with` block (see *Choosing what a violation
|
|
279
310
|
does*). The `accepts()`/`rejects()` probes are the boundary tool that survives `-O`.
|
|
280
|
-
- **Never call a contracted rule per-candidate in a search loop
|
|
281
|
-
|
|
282
|
-
enclosing algorithm (pattern in *Choosing what a violation does*): the rule stays
|
|
283
|
-
the single source of truth for legality, at one check per result instead of one per
|
|
284
|
-
candidate.
|
|
311
|
+
- **Never call a contracted rule per-candidate in a search loop** — see the
|
|
312
|
+
search-loop pattern in *Choosing what a violation does* above.
|
|
285
313
|
- **Screen untrusted input with `f.accepts(...)`, `probes(T).accepts(value)` on the
|
|
286
314
|
expected type, or parse it into a `@record` and
|
|
287
315
|
check with `probes(Record).accepts(...)`.** The probes turn the rule into a question — a
|
|
@@ -298,8 +326,12 @@ Measured on a real placement search: ~5× end-to-end (46 s → 10 s).
|
|
|
298
326
|
`@record` is `@dataclass_transform`, so pyright sees the real API. An ignore around
|
|
299
327
|
contractme code signals a misuse (e.g. `Rec.accepts` instead of
|
|
300
328
|
`probes(Rec).accepts`), not a framework gap.
|
|
301
|
-
-
|
|
302
|
-
|
|
329
|
+
- **Numeric tower**: `int` passes where `float` is declared (PEP 484 — `Annotated[float,
|
|
330
|
+
Gt(0)]` accepts `60`); the reverse never does — a `float` is always rejected where
|
|
331
|
+
`int` is declared. `bool` passes an `int` check via `isinstance`; beyond that,
|
|
332
|
+
exact-type matching is not supported. `typing.Literal` doesn't inherit this laxity —
|
|
333
|
+
membership checks type before value, so `Literal[1]` rejects `True` and `Literal[True]`
|
|
334
|
+
rejects `1`. Unknown annotations are ignored (open world), never a failure.
|
|
303
335
|
- **`Protocol` parameters**: checked structurally by `@annotated` (duck typing, no
|
|
304
336
|
`@runtime_checkable` needed), but `autotest` cannot generate values for them.
|
|
305
337
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Hypothesis integration: property-based tests generated from the contracts.
|
|
2
|
+
|
|
3
|
+
``property_test(f)`` builds the test — a ready-to-run callable whose Hypothesis
|
|
4
|
+
strategy comes from ``f``'s type hints and which — when ``f`` is contracted —
|
|
5
|
+
filters drawn inputs through its ``accepts`` probe, so only calls the
|
|
6
|
+
preconditions allow are exercised. ``autotest(f)`` runs it.
|
|
7
|
+
|
|
8
|
+
An ``async def`` target is driven with ``asyncio.run`` — a fresh event loop per
|
|
9
|
+
drawn example, for isolation and deterministic shrinking, and no new dependency
|
|
10
|
+
(issue #60). The dispatch reads ``inspect.iscoroutinefunction`` on the
|
|
11
|
+
*decorated* form, dogfooding the mark the engine puts on its wrapper (#58).
|
|
12
|
+
|
|
13
|
+
The one rule the async side answers to: **a test that runs nothing must not
|
|
14
|
+
pass**. Merely *calling* a coroutine function builds a coroutine and runs no
|
|
15
|
+
body, so a target this module cannot drive to completion is refused loudly, by
|
|
16
|
+
name, at :func:`property_test` time — never turned into a green test that
|
|
17
|
+
exercised nothing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import inspect
|
|
22
|
+
from typing import Callable, get_type_hints
|
|
23
|
+
|
|
24
|
+
from hypothesis import assume, given, example, strategies as st
|
|
25
|
+
|
|
26
|
+
from contractme.contracting import ContractedFunction, get_wrapped
|
|
27
|
+
|
|
28
|
+
type TestableFunction = ContractedFunction | Callable
|
|
29
|
+
type PropertyTest = Callable
|
|
30
|
+
# how one drawn example is executed: ``(target, drawn arguments) -> anything``
|
|
31
|
+
type ExampleRunner = Callable[[Callable, tuple | dict], object]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def name_of(f: Callable) -> str:
|
|
35
|
+
"""How a target is called out in a refusal message."""
|
|
36
|
+
return getattr(f, "__name__", repr(f))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def call_with_composite(f: Callable, composite: tuple | dict):
|
|
40
|
+
if isinstance(composite, dict):
|
|
41
|
+
return f(**composite)
|
|
42
|
+
return f(*composite)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def running_loop() -> asyncio.AbstractEventLoop | None:
|
|
46
|
+
"""The event loop running in this thread, or ``None`` — the question
|
|
47
|
+
``asyncio`` only answers by raising."""
|
|
48
|
+
try:
|
|
49
|
+
return asyncio.get_running_loop()
|
|
50
|
+
except RuntimeError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def refuse_running_loop(f: Callable) -> None:
|
|
55
|
+
"""Refuse to drive ``f`` from inside a running event loop.
|
|
56
|
+
|
|
57
|
+
``asyncio.run`` cannot nest, so there is no test to build here — and the
|
|
58
|
+
check has to happen *before* the coroutine is created, or the refusal would
|
|
59
|
+
leave one un-awaited behind it."""
|
|
60
|
+
if running_loop() is not None:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"cannot drive the coroutine function {name_of(f)}() while an event loop is "
|
|
63
|
+
"already running in this thread: each drawn example is run with asyncio.run(), "
|
|
64
|
+
"which refuses to nest. Call property_test/autotest/test_with_examples from "
|
|
65
|
+
"synchronous code — they need no async test runner of their own."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def await_with_composite(f: Callable, composite: tuple | dict):
|
|
70
|
+
"""Run one drawn example of a coroutine function to completion, on its own
|
|
71
|
+
event loop — so the body actually executes and the contracts see the awaited
|
|
72
|
+
value rather than a coroutine object."""
|
|
73
|
+
refuse_running_loop(f)
|
|
74
|
+
return asyncio.run(call_with_composite(f, composite))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def example_runner(f: TestableFunction) -> ExampleRunner:
|
|
78
|
+
"""How one drawn example of ``f`` gets executed — or a loud refusal.
|
|
79
|
+
|
|
80
|
+
The engine's wrapper is a callable *object*, which hides the code flags
|
|
81
|
+
``inspect`` reads: shape questions go to the unwrapped body, except
|
|
82
|
+
``iscoroutinefunction``, which the engine marks on the wrapper precisely so
|
|
83
|
+
that dispatch like this one works on the decorated form (#58)."""
|
|
84
|
+
body, _preconditions, _postconditions = get_wrapped(f)
|
|
85
|
+
if inspect.isasyncgenfunction(body):
|
|
86
|
+
raise NotImplementedError(
|
|
87
|
+
f"{name_of(f)}() is an async generator function, which property_test cannot "
|
|
88
|
+
"drive: calling it only builds an async generator, so the test would pass "
|
|
89
|
+
"without ever running the body. Async generators are out of scope (issue "
|
|
90
|
+
"#57) — property-test the coroutine that consumes one instead."
|
|
91
|
+
)
|
|
92
|
+
if inspect.iscoroutinefunction(f):
|
|
93
|
+
# fail at build time for the common case, where the test is built and
|
|
94
|
+
# run in the same breath; `await_with_composite` re-checks per example,
|
|
95
|
+
# for a test built outside a loop and later run inside one
|
|
96
|
+
refuse_running_loop(f)
|
|
97
|
+
return await_with_composite
|
|
98
|
+
return call_with_composite
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def property_test(f: TestableFunction) -> PropertyTest:
|
|
102
|
+
# `ContractedFunction` is a runtime-checkable Protocol, so this holds for both
|
|
103
|
+
# the wrapped and the `optimized` (bare function + attached probes) forms.
|
|
104
|
+
# Resolve the `accepts` probe and build the strategies once (not per drawn
|
|
105
|
+
# example — `get_type_hints` is costly); `accepts` is `None` for a plain
|
|
106
|
+
# callable, which then draws unfiltered.
|
|
107
|
+
accepts = f.accepts if isinstance(f, ContractedFunction) else None
|
|
108
|
+
hints = get_type_hints(f, include_extras=True)
|
|
109
|
+
strategies = [st.from_type(t) for name, t in hints.items() if name != "return"]
|
|
110
|
+
# sync targets keep the exact former path: `run_example` is `call_with_composite`
|
|
111
|
+
run_example = example_runner(f)
|
|
112
|
+
|
|
113
|
+
@st.composite
|
|
114
|
+
def correct_input(draw: Callable) -> tuple:
|
|
115
|
+
v = tuple(draw(s) for s in strategies)
|
|
116
|
+
if accepts is not None:
|
|
117
|
+
assume(accepts(*v))
|
|
118
|
+
return v
|
|
119
|
+
|
|
120
|
+
@given(correct_input())
|
|
121
|
+
def search(value):
|
|
122
|
+
run_example(f, value)
|
|
123
|
+
|
|
124
|
+
return search
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def autotest(f: TestableFunction):
|
|
128
|
+
property_test(f)()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_with_examples(f: TestableFunction, *exs) -> PropertyTest:
|
|
132
|
+
gen = property_test(f)
|
|
133
|
+
for e in exs:
|
|
134
|
+
if isinstance(f, ContractedFunction):
|
|
135
|
+
# screening stays synchronous whatever the target: probes are
|
|
136
|
+
# precondition-only and argument-only, they never touch a loop
|
|
137
|
+
accepted = call_with_composite(f.accepts, e)
|
|
138
|
+
if not accepted:
|
|
139
|
+
raise RuntimeError(f"contracts rejected example {e}")
|
|
140
|
+
gen = example(e)(gen)
|
|
141
|
+
return gen
|
|
@@ -11,7 +11,7 @@ the engine into anything else (a CLI, a deserializer, another framework).
|
|
|
11
11
|
import datetime
|
|
12
12
|
import zoneinfo
|
|
13
13
|
from enum import Enum
|
|
14
|
-
from typing import cast
|
|
14
|
+
from typing import Any, cast
|
|
15
15
|
|
|
16
16
|
import annotated_types
|
|
17
17
|
|
|
@@ -24,6 +24,7 @@ from contractme.typeshape import (
|
|
|
24
24
|
CompositeTypeInfo,
|
|
25
25
|
Constraint,
|
|
26
26
|
NormalizedConstraint,
|
|
27
|
+
accepted_classes,
|
|
27
28
|
normalize_constraint,
|
|
28
29
|
resolve_type,
|
|
29
30
|
)
|
|
@@ -37,7 +38,9 @@ __all__ = [
|
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
|
|
40
|
-
def get_all_constraint_errors(
|
|
41
|
+
def get_all_constraint_errors(
|
|
42
|
+
val: Any, constraints: list[Constraint]
|
|
43
|
+
) -> list[NormalizedConstraint]:
|
|
41
44
|
failing = []
|
|
42
45
|
for constraint_denorm in constraints:
|
|
43
46
|
constraint = normalize_constraint(constraint_denorm)
|
|
@@ -84,11 +87,11 @@ def get_all_constraint_errors(val, constraints: list[Constraint]) -> list[Normal
|
|
|
84
87
|
return failing
|
|
85
88
|
|
|
86
89
|
|
|
87
|
-
def _type_name(t) -> str:
|
|
90
|
+
def _type_name(t: AnnotationForm) -> str:
|
|
88
91
|
return getattr(t, "__name__", str(t))
|
|
89
92
|
|
|
90
93
|
|
|
91
|
-
def get_first_structure_error(value, an) -> str | None:
|
|
94
|
+
def get_first_structure_error(value: Any, an: AnnotationForm) -> str | None:
|
|
92
95
|
"""First structural mismatch between ``value`` and annotation ``an``, or
|
|
93
96
|
``None`` when the structure is sound.
|
|
94
97
|
|
|
@@ -110,6 +113,16 @@ def get_first_structure_error(value, an) -> str | None:
|
|
|
110
113
|
return f"expect one of {list(cast(type[Enum], type_info.origin))} got {value!r}"
|
|
111
114
|
return None
|
|
112
115
|
|
|
116
|
+
if type_info.child_mod == ChildMod.LITERAL:
|
|
117
|
+
# PEP 586: the value must *be* one of the spelled-out alternatives, which
|
|
118
|
+
# are the children here. Equality alone would not do it: `True == 1` and
|
|
119
|
+
# `False == 0` in Python, so `Literal[1]` would admit `True` (and
|
|
120
|
+
# `Literal[True]` a plain `1`). The exact type discriminates them — a
|
|
121
|
+
# literal denotes one value *of one type*, not an equivalence class.
|
|
122
|
+
if not any(type(value) is type(lit) and value == lit for lit in children):
|
|
123
|
+
return f"expect one of {list(children)!r} got {value!r}"
|
|
124
|
+
return None
|
|
125
|
+
|
|
113
126
|
if type_info.child_mod == ChildMod.STRUCTURAL:
|
|
114
127
|
# duck typing: every declared member must be present (hasattr), and a
|
|
115
128
|
# member carrying a type (anything but `Any`) is checked against it
|
|
@@ -125,7 +138,11 @@ def get_first_structure_error(value, an) -> str | None:
|
|
|
125
138
|
# `Any`, or a form isinstance cannot judge: open world
|
|
126
139
|
return None
|
|
127
140
|
|
|
128
|
-
|
|
141
|
+
# `accepted_classes` widens `float`/`complex` per PEP 484's numeric tower, so
|
|
142
|
+
# what pyright accepts statically the runtime accepts too. The promotion is
|
|
143
|
+
# acceptance only: the value is handed on untouched, never coerced. The
|
|
144
|
+
# message still names the *declared* type ("expect float got str").
|
|
145
|
+
if not isinstance(value, accepted_classes(type_info.origin)):
|
|
129
146
|
return f"expect {_type_name(type_info.origin)} got {value.__class__.__name__}"
|
|
130
147
|
|
|
131
148
|
match type_info.child_mod:
|
|
@@ -163,7 +180,7 @@ def get_first_structure_error(value, an) -> str | None:
|
|
|
163
180
|
return None
|
|
164
181
|
|
|
165
182
|
|
|
166
|
-
def get_constraints_errors(value, an) -> list[str]:
|
|
183
|
+
def get_constraints_errors(value: Any, an: AnnotationForm) -> list[str]:
|
|
167
184
|
"""Every constraint violation between ``value`` and annotation ``an``
|
|
168
185
|
(empty list = all constraints hold).
|
|
169
186
|
|
|
@@ -219,7 +236,7 @@ def get_constraints_errors(value, an) -> list[str]:
|
|
|
219
236
|
return errors
|
|
220
237
|
|
|
221
238
|
|
|
222
|
-
def type_error(value, an) -> str | None:
|
|
239
|
+
def type_error(value: Any, an: AnnotationForm) -> str | None:
|
|
223
240
|
"""The full check of ``value`` against annotation ``an``: the error message,
|
|
224
241
|
or ``None`` when the value parses.
|
|
225
242
|
|