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.
Files changed (69) hide show
  1. {contractme-2.2.0 → contractme-2.3.0}/CHANGELOG.md +43 -0
  2. {contractme-2.2.0 → contractme-2.3.0}/PKG-INFO +1 -1
  3. contractme-2.3.0/docs/comparison-facts.yaml +73 -0
  4. {contractme-2.2.0 → contractme-2.3.0}/pyproject.toml +1 -1
  5. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/annotations.py +2 -1
  6. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/contracting.py +87 -25
  7. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/skills/contractme/SKILL.md +41 -9
  8. contractme-2.3.0/src/contractme/testing.py +141 -0
  9. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/typecheck.py +24 -7
  10. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/predicates.py +17 -10
  11. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/typeshape.py +53 -6
  12. {contractme-2.2.0 → contractme-2.3.0}/tests/test_annotations.py +65 -375
  13. contractme-2.3.0/tests/test_async.py +479 -0
  14. contractme-2.3.0/tests/test_hypothesis_async.py +200 -0
  15. contractme-2.3.0/tests/test_literal.py +73 -0
  16. {contractme-2.2.0 → contractme-2.3.0}/tests/test_typechecks.py +213 -2
  17. {contractme-2.2.0 → contractme-2.3.0}/tests/test_types_extra.py +76 -0
  18. {contractme-2.2.0 → contractme-2.3.0}/tests/test_typeshape.py +58 -1
  19. {contractme-2.2.0 → contractme-2.3.0}/uv.lock +1 -1
  20. contractme-2.2.0/release_notes.md +0 -7
  21. contractme-2.2.0/src/contractme/testing.py +0 -67
  22. {contractme-2.2.0 → contractme-2.3.0}/.flake8 +0 -0
  23. {contractme-2.2.0 → contractme-2.3.0}/.gitignore +0 -0
  24. {contractme-2.2.0 → contractme-2.3.0}/.gitlab-ci.yml +0 -0
  25. {contractme-2.2.0 → contractme-2.3.0}/.pre-commit-config.yaml +0 -0
  26. {contractme-2.2.0 → contractme-2.3.0}/.python-version +0 -0
  27. {contractme-2.2.0 → contractme-2.3.0}/.vscode/settings.json +0 -0
  28. {contractme-2.2.0 → contractme-2.3.0}/CLAUDE.md +0 -0
  29. {contractme-2.2.0 → contractme-2.3.0}/LICENSE +0 -0
  30. {contractme-2.2.0 → contractme-2.3.0}/README.md +0 -0
  31. {contractme-2.2.0 → contractme-2.3.0}/context7.json +0 -0
  32. {contractme-2.2.0 → contractme-2.3.0}/docs/comparison.md +0 -0
  33. {contractme-2.2.0 → contractme-2.3.0}/docs/types.md +0 -0
  34. {contractme-2.2.0 → contractme-2.3.0}/justfile +0 -0
  35. {contractme-2.2.0 → contractme-2.3.0}/release/prerelease.py +0 -0
  36. {contractme-2.2.0 → contractme-2.3.0}/release/pypi_mint_token.py +0 -0
  37. {contractme-2.2.0 → contractme-2.3.0}/release/release_notes.py +0 -0
  38. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/__init__.py +0 -0
  39. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/config.py +0 -0
  40. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/policy.py +0 -0
  41. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/probing.py +0 -0
  42. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/py.typed +0 -0
  43. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/records.py +0 -0
  44. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/result.py +0 -0
  45. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/__init__.py +0 -0
  46. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/containers.py +0 -0
  47. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/data.py +0 -0
  48. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/identifiers.py +0 -0
  49. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/network.py +0 -0
  50. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/numeric.py +0 -0
  51. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/paths.py +0 -0
  52. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/system.py +0 -0
  53. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/temporal.py +0 -0
  54. {contractme-2.2.0 → contractme-2.3.0}/src/contractme/types/text.py +0 -0
  55. {contractme-2.2.0 → contractme-2.3.0}/tests/conftest.py +0 -0
  56. {contractme-2.2.0 → contractme-2.3.0}/tests/test_annotations_future.py +0 -0
  57. {contractme-2.2.0 → contractme-2.3.0}/tests/test_canaries.py +0 -0
  58. {contractme-2.2.0 → contractme-2.3.0}/tests/test_contracting.py +0 -0
  59. {contractme-2.2.0 → contractme-2.3.0}/tests/test_contracting_optimized_python.py +0 -0
  60. {contractme-2.2.0 → contractme-2.3.0}/tests/test_hypothesis.py +0 -0
  61. {contractme-2.2.0 → contractme-2.3.0}/tests/test_layering.py +0 -0
  62. {contractme-2.2.0 → contractme-2.3.0}/tests/test_optimized_policy.py +0 -0
  63. {contractme-2.2.0 → contractme-2.3.0}/tests/test_policy.py +0 -0
  64. {contractme-2.2.0 → contractme-2.3.0}/tests/test_probing.py +0 -0
  65. {contractme-2.2.0 → contractme-2.3.0}/tests/test_records.py +0 -0
  66. {contractme-2.2.0 → contractme-2.3.0}/tests/test_reentrancy.py +0 -0
  67. {contractme-2.2.0 → contractme-2.3.0}/tests/test_show_source.py +0 -0
  68. {contractme-2.2.0 → contractme-2.3.0}/tests/test_types.py +0 -0
  69. {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.2.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "contractme"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "Executable specifications for python. Keep control of the code AI writes for you."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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, and recursive types.
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
- violation = ContractViolation(
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
- violation = ContractViolation(
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
- return _ContractWrapper[F_Params, F_Ret](
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 `@annotated`, a `@record`, or a factory
272
- function** — `@precondition` on `__init__` raises `RuntimeError` at decoration time.
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.** Wrap the loop in
281
- `with local_policy("off")` and re-prove the output once with a postcondition on the
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
- - **`isinstance` semantics**: `bool` passes an `int` check; exact-type matching is not
302
- supported. Unknown annotations are ignored (open world), never a failure.
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(val, constraints: list[Constraint]) -> list[NormalizedConstraint]:
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
- if not isinstance(value, type_info.origin):
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