contractme 2.3.0__tar.gz → 2.4.2__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 (71) hide show
  1. {contractme-2.3.0 → contractme-2.4.2}/.flake8 +1 -1
  2. {contractme-2.3.0 → contractme-2.4.2}/.gitignore +1 -0
  3. {contractme-2.3.0 → contractme-2.4.2}/.gitlab-ci.yml +79 -12
  4. {contractme-2.3.0 → contractme-2.4.2}/CHANGELOG.md +53 -0
  5. {contractme-2.3.0 → contractme-2.4.2}/CLAUDE.md +4 -0
  6. contractme-2.3.0/README.md → contractme-2.4.2/PKG-INFO +41 -8
  7. contractme-2.3.0/PKG-INFO → contractme-2.4.2/README.md +22 -27
  8. {contractme-2.3.0 → contractme-2.4.2}/justfile +5 -0
  9. {contractme-2.3.0 → contractme-2.4.2}/pyproject.toml +6 -2
  10. contractme-2.4.2/release_notes.md +8 -0
  11. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/annotations.py +25 -14
  12. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/config.py +15 -2
  13. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/contracting.py +215 -70
  14. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/probing.py +3 -3
  15. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/records.py +11 -14
  16. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/skills/contractme/SKILL.md +22 -4
  17. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/typecheck.py +204 -22
  18. {contractme-2.3.0 → contractme-2.4.2}/tests/conftest.py +2 -2
  19. {contractme-2.3.0 → contractme-2.4.2}/tests/test_contracting.py +218 -1
  20. contractme-2.4.2/tests/test_evaluation_order.py +323 -0
  21. {contractme-2.3.0 → contractme-2.4.2}/tests/test_optimized_policy.py +61 -0
  22. contractme-2.4.2/tests/test_predicate_source.py +408 -0
  23. contractme-2.4.2/tox.ini +21 -0
  24. {contractme-2.3.0 → contractme-2.4.2}/uv.lock +186 -13
  25. {contractme-2.3.0 → contractme-2.4.2}/.pre-commit-config.yaml +0 -0
  26. {contractme-2.3.0 → contractme-2.4.2}/.python-version +0 -0
  27. {contractme-2.3.0 → contractme-2.4.2}/.vscode/settings.json +0 -0
  28. {contractme-2.3.0 → contractme-2.4.2}/LICENSE +0 -0
  29. {contractme-2.3.0 → contractme-2.4.2}/context7.json +0 -0
  30. {contractme-2.3.0 → contractme-2.4.2}/docs/comparison-facts.yaml +0 -0
  31. {contractme-2.3.0 → contractme-2.4.2}/docs/comparison.md +0 -0
  32. {contractme-2.3.0 → contractme-2.4.2}/docs/types.md +0 -0
  33. {contractme-2.3.0 → contractme-2.4.2}/release/prerelease.py +0 -0
  34. {contractme-2.3.0 → contractme-2.4.2}/release/pypi_mint_token.py +0 -0
  35. {contractme-2.3.0 → contractme-2.4.2}/release/release_notes.py +0 -0
  36. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/__init__.py +0 -0
  37. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/policy.py +0 -0
  38. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/py.typed +0 -0
  39. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/result.py +0 -0
  40. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/testing.py +0 -0
  41. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/__init__.py +0 -0
  42. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/containers.py +0 -0
  43. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/data.py +0 -0
  44. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/identifiers.py +0 -0
  45. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/network.py +0 -0
  46. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/numeric.py +0 -0
  47. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/paths.py +0 -0
  48. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/predicates.py +0 -0
  49. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/system.py +0 -0
  50. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/temporal.py +0 -0
  51. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/types/text.py +0 -0
  52. {contractme-2.3.0 → contractme-2.4.2}/src/contractme/typeshape.py +0 -0
  53. {contractme-2.3.0 → contractme-2.4.2}/tests/test_annotations.py +0 -0
  54. {contractme-2.3.0 → contractme-2.4.2}/tests/test_annotations_future.py +0 -0
  55. {contractme-2.3.0 → contractme-2.4.2}/tests/test_async.py +0 -0
  56. {contractme-2.3.0 → contractme-2.4.2}/tests/test_canaries.py +0 -0
  57. {contractme-2.3.0 → contractme-2.4.2}/tests/test_contracting_optimized_python.py +0 -0
  58. {contractme-2.3.0 → contractme-2.4.2}/tests/test_hypothesis.py +0 -0
  59. {contractme-2.3.0 → contractme-2.4.2}/tests/test_hypothesis_async.py +0 -0
  60. {contractme-2.3.0 → contractme-2.4.2}/tests/test_layering.py +0 -0
  61. {contractme-2.3.0 → contractme-2.4.2}/tests/test_literal.py +0 -0
  62. {contractme-2.3.0 → contractme-2.4.2}/tests/test_policy.py +0 -0
  63. {contractme-2.3.0 → contractme-2.4.2}/tests/test_probing.py +0 -0
  64. {contractme-2.3.0 → contractme-2.4.2}/tests/test_records.py +0 -0
  65. {contractme-2.3.0 → contractme-2.4.2}/tests/test_reentrancy.py +0 -0
  66. {contractme-2.3.0 → contractme-2.4.2}/tests/test_show_source.py +0 -0
  67. {contractme-2.3.0 → contractme-2.4.2}/tests/test_typechecks.py +0 -0
  68. {contractme-2.3.0 → contractme-2.4.2}/tests/test_types.py +0 -0
  69. {contractme-2.3.0 → contractme-2.4.2}/tests/test_types_advanced.py +0 -0
  70. {contractme-2.3.0 → contractme-2.4.2}/tests/test_types_extra.py +0 -0
  71. {contractme-2.3.0 → contractme-2.4.2}/tests/test_typeshape.py +0 -0
@@ -4,4 +4,4 @@ max-line-length = 100
4
4
  # E741: `l` is used on purpose as the list parameter in the design-by-contract
5
5
  # examples (e.g. append_count(l, n)), mirroring the README
6
6
  extend-ignore = E203, E741
7
- exclude = .venv, obj, build, dist, .git, __pycache__
7
+ exclude = .venv, .uv-cache, obj, build, dist, .git, __pycache__
@@ -14,3 +14,4 @@ obj
14
14
 
15
15
  # tooling
16
16
  .claude/
17
+ .uv-cache/
@@ -1,10 +1,25 @@
1
1
  variables:
2
2
  UV_VERSION: 0.9
3
- PYTHON_VERSION: 3.12
3
+ # Matches pyproject's requires-python (>=3.13): the image ships the interpreter,
4
+ # so uv no longer downloads one in every job (saves ~10s per job).
5
+ PYTHON_VERSION: 3.13
4
6
  BASE_LAYER: bookworm-slim
5
7
  # GitLab CI creates a separate mountpoint for the build directory,
6
8
  # so we need to copy instead of using hard links.
7
9
  UV_LINK_MODE: copy
10
+ # Project-local so the `cache:` of .uv below can store it between jobs.
11
+ UV_CACHE_DIR: .uv-cache
12
+
13
+ # Pipelines exist only for MR events, main pushes and release tags. A new commit
14
+ # cancels the still-running `interruptible` jobs of the older pipeline, so a burst
15
+ # of pushes bills one test run instead of one per push.
16
+ workflow:
17
+ auto_cancel:
18
+ on_new_commit: interruptible
19
+ rules:
20
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
21
+ - if: $CI_COMMIT_BRANCH == "main"
22
+ - if: $CI_COMMIT_TAG =~ /^v/
8
23
 
9
24
  stages:
10
25
  - test
@@ -17,16 +32,50 @@ stages:
17
32
  rules:
18
33
  - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
19
34
 
35
+ # Skip the test jobs on pushes that touch nothing the tests read (docs, README,
36
+ # CHANGELOG...): no minutes spent. release/ is linted and type-checked, hence listed.
37
+ .on-code-change:
38
+ rules:
39
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
40
+ changes: &code-paths
41
+ - src/**/*
42
+ - tests/**/*
43
+ - release/**/*
44
+ - pyproject.toml
45
+ - uv.lock
46
+ - justfile
47
+ - tox.ini
48
+ - .flake8
49
+ - .gitlab-ci.yml
50
+ when: always
51
+ - if: $CI_COMMIT_BRANCH == "main"
52
+ changes: *code-paths
53
+ when: always
54
+
55
+ # The uv cache (wheels, hypothesis/pyright deps) is shared across jobs and
56
+ # pipelines, keyed on uv.lock: saves the re-download of every dependency each run.
57
+ # `uv cache prune --ci` drops what is cheap to rebuild so the cache stays small.
20
58
  .uv:
21
59
  image: ghcr.io/astral-sh/uv:$UV_VERSION-python$PYTHON_VERSION-$BASE_LAYER
60
+ cache:
61
+ key:
62
+ files:
63
+ - uv.lock
64
+ paths:
65
+ - .uv-cache
66
+ after_script:
67
+ - uv cache prune --ci
22
68
 
23
69
  # The scripts are one-liner justfile recipe calls (`uv run just ...`, the just
24
70
  # binary comes from the dev deps) so CI runs exactly what `just check` runs
25
71
  # locally; only the report/artifact flags are CI extras, passed through the
26
72
  # recipes' *args.
27
73
  checks:
28
- extends: .uv
74
+ extends:
75
+ - .uv
76
+ - .on-code-change
29
77
  stage: test
78
+ interruptible: true
30
79
  before_script:
31
80
  - |
32
81
  if echo "$CI_MERGE_REQUEST_TITLE" | grep -iq "^draft:\|^wip:"; then
@@ -61,11 +110,6 @@ checks:
61
110
  coverage_format: cobertura
62
111
  path: obj/cov.xml
63
112
  junit: obj/tests_report.xml
64
- rules:
65
- - if: $CI_PIPELINE_SOURCE == "merge_request_event"
66
- when: always
67
- - if: $CI_COMMIT_BRANCH == "main"
68
- when: always
69
113
 
70
114
  # Differential run: contracts are pure redundancy, so with them compiled out
71
115
  # (python -O) the suite must still pass on valid inputs. Contract-violation
@@ -74,18 +118,41 @@ checks:
74
118
  # The ignored warning is pytest noticing library asserts are off — which is
75
119
  # exactly what this job is about.
76
120
  checks-optimized:
77
- extends: .uv
121
+ extends:
122
+ - .uv
123
+ - .on-code-change
78
124
  stage: test
125
+ interruptible: true
79
126
  script:
80
127
  - uv run just test-optimized --junitxml=obj/tests_report_optimized.xml
81
128
  artifacts:
82
129
  reports:
83
130
  junit: obj/tests_report_optimized.xml
131
+
132
+ # Python 3.14: one extra job runs both version-sensitive gates
133
+ # (coverage + the -O run) through tox; lint and types stay on 3.13 in `checks`.
134
+ # UV_PYTHON makes the outer `uv run` use the image's interpreter instead of
135
+ # downloading .python-version's 3.13. A draft MR tolerates failures here, like
136
+ # the lax mode of `checks`.
137
+ checks-py314:
138
+ extends: .uv
139
+ stage: test
140
+ interruptible: true
141
+ variables:
142
+ PYTHON_VERSION: "3.14"
143
+ UV_PYTHON: "3.14"
84
144
  rules:
85
- - if: $CI_PIPELINE_SOURCE == "merge_request_event"
86
- when: always
87
- - if: $CI_COMMIT_BRANCH == "main"
88
- when: always
145
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE =~ /^(draft|wip):/i
146
+ changes: *code-paths
147
+ allow_failure: true
148
+ - !reference [.on-code-change, rules]
149
+ script:
150
+ - uv run just tox -e py314
151
+ artifacts:
152
+ reports:
153
+ junit:
154
+ - obj/py314/tests_report.xml
155
+ - obj/py314/tests_report_optimized.xml
89
156
 
90
157
  # Validate the tag against pyproject + CHANGELOG, extract the release notes,
91
158
  # then build dists.
@@ -7,6 +7,59 @@ 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.4.2
11
+
12
+ * On Python 3.14, `@annotated` stacked over `@precondition` (or any contracted function
13
+ passed to `typing.get_type_hints`) works again: it raised
14
+ `NameError: name 'ClassVar' is not defined` at decoration since v2.4.0
15
+ ([#104](https://gitlab.com/leogermond/contractme/-/issues/104)).
16
+ * The test suite runs on Python 3.13 and 3.14, locally (`just tox`) and in CI
17
+ ([9b69657](https://gitlab.com/leogermond/contractme/-/commit/9b69657)).
18
+ * `just types` installs pyright with its bundled node, so a broken nodeenv no longer fails CI
19
+ ([51e80c6](https://gitlab.com/leogermond/contractme/-/commit/51e80c6)).
20
+
21
+ ## v2.4.1
22
+
23
+ * A failed `Predicate` constraint is named by its source when it is a lambda
24
+ (`Predicate(lambda s: s.islower())`), by its qualified name otherwise, in `type_error`,
25
+ `@record` and the `@annotated` header, where it used to print `<function <lambda> at 0x…>`;
26
+ a lambda with default arguments, or whose source cannot be proven to match, falls back to
27
+ its qualified name ([#80](https://gitlab.com/leogermond/contractme/-/issues/80)).
28
+ * A passing check and every `accepts`/`rejects` probe build no message, unions included:
29
+ the text is written only for the failure that gets reported
30
+ ([#80](https://gitlab.com/leogermond/contractme/-/issues/80)).
31
+ * The `@annotated` header lists its constraints as `under constraints [...]`, where it used
32
+ to show a tuple ([#80](https://gitlab.com/leogermond/contractme/-/issues/80)).
33
+ * v2.4.0 never reached PyPI (its CI build failed on the in-project uv cache): v2.4.1 ships
34
+ its changes too, see below ([5739ee2](https://gitlab.com/leogermond/contractme/-/commit/5739ee2)).
35
+
36
+ ## v2.4.0
37
+
38
+ * A decorator built with `functools.wraps` around a contracted function keeps its own
39
+ body: it used to be silently skipped, the contracted function running in its place
40
+ ([#73](https://gitlab.com/leogermond/contractme/-/issues/73)).
41
+ * `@annotated` type checks now run before the explicit `@precondition`/`@postcondition`,
42
+ whatever the decorator order, so a wrong type is reported as such instead of a more
43
+ distant contract failing, or a precondition crashing on the ill-typed argument, or a
44
+ postcondition crashing on the ill-typed result under `observe`/`warn`
45
+ ([#78](https://gitlab.com/leogermond/contractme/-/issues/78)).
46
+ * A callable `message=` receives the parameters it declares itself (`result` and
47
+ `old` included), independently of the predicate; it used to raise `KeyError` in
48
+ place of the violation
49
+ ([#70](https://gitlab.com/leogermond/contractme/-/issues/70)).
50
+ * A callable `message=` naming a parameter the function lacks is refused with a
51
+ `TypeError` when the contract is declared, instead of a `KeyError` at its first
52
+ failure ([#70](https://gitlab.com/leogermond/contractme/-/issues/70)).
53
+ * A `functools.wraps` wrapper is told apart from the function it wraps by
54
+ `@no_contracts` and by the contract decorators: marking a wrapper of a contracted
55
+ function, or contracting a wrapper of a `@no_contracts` function, now works
56
+ ([#74](https://gitlab.com/leogermond/contractme/-/issues/74)).
57
+ * A contracted function exposes `__name__`, `__qualname__` and `__doc__` to the type
58
+ checker, like a plain function: `step.__name__` passes pyright
59
+ ([#77](https://gitlab.com/leogermond/contractme/-/issues/77)).
60
+ * CI spends fewer runner minutes (Python 3.13 image, uv cache, tests skipped on
61
+ doc-only pushes) ([5f70e0d](https://gitlab.com/leogermond/contractme/-/commit/5f70e0d)).
62
+
10
63
  ## v2.3.0
11
64
 
12
65
  * Contracts now work on `async def`. `@precondition`, `@postcondition` and
@@ -24,6 +24,10 @@ just test-optimized # differential run: contracts compiled out via -O, valid-p
24
24
  # behavior must be identical (mirrors the checks-optimized CI
25
25
  # job). Contract-violation tests skip themselves via
26
26
  # tests/conftest.py:contract_violation().
27
+ just tox [args...] # cov + test-optimized on Python 3.13 and 3.14 (tox.ini, tox-uv:
28
+ # each env synced from uv.lock's dev group); mirrors the
29
+ # checks-py314 CI job:
30
+ # just tox -e py314 -- -x
27
31
  just fmt # format code (black)
28
32
  just lint # black --check + flake8
29
33
  just types # type checking (pinned pyright from the dev deps — matches CI)
@@ -1,3 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: contractme
3
+ Version: 2.4.2
4
+ Summary: Executable specifications for python. Keep control of the code AI writes for you.
5
+ Project-URL: Repository, https://gitlab.com/leogermond/contractme
6
+ Author-email: Leo Germond <leo.germond@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.13
10
+ Requires-Dist: annotated-types>=0.7.0
11
+ Requires-Dist: hypothesis>=6.131.9
12
+ Provides-Extra: cron
13
+ Requires-Dist: croniter>=2.0.0; extra == 'cron'
14
+ Provides-Extra: iso
15
+ Requires-Dist: pycountry>=23.12.11; extra == 'iso'
16
+ Provides-Extra: yaml
17
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
18
+ Description-Content-Type: text/markdown
19
+
1
20
  # ContractMe — executable specifications for Python
2
21
 
3
22
  ## Code is free. Bugs are not. Write the spec instead.
@@ -50,11 +69,12 @@ ready-made types](docs/types.md) (`Port`, `EmailStr`, `Positive`, …).
50
69
 
51
70
  ```python
52
71
  import math
53
- from contractme import precondition, postcondition
72
+ from contractme import annotated, postcondition
73
+ from contractme.types import NonNegativeFloat
54
74
 
55
- @precondition(lambda x: x >= 0)
75
+ @annotated
56
76
  @postcondition(lambda x, result: math.isclose(result * result, x, abs_tol=1e-12))
57
- def square_root(x: float) -> float:
77
+ def square_root(x: NonNegativeFloat) -> NonNegativeFloat:
58
78
  return x**0.5
59
79
  ```
60
80
 
@@ -65,13 +85,24 @@ it — this is the ground only contracts cover.
65
85
  Rules can even compare the state before and after the call (`old`):
66
86
 
67
87
  ```python
68
- @precondition(lambda l, n: n >= 0)
69
- @postcondition(lambda l, n: l[-1] == n) # element appended
70
- @postcondition(lambda l, n, old: l[:-1] == old.l) # nothing lost
71
- def append_count(l: list[int], n: int):
88
+ from contractme import annotated, postcondition
89
+ from contractme.types import NonNegativeInt
90
+
91
+ @annotated
92
+ @postcondition(lambda l, n: l[-1] == n) # element appended
93
+ @postcondition(lambda l, old: l[:-1] == old.l) # nothing lost
94
+ def append_count(l: list[NonNegativeInt], n: NonNegativeInt):
72
95
  l.append(n)
73
96
  ```
74
97
 
98
+ Stack `@annotated` and the explicit rules in any order: the type checks run
99
+ first — parameter types, in signature order, before the preconditions; the return
100
+ type before the postconditions — so a violation names its most immediate cause.
101
+ The types guard the relational rules of their side: when a parameter type fails,
102
+ it is reported and the preconditions stay unevaluated; when the return type fails,
103
+ it is reported and the postconditions stay unevaluated. Write the relational rules
104
+ for well-typed values only.
105
+
75
106
  ## When the generated code is wrong, you hear about it
76
107
 
77
108
  Say your agent "implements" `square_root` as `return x / 2` — plausible at a
@@ -464,7 +495,9 @@ cost nothing where it matters.
464
495
  `just test`
465
496
 
466
497
  Dev commands are `just` recipes — bare `just` lists them; `just check` runs the
467
- full local gate (tests + coverage, the `-O` differential run, lint, types).
498
+ full local gate (tests + coverage, the `-O` differential run, lint, types); `just tox`
499
+ runs the version-sensitive gates (coverage, the `-O` run) on every supported Python
500
+ (3.13, 3.14).
468
501
 
469
502
  # Deploy new version
470
503
 
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: contractme
3
- Version: 2.3.0
4
- Summary: Executable specifications for python. Keep control of the code AI writes for you.
5
- Project-URL: Repository, https://gitlab.com/leogermond/contractme
6
- Author-email: Leo Germond <leo.germond@gmail.com>
7
- License-Expression: Apache-2.0
8
- License-File: LICENSE
9
- Requires-Python: >=3.13
10
- Requires-Dist: annotated-types>=0.7.0
11
- Requires-Dist: hypothesis>=6.131.9
12
- Provides-Extra: cron
13
- Requires-Dist: croniter>=2.0.0; extra == 'cron'
14
- Provides-Extra: iso
15
- Requires-Dist: pycountry>=23.12.11; extra == 'iso'
16
- Provides-Extra: yaml
17
- Requires-Dist: pyyaml>=6.0; extra == 'yaml'
18
- Description-Content-Type: text/markdown
19
-
20
1
  # ContractMe — executable specifications for Python
21
2
 
22
3
  ## Code is free. Bugs are not. Write the spec instead.
@@ -69,11 +50,12 @@ ready-made types](docs/types.md) (`Port`, `EmailStr`, `Positive`, …).
69
50
 
70
51
  ```python
71
52
  import math
72
- from contractme import precondition, postcondition
53
+ from contractme import annotated, postcondition
54
+ from contractme.types import NonNegativeFloat
73
55
 
74
- @precondition(lambda x: x >= 0)
56
+ @annotated
75
57
  @postcondition(lambda x, result: math.isclose(result * result, x, abs_tol=1e-12))
76
- def square_root(x: float) -> float:
58
+ def square_root(x: NonNegativeFloat) -> NonNegativeFloat:
77
59
  return x**0.5
78
60
  ```
79
61
 
@@ -84,13 +66,24 @@ it — this is the ground only contracts cover.
84
66
  Rules can even compare the state before and after the call (`old`):
85
67
 
86
68
  ```python
87
- @precondition(lambda l, n: n >= 0)
88
- @postcondition(lambda l, n: l[-1] == n) # element appended
89
- @postcondition(lambda l, n, old: l[:-1] == old.l) # nothing lost
90
- def append_count(l: list[int], n: int):
69
+ from contractme import annotated, postcondition
70
+ from contractme.types import NonNegativeInt
71
+
72
+ @annotated
73
+ @postcondition(lambda l, n: l[-1] == n) # element appended
74
+ @postcondition(lambda l, old: l[:-1] == old.l) # nothing lost
75
+ def append_count(l: list[NonNegativeInt], n: NonNegativeInt):
91
76
  l.append(n)
92
77
  ```
93
78
 
79
+ Stack `@annotated` and the explicit rules in any order: the type checks run
80
+ first — parameter types, in signature order, before the preconditions; the return
81
+ type before the postconditions — so a violation names its most immediate cause.
82
+ The types guard the relational rules of their side: when a parameter type fails,
83
+ it is reported and the preconditions stay unevaluated; when the return type fails,
84
+ it is reported and the postconditions stay unevaluated. Write the relational rules
85
+ for well-typed values only.
86
+
94
87
  ## When the generated code is wrong, you hear about it
95
88
 
96
89
  Say your agent "implements" `square_root` as `return x / 2` — plausible at a
@@ -483,7 +476,9 @@ cost nothing where it matters.
483
476
  `just test`
484
477
 
485
478
  Dev commands are `just` recipes — bare `just` lists them; `just check` runs the
486
- full local gate (tests + coverage, the `-O` differential run, lint, types).
479
+ full local gate (tests + coverage, the `-O` differential run, lint, types); `just tox`
480
+ runs the version-sensitive gates (coverage, the `-O` run) on every supported Python
481
+ (3.13, 3.14).
487
482
 
488
483
  # Deploy new version
489
484
 
@@ -24,6 +24,11 @@ cov *args:
24
24
  test-optimized *args:
25
25
  uv run python -O -m pytest -W ignore::pytest.PytestConfigWarning "$@"
26
26
 
27
+ # Envs and args pass through to tox (e.g. `just tox -e py314 -- -x`); see tox.ini.
28
+ [doc('Coverage gate + -O differential run on every supported Python (3.13, 3.14)')]
29
+ tox *args:
30
+ uv run tox "$@"
31
+
27
32
  # Format the code (black, line length 100)
28
33
  fmt:
29
34
  uv run black .
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "contractme"
3
- version = "2.3.0"
3
+ version = "2.4.2"
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"
@@ -30,7 +30,9 @@ build-backend = "hatchling.build"
30
30
  [dependency-groups]
31
31
  dev = [
32
32
  "epycs>=1.4.0",
33
- "pyright==1.1.407",
33
+ # [nodejs] ships node as a wheel: pyright's nodeenv fallback crashes on
34
+ # some node release listings (CI image has no node of its own)
35
+ "pyright[nodejs]==1.1.407",
34
36
  "pytest>=8.3.5",
35
37
  "pytest-cov>=6.0.0",
36
38
  "black>=25.9.0",
@@ -42,6 +44,8 @@ dev = [
42
44
  "croniter>=2.0.0",
43
45
  "pycountry>=23.12.11",
44
46
  "pyyaml>=6.0",
47
+ "tox>=4.67.12",
48
+ "tox-uv>=1.37.5",
45
49
  ]
46
50
 
47
51
  [tool.pytest.ini_options]
@@ -0,0 +1,8 @@
1
+ * On Python 3.14, `@annotated` stacked over `@precondition` (or any contracted function
2
+ passed to `typing.get_type_hints`) works again: it raised
3
+ `NameError: name 'ClassVar' is not defined` at decoration since v2.4.0
4
+ ([#104](https://gitlab.com/leogermond/contractme/-/issues/104)).
5
+ * The test suite runs on Python 3.13 and 3.14, locally (`just tox`) and in CI
6
+ ([9b69657](https://gitlab.com/leogermond/contractme/-/commit/9b69657)).
7
+ * `just types` installs pyright with its bundled node, so a broken nodeenv no longer fails CI
8
+ ([51e80c6](https://gitlab.com/leogermond/contractme/-/commit/51e80c6)).
@@ -1,6 +1,6 @@
1
1
  from typing import Callable, cast, get_type_hints
2
- from contractme.contracting import ContractedFunction, precondition, postcondition
3
- from contractme.typecheck import type_error
2
+ from contractme.contracting import PRE, POST, ContractedFunction, condition_decorator
3
+ from contractme.typecheck import conforms, constraint_text, type_error
4
4
  from contractme.typeshape import (
5
5
  CompositeTypeInfo,
6
6
  resolve_type,
@@ -16,9 +16,11 @@ def annotated[**F_Params, F_Ret](
16
16
  For each parameter and the return value, ``@annotated`` auto-generates a
17
17
  ``@precondition`` (parameters) or ``@postcondition`` (return) that validates
18
18
  both the type structure and any ``annotated-types`` constraints (``Ge``,
19
- ``Lt``, ``MinLen``, ``Predicate``, ...). Every type shape :mod:`typeshape`
20
- models is supported — scalars, containers, unions, tuples, dataclasses
21
- (field-by-field), enums, ``Literal`` alternatives, ``Protocol`` duck typing,
19
+ ``Lt``, ``MinLen``, ``Predicate``, ...). These type checks run before the
20
+ explicit ``@precondition``/``@postcondition`` of the same function, whatever
21
+ the decorator order: a violation names its most immediate cause. Every type
22
+ shape :mod:`typeshape` models is supported — scalars, containers, unions,
23
+ tuples, dataclasses (field-by-field), enums, ``Literal`` alternatives, ``Protocol`` duck typing,
22
24
  and recursive types.
23
25
 
24
26
  Validation is two-phase: structure first ("is this a list?"), then
@@ -40,28 +42,35 @@ def annotated[**F_Params, F_Ret](
40
42
 
41
43
  def check_annotated_arg(arg_name, arg_an: AnnotationForm):
42
44
  info = CompositeTypeInfo(resolve_type(arg_an))
43
- msg = f"{arg_name} should be instance of {info.base}"
44
- if info.constraints:
45
- msg += f" under constraints {tuple(info.constraints)}"
45
+
46
+ def header() -> str:
47
+ # built on failure only: naming a lambda by its source reads and
48
+ # compiles its module, a cost decoration (an import) never pays
49
+ msg = f"{arg_name} should be instance of {info.base}"
50
+ if info.constraints:
51
+ msg += f" under constraints [{', '.join(map(constraint_text, info.constraints))}]"
52
+ return msg
46
53
 
47
54
  if arg_name == "return":
48
55
  cond_arg_name = "result"
49
- cond_f = postcondition
56
+ kind = POST
50
57
  else:
51
58
  cond_arg_name = arg_name
52
- cond_f = precondition
59
+ kind = PRE
53
60
 
54
61
  def chk(**kw):
55
- return type_error(kw[cond_arg_name], arg_an) is None
62
+ return conforms(kw[cond_arg_name], arg_an)
56
63
 
57
64
  def describe_failure(**kw):
58
65
  # only evaluated at failure time (by the raising path, never by the
59
66
  # accepts/rejects probes), so re-running the check is affordable and
60
67
  # the error is known to exist
61
68
  err = type_error(kw[cond_arg_name], arg_an)
62
- return f"{msg}: {err}"
69
+ return f"{header()}: {err}"
63
70
 
64
- return cond_f(chk, describe_failure)
71
+ # a type check: it runs before the explicit pre/postconditions,
72
+ # whatever the decorator order (issue #78)
73
+ return condition_decorator(kind, chk, describe_failure, type_check=True)
65
74
 
66
75
  # `include_extras=True` keeps the `Annotated[...]` metadata (plain
67
76
  # `get_type_hints` strips it), and, crucially, `get_type_hints` *resolves*
@@ -70,7 +79,9 @@ def annotated[**F_Params, F_Ret](
70
79
  # `f.__annotations__` would be strings and silently disable every check.
71
80
  ans = get_type_hints(f, include_extras=True)
72
81
 
73
- for n, an in ans.items():
82
+ # each check prepends to the type checks: apply them last hint first, so the
83
+ # parameter types run in signature order
84
+ for n, an in reversed(ans.items()):
74
85
  f = check_annotated_arg(n, an)(f)
75
86
 
76
87
  # the loop is invisible statically: with any hint at all, `f` is now the
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
 
28
28
  import os
29
29
  from contextlib import contextmanager
30
+ from types import MethodType
30
31
  from typing import Any, Callable
31
32
 
32
33
  from contractme.policy import (
@@ -50,10 +51,22 @@ def no_contracts(f: Callable) -> Callable:
50
51
  """Mark ``f`` as never contractable: applying a contract to it raises at
51
52
  decoration time. The engine's check-path helpers rely on it; also usable on
52
53
  any function that must stay contract-free (hot paths, engine extensions).
54
+
55
+ Both ways are decided by *identity* (issue #74, as #73 for ``get_wrapped``):
56
+ ``functools.wraps`` copies ``__dict__``, so a wrapper carries the wrapped
57
+ function's ``contractme_*`` attributes. "Already contracted" is the engine's
58
+ wrapper — its *class* carries ``contractme_engine_wrapper``, which ``wraps``
59
+ never copies — or, under ``optimized``, a function whose
60
+ ``contractme_wrapped`` is itself. The mark stores the marked function, so it
61
+ designates that function alone. A bound method designates its function
62
+ (``__func__``, the very object, so identity still holds).
53
63
  """
54
- if hasattr(f, "contractme_wrapped"):
64
+ target = f.__func__ if isinstance(f, MethodType) else f
65
+ if getattr(type(target), "contractme_engine_wrapper", False) or (
66
+ getattr(target, "contractme_wrapped", None) is target
67
+ ):
55
68
  raise RuntimeError(f"{f.__name__}() is already contracted")
56
- setattr(f, "contractme_no_contracts", True)
69
+ setattr(f, "contractme_no_contracts", f)
57
70
  return f
58
71
 
59
72