pyintent 0.1.0__py3-none-any.whl
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.
- pyintent/__init__.py +56 -0
- pyintent/_discovery.py +105 -0
- pyintent/_effects.py +166 -0
- pyintent/_errors.py +15 -0
- pyintent/_loader.py +56 -0
- pyintent/_module_spec.py +68 -0
- pyintent/_parser.py +198 -0
- pyintent/_perf.py +49 -0
- pyintent/_spec.py +375 -0
- pyintent/cli.py +240 -0
- pyintent/plugin.py +155 -0
- pyintent/prompt.py +142 -0
- pyintent/py.typed +0 -0
- pyintent/verifier/__init__.py +64 -0
- pyintent/verifier/_result.py +42 -0
- pyintent/verifier/effects.py +169 -0
- pyintent/verifier/examples.py +142 -0
- pyintent/verifier/properties.py +196 -0
- pyintent/verifier/types.py +50 -0
- pyintent-0.1.0.dist-info/METADATA +255 -0
- pyintent-0.1.0.dist-info/RECORD +25 -0
- pyintent-0.1.0.dist-info/WHEEL +5 -0
- pyintent-0.1.0.dist-info/entry_points.txt +5 -0
- pyintent-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyintent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Examples verifier — run every ``ex`` case against the real implementation.
|
|
2
|
+
|
|
3
|
+
Runnable in v0.1 for module-level functions, ``@staticmethod`` and
|
|
4
|
+
``@classmethod``. Instance methods and properties need an instance and are
|
|
5
|
+
skipped with a clear reason (planned for v0.2).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .._errors import PyIntentSpecError
|
|
14
|
+
from .._parser import Example, Raises, ReturnsValue, _Wildcard
|
|
15
|
+
from .._spec import SpecLevel
|
|
16
|
+
from .._discovery import SpecTarget
|
|
17
|
+
from ._result import CheckResult, Status
|
|
18
|
+
|
|
19
|
+
_RUNNABLE = {SpecLevel.FUNCTION, SpecLevel.STATICMETHOD, SpecLevel.CLASSMETHOD}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _safe_eq(a: object, b: object) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
return bool(a == b)
|
|
25
|
+
except Exception:
|
|
26
|
+
return repr(a) == repr(b)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _call(fn: Any, args: tuple, is_async: bool) -> Any:
|
|
30
|
+
if is_async:
|
|
31
|
+
return asyncio.run(fn(*args))
|
|
32
|
+
return fn(*args)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fmt_args(args: tuple) -> str:
|
|
36
|
+
inner = ", ".join(repr(a) for a in args)
|
|
37
|
+
if len(args) == 1:
|
|
38
|
+
inner += ","
|
|
39
|
+
return f"({inner})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def verify_example_case(target: SpecTarget, ex: Example) -> CheckResult:
|
|
43
|
+
"""Run exactly one example case (the function executes once)."""
|
|
44
|
+
sp = target.spec
|
|
45
|
+
if sp.level not in _RUNNABLE or target.invoke is None:
|
|
46
|
+
reason = f"{sp.level.value} examples require an instance (v0.2)"
|
|
47
|
+
return CheckResult("examples", sp.target_name, Status.SKIPPED, summary=reason, label=ex.raw)
|
|
48
|
+
return _run_one(target, ex)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_examples(target: SpecTarget) -> list[CheckResult]:
|
|
52
|
+
if not target.spec.examples:
|
|
53
|
+
return []
|
|
54
|
+
return [verify_example_case(target, ex) for ex in target.spec.examples]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_one(target: SpecTarget, ex: Example) -> CheckResult:
|
|
58
|
+
sp = target.spec
|
|
59
|
+
name = sp.target_name
|
|
60
|
+
globalns = target.globalns
|
|
61
|
+
try:
|
|
62
|
+
args = ex.eval_args(globalns)
|
|
63
|
+
except PyIntentSpecError as e:
|
|
64
|
+
return CheckResult("examples", name, Status.ERROR, summary=str(e), label=ex.raw)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
outcome = _call(target.invoke, args, sp.is_async)
|
|
68
|
+
except BaseException as exc: # noqa: BLE001 - we classify it below
|
|
69
|
+
return _check_raised(name, ex, exc, args, globalns)
|
|
70
|
+
return _check_returned(name, ex, outcome, args, globalns)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_raised(name, ex: Example, exc, args, globalns) -> CheckResult:
|
|
74
|
+
if isinstance(ex.expected, Raises):
|
|
75
|
+
try:
|
|
76
|
+
want = ex.expected.resolve(globalns)
|
|
77
|
+
except PyIntentSpecError as e:
|
|
78
|
+
return CheckResult("examples", name, Status.ERROR, summary=str(e), label=ex.raw)
|
|
79
|
+
if isinstance(exc, want):
|
|
80
|
+
return CheckResult("examples", name, Status.PASS, label=ex.raw)
|
|
81
|
+
detail = (
|
|
82
|
+
f"{name}{_fmt_args(args)}\n"
|
|
83
|
+
f" expected: raises {ex.expected.exc_name}\n"
|
|
84
|
+
f" actual: raised {type(exc).__name__}: {exc}"
|
|
85
|
+
)
|
|
86
|
+
return CheckResult(
|
|
87
|
+
"examples", name, Status.FAIL,
|
|
88
|
+
summary=f"raised {type(exc).__name__}, expected {ex.expected.exc_name}",
|
|
89
|
+
detail=detail, label=ex.raw,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expected_desc = (
|
|
93
|
+
"_ (returns without raising)"
|
|
94
|
+
if isinstance(ex.expected, _Wildcard)
|
|
95
|
+
else ex.expected.value_src
|
|
96
|
+
)
|
|
97
|
+
detail = (
|
|
98
|
+
f"{name}{_fmt_args(args)}\n"
|
|
99
|
+
f" expected: {expected_desc}\n"
|
|
100
|
+
f" actual: raised {type(exc).__name__}: {exc}"
|
|
101
|
+
)
|
|
102
|
+
return CheckResult(
|
|
103
|
+
"examples", name, Status.FAIL,
|
|
104
|
+
summary=f"unexpected {type(exc).__name__}: {exc}",
|
|
105
|
+
detail=detail, label=ex.raw,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_returned(name, ex: Example, outcome, args, globalns) -> CheckResult:
|
|
110
|
+
if isinstance(ex.expected, _Wildcard):
|
|
111
|
+
return CheckResult("examples", name, Status.PASS, label=ex.raw)
|
|
112
|
+
|
|
113
|
+
if isinstance(ex.expected, Raises):
|
|
114
|
+
detail = (
|
|
115
|
+
f"{name}{_fmt_args(args)}\n"
|
|
116
|
+
f" expected: raises {ex.expected.exc_name}\n"
|
|
117
|
+
f" actual: returned {outcome!r}"
|
|
118
|
+
)
|
|
119
|
+
return CheckResult(
|
|
120
|
+
"examples", name, Status.FAIL,
|
|
121
|
+
summary=f"returned {outcome!r}, expected raise {ex.expected.exc_name}",
|
|
122
|
+
detail=detail, label=ex.raw,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
assert isinstance(ex.expected, ReturnsValue)
|
|
126
|
+
try:
|
|
127
|
+
expected_value = ex.expected.resolve(globalns)
|
|
128
|
+
except PyIntentSpecError as e:
|
|
129
|
+
return CheckResult("examples", name, Status.ERROR, summary=str(e), label=ex.raw)
|
|
130
|
+
|
|
131
|
+
if _safe_eq(outcome, expected_value):
|
|
132
|
+
return CheckResult("examples", name, Status.PASS, label=ex.raw)
|
|
133
|
+
detail = (
|
|
134
|
+
f"{name}{_fmt_args(args)}\n"
|
|
135
|
+
f" expected: {expected_value!r}\n"
|
|
136
|
+
f" actual: {outcome!r}"
|
|
137
|
+
)
|
|
138
|
+
return CheckResult(
|
|
139
|
+
"examples", name, Status.FAIL,
|
|
140
|
+
summary=f"returned {outcome!r}, expected {expected_value!r}",
|
|
141
|
+
detail=detail, label=ex.raw,
|
|
142
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Properties verifier — hypothesis-generate inputs and check ``ensures``.
|
|
2
|
+
|
|
3
|
+
Only runs for pure, runnable callables (module functions / staticmethods /
|
|
4
|
+
classmethods with no impure declared effects). Effectful callables, instance
|
|
5
|
+
methods, and callables whose parameter types can't be mapped to a strategy are
|
|
6
|
+
skipped with a reason.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import builtins as _builtins
|
|
12
|
+
import inspect
|
|
13
|
+
import types
|
|
14
|
+
import typing
|
|
15
|
+
from typing import Any, get_args, get_origin
|
|
16
|
+
|
|
17
|
+
_BUILTINS_DICT = vars(_builtins)
|
|
18
|
+
|
|
19
|
+
from hypothesis import HealthCheck, given, settings
|
|
20
|
+
from hypothesis import strategies as st
|
|
21
|
+
from hypothesis.errors import UnsatisfiedAssumption
|
|
22
|
+
|
|
23
|
+
from .._spec import SpecLevel
|
|
24
|
+
from .._discovery import SpecTarget
|
|
25
|
+
from ._result import CheckResult, Status
|
|
26
|
+
|
|
27
|
+
_RUNNABLE = {SpecLevel.FUNCTION, SpecLevel.STATICMETHOD, SpecLevel.CLASSMETHOD}
|
|
28
|
+
_MAX_EXAMPLES = 50
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Unsupported(Exception):
|
|
32
|
+
def __init__(self, tp: Any) -> None:
|
|
33
|
+
super().__init__(str(tp))
|
|
34
|
+
self.tp = tp
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _strategy_for(tp: Any):
|
|
38
|
+
if tp is int:
|
|
39
|
+
return st.integers()
|
|
40
|
+
if tp is bool:
|
|
41
|
+
return st.booleans()
|
|
42
|
+
if tp is float:
|
|
43
|
+
return st.floats(allow_nan=False, allow_infinity=False)
|
|
44
|
+
if tp is str:
|
|
45
|
+
return st.text()
|
|
46
|
+
if tp is bytes:
|
|
47
|
+
return st.binary()
|
|
48
|
+
if tp is type(None):
|
|
49
|
+
return st.none()
|
|
50
|
+
|
|
51
|
+
origin = get_origin(tp)
|
|
52
|
+
args = get_args(tp)
|
|
53
|
+
if origin is list:
|
|
54
|
+
return st.lists(_strategy_for(args[0]) if args else st.integers())
|
|
55
|
+
if origin is set:
|
|
56
|
+
return st.sets(_strategy_for(args[0]) if args else st.integers())
|
|
57
|
+
if origin is frozenset:
|
|
58
|
+
return st.frozensets(_strategy_for(args[0]) if args else st.integers())
|
|
59
|
+
if origin is tuple:
|
|
60
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
61
|
+
return st.lists(_strategy_for(args[0])).map(tuple)
|
|
62
|
+
return st.tuples(*[_strategy_for(a) for a in args])
|
|
63
|
+
if origin is dict:
|
|
64
|
+
k = _strategy_for(args[0]) if args else st.text()
|
|
65
|
+
v = _strategy_for(args[1]) if len(args) > 1 else st.integers()
|
|
66
|
+
return st.dictionaries(k, v)
|
|
67
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
68
|
+
return st.one_of(*[_strategy_for(a) for a in args])
|
|
69
|
+
raise _Unsupported(tp)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _skip(name: str, reason: str) -> list[CheckResult]:
|
|
73
|
+
return [CheckResult("properties", name, Status.SKIPPED, summary=reason)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def verify_properties(target: SpecTarget) -> list[CheckResult]:
|
|
77
|
+
sp = target.spec
|
|
78
|
+
name = sp.target_name
|
|
79
|
+
|
|
80
|
+
if not sp.ensures:
|
|
81
|
+
return []
|
|
82
|
+
if sp.level not in _RUNNABLE or target.invoke is None:
|
|
83
|
+
return _skip(name, f"{sp.level.value} property tests need an instance (v0.2)")
|
|
84
|
+
if not sp.is_verifiable_pure:
|
|
85
|
+
return _skip(name, "effectful — property testing only runs on pure functions")
|
|
86
|
+
if sp.is_async:
|
|
87
|
+
return _skip(name, "async property testing deferred to v0.2")
|
|
88
|
+
|
|
89
|
+
fn = target.invoke
|
|
90
|
+
try:
|
|
91
|
+
sig = inspect.signature(fn)
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return _skip(name, "could not introspect signature")
|
|
94
|
+
|
|
95
|
+
params = [
|
|
96
|
+
p for p in sig.parameters.values()
|
|
97
|
+
if p.name not in ("self", "cls")
|
|
98
|
+
]
|
|
99
|
+
for p in params:
|
|
100
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
101
|
+
return _skip(name, "*args/**kwargs not supported for property testing")
|
|
102
|
+
if p.kind is p.POSITIONAL_ONLY:
|
|
103
|
+
return _skip(name, "positional-only parameters not supported")
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
hints = typing.get_type_hints(fn)
|
|
107
|
+
except Exception:
|
|
108
|
+
return _skip(name, "could not resolve type hints")
|
|
109
|
+
|
|
110
|
+
strategies = {}
|
|
111
|
+
for p in params:
|
|
112
|
+
if p.name not in hints:
|
|
113
|
+
return _skip(name, f"parameter '{p.name}' has no type annotation")
|
|
114
|
+
try:
|
|
115
|
+
strategies[p.name] = _strategy_for(hints[p.name])
|
|
116
|
+
except _Unsupported as u:
|
|
117
|
+
return _skip(name, f"no strategy for type {u.tp!r} (parameter '{p.name}')")
|
|
118
|
+
|
|
119
|
+
return [_run(target, strategies)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run(target: SpecTarget, strategies: dict) -> CheckResult:
|
|
123
|
+
sp = target.spec
|
|
124
|
+
name = sp.target_name
|
|
125
|
+
fn = target.invoke
|
|
126
|
+
assert fn is not None # guaranteed by verify_properties
|
|
127
|
+
globalns = target.globalns
|
|
128
|
+
failure: dict[str, Any] = {}
|
|
129
|
+
|
|
130
|
+
arg_strategy = st.fixed_dictionaries(strategies) if strategies else st.just({})
|
|
131
|
+
|
|
132
|
+
@settings(max_examples=_MAX_EXAMPLES, deadline=None,
|
|
133
|
+
suppress_health_check=list(HealthCheck))
|
|
134
|
+
@given(kwargs=arg_strategy)
|
|
135
|
+
def run(kwargs: dict) -> None:
|
|
136
|
+
for cond in sp.where:
|
|
137
|
+
try:
|
|
138
|
+
ok = bool(eval(cond, {"__builtins__": _BUILTINS_DICT, **globalns, **kwargs}))
|
|
139
|
+
except Exception:
|
|
140
|
+
ok = False
|
|
141
|
+
if not ok:
|
|
142
|
+
raise UnsatisfiedAssumption()
|
|
143
|
+
try:
|
|
144
|
+
result = fn(**kwargs)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
failure.update(kwargs=kwargs, result="<raised>", cond="(function call)", error=exc)
|
|
147
|
+
raise AssertionError(
|
|
148
|
+
f"function raised {type(exc).__name__}: {exc}"
|
|
149
|
+
) from exc
|
|
150
|
+
env = {**kwargs, "result": result}
|
|
151
|
+
for cond in sp.ensures:
|
|
152
|
+
try:
|
|
153
|
+
holds = bool(eval(cond, {"__builtins__": _BUILTINS_DICT, **globalns, **env}))
|
|
154
|
+
except Exception as e:
|
|
155
|
+
failure.update(kwargs=kwargs, result=result, cond=cond, error=e)
|
|
156
|
+
raise AssertionError(
|
|
157
|
+
f"ensures {cond!r} raised {type(e).__name__}: {e}"
|
|
158
|
+
) from e
|
|
159
|
+
if not holds:
|
|
160
|
+
failure.update(kwargs=kwargs, result=result, cond=cond, error=None)
|
|
161
|
+
raise AssertionError(f"ensures {cond!r} is False")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
run()
|
|
165
|
+
except AssertionError:
|
|
166
|
+
kwargs = failure.get("kwargs", {})
|
|
167
|
+
call = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
|
|
168
|
+
cond = failure.get("cond", "?")
|
|
169
|
+
result = failure.get("result", "?")
|
|
170
|
+
err = failure.get("error")
|
|
171
|
+
if err:
|
|
172
|
+
line = (
|
|
173
|
+
f"function raised {type(err).__name__}: {err}"
|
|
174
|
+
if cond == "(function call)"
|
|
175
|
+
else f"ensures {cond!r} raised {type(err).__name__}: {err}"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
line = f"ensures {cond!r} is False"
|
|
179
|
+
detail = (
|
|
180
|
+
f"{name}({call})\n"
|
|
181
|
+
f" returned: {result!r}\n"
|
|
182
|
+
f" {line}"
|
|
183
|
+
)
|
|
184
|
+
return CheckResult(
|
|
185
|
+
"properties", name, Status.FAIL,
|
|
186
|
+
summary=f"falsified: {line}", detail=detail,
|
|
187
|
+
)
|
|
188
|
+
except Exception as e: # noqa: BLE001
|
|
189
|
+
return CheckResult(
|
|
190
|
+
"properties", name, Status.ERROR,
|
|
191
|
+
summary=f"property run errored: {e}", detail=str(e),
|
|
192
|
+
)
|
|
193
|
+
return CheckResult(
|
|
194
|
+
"properties", name, Status.PASS,
|
|
195
|
+
summary=f"{len(sp.ensures)} ensures held over {_MAX_EXAMPLES} examples",
|
|
196
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Types verifier — run mypy over a file (optional).
|
|
2
|
+
|
|
3
|
+
Runs once per file, not per function. If mypy is not installed the check is
|
|
4
|
+
skipped (not failed), so mypy stays an optional dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from ._result import CheckResult, Status
|
|
14
|
+
|
|
15
|
+
_MYPY_AVAILABLE: bool | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mypy_available() -> bool:
|
|
19
|
+
global _MYPY_AVAILABLE
|
|
20
|
+
if _MYPY_AVAILABLE is None:
|
|
21
|
+
_MYPY_AVAILABLE = importlib.util.find_spec("mypy") is not None
|
|
22
|
+
return _MYPY_AVAILABLE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_types(filename: str) -> list[CheckResult]:
|
|
26
|
+
target = filename
|
|
27
|
+
if not _mypy_available():
|
|
28
|
+
return [CheckResult("types", target, Status.SKIPPED,
|
|
29
|
+
summary="mypy not installed (pip install pyintent[types])")]
|
|
30
|
+
try:
|
|
31
|
+
proc = subprocess.run(
|
|
32
|
+
[sys.executable, "-m", "mypy",
|
|
33
|
+
"--no-error-summary", "--hide-error-context",
|
|
34
|
+
"--no-color-output", "--follow-imports=silent",
|
|
35
|
+
"--ignore-missing-imports", filename],
|
|
36
|
+
capture_output=True, text=True, timeout=120,
|
|
37
|
+
)
|
|
38
|
+
except subprocess.TimeoutExpired:
|
|
39
|
+
return [CheckResult("types", target, Status.ERROR, summary="mypy timed out")]
|
|
40
|
+
except Exception as e: # noqa: BLE001
|
|
41
|
+
return [CheckResult("types", target, Status.ERROR, summary=f"mypy failed to run: {e}")]
|
|
42
|
+
|
|
43
|
+
out = (proc.stdout + proc.stderr).strip()
|
|
44
|
+
if proc.returncode == 0:
|
|
45
|
+
return [CheckResult("types", target, Status.PASS, summary="mypy clean")]
|
|
46
|
+
if proc.returncode == 1:
|
|
47
|
+
return [CheckResult("types", target, Status.FAIL,
|
|
48
|
+
summary="mypy reported type errors", detail=out)]
|
|
49
|
+
return [CheckResult("types", target, Status.ERROR,
|
|
50
|
+
summary="mypy usage error", detail=out)]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyintent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify that implementations satisfy intent specifications. A pure verifier for AI-generated Python code — it checks, it never generates.
|
|
5
|
+
Author: pyintent contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cshaley/pyintent
|
|
8
|
+
Project-URL: Repository, https://github.com/cshaley/pyintent
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/cshaley/pyintent/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/cshaley/pyintent/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: verification,specification,ai,codegen,contracts,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: click>=8.0
|
|
25
|
+
Requires-Dist: hypothesis>=6.0
|
|
26
|
+
Requires-Dist: pytest>=7.0
|
|
27
|
+
Provides-Extra: types
|
|
28
|
+
Requires-Dist: mypy>=1.0; extra == "types"
|
|
29
|
+
Provides-Extra: async
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "async"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
34
|
+
Provides-Extra: evals
|
|
35
|
+
Requires-Dist: openai>=1.40; extra == "evals"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# pyintent
|
|
39
|
+
|
|
40
|
+
Write your intent as a specification. Let any AI coding tool write the implementation. **pyintent verifies the implementation actually satisfies the intent.**
|
|
41
|
+
|
|
42
|
+
pyintent is a *pure verifier*. It never calls an LLM. Like `mypy` checks types without generating code, pyintent checks that an implementation matches its declared intent — examples, pre/post-conditions, effects, and types.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pyintent import spec, reads, throws
|
|
46
|
+
|
|
47
|
+
@spec(
|
|
48
|
+
intent = "Return the order with the given id from the database.",
|
|
49
|
+
where = ["order_id > 0"],
|
|
50
|
+
ensures = ["result.id == order_id"],
|
|
51
|
+
effects = [reads("db"), throws(NotFoundError)],
|
|
52
|
+
ex = [
|
|
53
|
+
"(42,) -> _",
|
|
54
|
+
"(999,) -> raises NotFoundError",
|
|
55
|
+
"(0,) -> raises ValueError",
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
def find_order(order_id: int) -> Order:
|
|
59
|
+
... # implemented by Claude Code / Copilot / Devin / you
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Then:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pyintent verify myapp/orders.py # run all verifiers, human-readable report
|
|
66
|
+
pytest --pyintent # specs become pytest items automatically
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install pyintent # core (examples, properties, effects)
|
|
73
|
+
pip install pyintent[types] # + mypy integration
|
|
74
|
+
pip install pyintent[dev] # + mypy + pytest-asyncio
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pyintent import spec, pure
|
|
81
|
+
|
|
82
|
+
@spec(
|
|
83
|
+
intent = "Return the absolute value of x.",
|
|
84
|
+
effects = [pure],
|
|
85
|
+
ensures = ["result >= 0", "result == x or result == -x"],
|
|
86
|
+
ex = ["(3,) -> 3", "(-4,) -> 4", "(0,) -> 0"],
|
|
87
|
+
)
|
|
88
|
+
def my_abs(x: int) -> int:
|
|
89
|
+
return x if x >= 0 else -x
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
$ pyintent verify mymodule.py
|
|
94
|
+
[PASS] examples my_abs (3,) -> 3
|
|
95
|
+
[PASS] examples my_abs (-4,) -> 4
|
|
96
|
+
[PASS] examples my_abs (0,) -> 0
|
|
97
|
+
[PASS] properties my_abs
|
|
98
|
+
[PASS] effects my_abs pure
|
|
99
|
+
[PASS] types mymodule.py
|
|
100
|
+
|
|
101
|
+
3 passed 0 failed 0 errored 0 skipped
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## The `@spec` decorator
|
|
105
|
+
|
|
106
|
+
`@spec` accepts these fields:
|
|
107
|
+
|
|
108
|
+
| Field | Type | Description |
|
|
109
|
+
|--------------|---------------------|-------------|
|
|
110
|
+
| `intent` | `str` (required) | One-line description of what the function does and why. |
|
|
111
|
+
| `where` | `list[str]` | Preconditions — Python expressions that must hold over the inputs. |
|
|
112
|
+
| `ensures` | `list[str]` | Postconditions — Python expressions over inputs and `result`. |
|
|
113
|
+
| `effects` | `list[Effect]` | Declared side-effects (see below). |
|
|
114
|
+
| `ex` | `list[str]` | Runnable examples in `"(args) -> expected"` format. |
|
|
115
|
+
| `perf` | `Perf` | Advisory complexity, e.g. `Perf(time="O(n)")`. |
|
|
116
|
+
| `invariants` | `list[str]` | Class/module-level invariants (plain strings or expressions). |
|
|
117
|
+
|
|
118
|
+
`@spec` must be the **outermost** decorator and returns the target **unchanged** — it only attaches metadata, so there is zero runtime overhead.
|
|
119
|
+
|
|
120
|
+
## Verifiers
|
|
121
|
+
|
|
122
|
+
### `examples` — run concrete cases
|
|
123
|
+
|
|
124
|
+
Each `ex` string has the format `"(args) -> expected"`:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
ex = [
|
|
128
|
+
"(1, 2) -> 3", # must return 3
|
|
129
|
+
"(0,) -> raises ValueError", # must raise ValueError
|
|
130
|
+
"('hi',) -> _", # wildcard: any return without raising
|
|
131
|
+
]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- The left side is a tuple literal (single-arg tuples need a trailing comma: `(42,)`).
|
|
135
|
+
- `raises ExcType` matches if the call raises that type or a subclass.
|
|
136
|
+
- `_` matches any non-raising return.
|
|
137
|
+
- Values are evaluated in the module's global namespace, so domain objects and enums resolve correctly.
|
|
138
|
+
|
|
139
|
+
### `properties` — hypothesis-based postcondition testing
|
|
140
|
+
|
|
141
|
+
For functions with `ensures` and no impure effects, pyintent generates inputs from type hints using [Hypothesis](https://hypothesis.readthedocs.io/), filters them through `where`, and asserts every `ensures` expression:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
@spec(
|
|
145
|
+
intent = "Sort a list of integers in ascending order.",
|
|
146
|
+
effects = [pure],
|
|
147
|
+
where = ["len(xs) < 1000"],
|
|
148
|
+
ensures = [
|
|
149
|
+
"len(result) == len(xs)",
|
|
150
|
+
"all(result[i] <= result[i+1] for i in range(len(result)-1))",
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
def sort_ints(xs: list[int]) -> list[int]:
|
|
154
|
+
return sorted(xs)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`ensures` expressions may reference input parameters and `result` (the return value).
|
|
158
|
+
|
|
159
|
+
### `types` — mypy integration
|
|
160
|
+
|
|
161
|
+
Runs `mypy` over the target file. Skipped gracefully if mypy is not installed. Install it with `pip install pyintent[types]`.
|
|
162
|
+
|
|
163
|
+
### `effects` — AST-based effect checking
|
|
164
|
+
|
|
165
|
+
Three effects are actively verified in v0.1:
|
|
166
|
+
|
|
167
|
+
| Effect | What is checked |
|
|
168
|
+
|--------|----------------|
|
|
169
|
+
| `pure` | No calls to impure builtins (`print`, `open`, …) or modules (`os`, `sys`, `random`, `requests`, …), no `global`/`nonlocal` writes. |
|
|
170
|
+
| `async_` | The function must be defined with `async def`. |
|
|
171
|
+
| `throws(ExcA, ExcB)` | Every explicitly raised exception type is declared. |
|
|
172
|
+
|
|
173
|
+
These effects are **declaration-only** (recorded but not verified):
|
|
174
|
+
`reads("db")`, `writes("cache")`, `network("stripe")`, `io`
|
|
175
|
+
|
|
176
|
+
A function may combine multiple effects:
|
|
177
|
+
```python
|
|
178
|
+
effects = [reads("db"), throws(NotFoundError, ValueError)]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## CLI usage
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# Write the spec-authoring guide into your AI tool's prompt files
|
|
185
|
+
# (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md, etc.)
|
|
186
|
+
pyintent init
|
|
187
|
+
|
|
188
|
+
# Print the spec-authoring guide to stdout
|
|
189
|
+
pyintent prompt
|
|
190
|
+
|
|
191
|
+
# Validate spec structure by importing files (no execution)
|
|
192
|
+
pyintent check myapp/
|
|
193
|
+
|
|
194
|
+
# Require every public function to have a @spec
|
|
195
|
+
pyintent check --require-specs myapp/
|
|
196
|
+
|
|
197
|
+
# Run all verifiers and report results
|
|
198
|
+
pyintent verify myapp/orders.py
|
|
199
|
+
pyintent verify myapp/
|
|
200
|
+
|
|
201
|
+
# Machine-readable JSON output
|
|
202
|
+
pyintent verify --json myapp/ > results.json
|
|
203
|
+
|
|
204
|
+
# Run only specific verifiers
|
|
205
|
+
pyintent verify --only examples --only properties myapp/
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Exit codes: `0` all good, `1` verification failures, `2` usage or load error.
|
|
209
|
+
|
|
210
|
+
## pytest plugin
|
|
211
|
+
|
|
212
|
+
The pytest plugin is opt-in — installing pyintent does not change how existing `pytest` runs behave.
|
|
213
|
+
|
|
214
|
+
Enable it on the command line:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pytest --pyintent
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Or permanently in `pyproject.toml`:
|
|
221
|
+
|
|
222
|
+
```toml
|
|
223
|
+
[tool.pytest.ini_options]
|
|
224
|
+
pyintent = true
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Each spec becomes one or more pytest items:
|
|
228
|
+
|
|
229
|
+
- One item per `ex` case
|
|
230
|
+
- One item for property testing (if `ensures` is set)
|
|
231
|
+
- One item for the type check per file
|
|
232
|
+
|
|
233
|
+
## pyproject.toml configuration
|
|
234
|
+
|
|
235
|
+
```toml
|
|
236
|
+
[tool.pyintent]
|
|
237
|
+
require_specs = true # or "all" to also require class/module specs
|
|
238
|
+
exclude = ["migrations", "tests"]
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Safety
|
|
242
|
+
|
|
243
|
+
pyintent's `examples` and `properties` verifiers **execute the code under test** in the current Python process. That is fine for your own code — but pyintent's whole premise is checking code written by an AI tool, so treat that code as untrusted: review it, or run `pyintent verify` in a sandbox (container, VM, or restricted user), before running it on your machine.
|
|
244
|
+
|
|
245
|
+
## Status
|
|
246
|
+
|
|
247
|
+
v0.1. The following are planned for v0.2: generator/async-generator specs, `@overload`, instance-method example execution, Liskov enforcement of abstract-method contracts, performance measurement.
|
|
248
|
+
|
|
249
|
+
## Contributing
|
|
250
|
+
|
|
251
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
pyintent/__init__.py,sha256=b3S-xTB6Oz7WApkBjGmbS4av6rpbjODGozp3vue6xMU,989
|
|
2
|
+
pyintent/_discovery.py,sha256=AmbCN1MLkMlU75sWjiPq1x1-Ku0L5lBAeaG0PRqcESg,3382
|
|
3
|
+
pyintent/_effects.py,sha256=n8NM63eEjyPxm50pINFR7Mb-phXu9Oe37NJ4pfF6lSE,5185
|
|
4
|
+
pyintent/_errors.py,sha256=vJ0U94AoY-M9cc6kuNXJIrsxVUI42aDQaeZUDYTpeRc,390
|
|
5
|
+
pyintent/_loader.py,sha256=dgfjuGg8VTV1h4pYdoLw3hB7OKYgpYRUldXseObPptU,1963
|
|
6
|
+
pyintent/_module_spec.py,sha256=_RGBWyR9h48x2ll7UpUMoSPPTAglWKbKU-mqOc6LCDI,1953
|
|
7
|
+
pyintent/_parser.py,sha256=W7qQrfs28HYyIBcrr2FqCVCWCMwtyNl9LjMIZKoKlTg,6734
|
|
8
|
+
pyintent/_perf.py,sha256=iRqeUQ2mHA29otofwiE2jb-lYSdW974LLB0SCoQ51oA,1714
|
|
9
|
+
pyintent/_spec.py,sha256=wj_q2Fdr_qGXXBhOBSBK4Rjn0nr-tuTCuiKE7eOT-og,12740
|
|
10
|
+
pyintent/cli.py,sha256=wwNv6qZO0ixIOUz3GWEOhueLS8E5Tkvc4ZZZoboQMA0,9048
|
|
11
|
+
pyintent/plugin.py,sha256=UulqBG4EprdniZTs4TMvJRsZIuj7UfOCdSTH_PwYu_s,5062
|
|
12
|
+
pyintent/prompt.py,sha256=N19AJW85PeKR6zM_cxf3Qyem_vIVBeVAdEUUIl4C_e4,5639
|
|
13
|
+
pyintent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
pyintent/verifier/__init__.py,sha256=PbJq44_hq72adTpfxr8Nk9-AGwsJyz15IBwdW_pCBwA,1861
|
|
15
|
+
pyintent/verifier/_result.py,sha256=oicuvu82gNYy6Wmfg3iYtlg1YQTRZRWckaRR6O4PQtU,1177
|
|
16
|
+
pyintent/verifier/effects.py,sha256=vdss3N-FvXgEWfbK_vuOTFZsb9GCqHQIJWPHkTfFGzk,6116
|
|
17
|
+
pyintent/verifier/examples.py,sha256=gplxv4XUe5j_CAAacHKQgeyBPydd2YKUcB3ZmTsUH9w,4836
|
|
18
|
+
pyintent/verifier/properties.py,sha256=yjOc6drKyZZ8QZRF5MSnQ4NdDeo7TvdP5dugBMkT97w,6848
|
|
19
|
+
pyintent/verifier/types.py,sha256=K_fm_8cWQVLkUn1dHgoxx-2EYJA4sQ9wjES3GBzdIK8,1838
|
|
20
|
+
pyintent-0.1.0.dist-info/licenses/LICENSE,sha256=ZzxQUeFTwIbKJdbV9IvI7MkFcvQnYTnQfWV6fjeQnJA,1078
|
|
21
|
+
pyintent-0.1.0.dist-info/METADATA,sha256=62fSIjCyxJhGBAxRlyfdrfLPbgCJf5w-aarRQjitTLE,8599
|
|
22
|
+
pyintent-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
23
|
+
pyintent-0.1.0.dist-info/entry_points.txt,sha256=d6yq9D480QpMIOMeQGsgooieLSj7zYW2vbclE6c44So,86
|
|
24
|
+
pyintent-0.1.0.dist-info/top_level.txt,sha256=RTfkCed7A1J1TuH7z-LrL-8gsDoJqLhC9opv2cKZfuI,9
|
|
25
|
+
pyintent-0.1.0.dist-info/RECORD,,
|