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
pyintent/_perf.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Performance declaration for a spec.
|
|
2
|
+
|
|
3
|
+
In v0.1 ``Perf`` is *stored only* — pyintent records the declared complexity but
|
|
4
|
+
does not measure or verify it. Measurement is planned for v0.2.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._errors import PyIntentSpecError
|
|
10
|
+
|
|
11
|
+
_ALLOWED = ("time", "space")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Perf:
|
|
15
|
+
"""Declared algorithmic complexity, e.g. ``Perf(time="O(log n)", space="O(1)")``."""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("time", "space")
|
|
18
|
+
|
|
19
|
+
def __init__(self, **kwargs: str) -> None:
|
|
20
|
+
unknown = set(kwargs) - set(_ALLOWED)
|
|
21
|
+
if unknown:
|
|
22
|
+
raise PyIntentSpecError(
|
|
23
|
+
f"Perf() got unexpected keyword(s): {', '.join(sorted(unknown))}. "
|
|
24
|
+
f"Allowed: {', '.join(_ALLOWED)}"
|
|
25
|
+
)
|
|
26
|
+
time = kwargs.get("time")
|
|
27
|
+
space = kwargs.get("space")
|
|
28
|
+
if time is None and space is None:
|
|
29
|
+
raise PyIntentSpecError("Perf() requires at least one of time= or space=")
|
|
30
|
+
for name, val in (("time", time), ("space", space)):
|
|
31
|
+
if val is not None and (not isinstance(val, str) or not val.strip()):
|
|
32
|
+
raise PyIntentSpecError(
|
|
33
|
+
f"Perf {name}= must be a non-empty string, got {val!r}"
|
|
34
|
+
)
|
|
35
|
+
self.time = time
|
|
36
|
+
self.space = space
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
parts = []
|
|
40
|
+
if self.time is not None:
|
|
41
|
+
parts.append(f"time={self.time!r}")
|
|
42
|
+
if self.space is not None:
|
|
43
|
+
parts.append(f"space={self.space!r}")
|
|
44
|
+
return f"Perf({', '.join(parts)})"
|
|
45
|
+
|
|
46
|
+
def __eq__(self, other: object) -> bool:
|
|
47
|
+
if not isinstance(other, Perf):
|
|
48
|
+
return NotImplemented
|
|
49
|
+
return self.time == other.time and self.space == other.space
|
pyintent/_spec.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""The ``@spec`` decorator and its data model.
|
|
2
|
+
|
|
3
|
+
``@spec`` attaches a :class:`PyIntentSpec` to the target as ``__pyintent_spec__``
|
|
4
|
+
and returns the target **unchanged** — there is zero runtime overhead. All
|
|
5
|
+
validation happens eagerly at decoration time so a malformed spec fails on import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from ._effects import Effect, EffectKind
|
|
16
|
+
from ._errors import PyIntentSpecError
|
|
17
|
+
from ._parser import Example, parse_example
|
|
18
|
+
from ._perf import Perf
|
|
19
|
+
|
|
20
|
+
SPEC_ATTR = "__pyintent_spec__"
|
|
21
|
+
|
|
22
|
+
_IMPURE_KINDS = {
|
|
23
|
+
EffectKind.READS,
|
|
24
|
+
EffectKind.WRITES,
|
|
25
|
+
EffectKind.NETWORK,
|
|
26
|
+
EffectKind.IO,
|
|
27
|
+
EffectKind.ASYNC,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SpecLevel(Enum):
|
|
32
|
+
FUNCTION = "function"
|
|
33
|
+
METHOD = "method"
|
|
34
|
+
CLASSMETHOD = "classmethod"
|
|
35
|
+
STATICMETHOD = "staticmethod"
|
|
36
|
+
PROPERTY = "property"
|
|
37
|
+
ABSTRACT = "abstract method"
|
|
38
|
+
CLASS = "class"
|
|
39
|
+
MODULE = "module"
|
|
40
|
+
PACKAGE = "package"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: Callable levels that accept call-time conditions and examples.
|
|
44
|
+
_CALLABLE_LEVELS = {
|
|
45
|
+
SpecLevel.FUNCTION,
|
|
46
|
+
SpecLevel.METHOD,
|
|
47
|
+
SpecLevel.CLASSMETHOD,
|
|
48
|
+
SpecLevel.STATICMETHOD,
|
|
49
|
+
SpecLevel.ABSTRACT,
|
|
50
|
+
SpecLevel.PROPERTY,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: Which kwargs each level accepts (``intent`` is always required, handled apart).
|
|
54
|
+
_ALLOWED_FIELDS: dict[SpecLevel, set[str]] = {
|
|
55
|
+
SpecLevel.FUNCTION: {"where", "ensures", "effects", "perf", "ex"},
|
|
56
|
+
SpecLevel.METHOD: {"where", "ensures", "effects", "perf", "ex"},
|
|
57
|
+
SpecLevel.CLASSMETHOD: {"where", "ensures", "effects", "perf", "ex"},
|
|
58
|
+
SpecLevel.STATICMETHOD: {"where", "ensures", "effects", "perf", "ex"},
|
|
59
|
+
SpecLevel.ABSTRACT: {"where", "ensures", "effects", "perf", "ex"},
|
|
60
|
+
SpecLevel.PROPERTY: {"ensures", "effects", "perf", "ex"},
|
|
61
|
+
SpecLevel.CLASS: {"effects", "invariants"},
|
|
62
|
+
SpecLevel.MODULE: {"effects", "invariants"},
|
|
63
|
+
SpecLevel.PACKAGE: {"effects", "invariants", "modules"},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Invariant:
|
|
69
|
+
"""A class/module/package invariant.
|
|
70
|
+
|
|
71
|
+
``is_expr`` is True when the text compiles as a Python expression (and is
|
|
72
|
+
therefore checkable); otherwise it is treated as natural-language docs.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
text: str
|
|
76
|
+
is_expr: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PyIntentSpec:
|
|
81
|
+
level: SpecLevel
|
|
82
|
+
intent: str
|
|
83
|
+
target_name: str
|
|
84
|
+
is_async: bool = False
|
|
85
|
+
where: list[str] = field(default_factory=list)
|
|
86
|
+
ensures: list[str] = field(default_factory=list)
|
|
87
|
+
effects: list[Effect] = field(default_factory=list)
|
|
88
|
+
perf: Perf | None = None
|
|
89
|
+
examples: list[Example] = field(default_factory=list)
|
|
90
|
+
invariants: list[Invariant] = field(default_factory=list)
|
|
91
|
+
modules: list[str] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def declares_pure(self) -> bool:
|
|
95
|
+
return any(e.kind is EffectKind.PURE for e in self.effects)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_verifiable_pure(self) -> bool:
|
|
99
|
+
"""True when property-based testing is safe (no impure declared effects)."""
|
|
100
|
+
return not ({e.kind for e in self.effects} & _IMPURE_KINDS)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def thrown_exceptions(self) -> tuple[type[BaseException], ...]:
|
|
104
|
+
result: list[type[BaseException]] = []
|
|
105
|
+
for e in self.effects:
|
|
106
|
+
if e.kind is EffectKind.THROWS:
|
|
107
|
+
result.extend(e.exceptions)
|
|
108
|
+
return tuple(result)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
# Validation helpers
|
|
113
|
+
# --------------------------------------------------------------------------- #
|
|
114
|
+
def _require_intent(intent: object) -> str:
|
|
115
|
+
if not isinstance(intent, str) or not intent.strip():
|
|
116
|
+
raise PyIntentSpecError("intent= is required and must be a non-empty string")
|
|
117
|
+
return intent
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _validate_conditions(name: str, conditions: object) -> list[str]:
|
|
121
|
+
if conditions is None:
|
|
122
|
+
return []
|
|
123
|
+
if not isinstance(conditions, (list, tuple)):
|
|
124
|
+
raise PyIntentSpecError(f"{name}= must be a list of strings")
|
|
125
|
+
result: list[str] = []
|
|
126
|
+
for c in conditions:
|
|
127
|
+
if not isinstance(c, str) or not c.strip():
|
|
128
|
+
raise PyIntentSpecError(f"each {name} entry must be a non-empty string, got {c!r}")
|
|
129
|
+
try:
|
|
130
|
+
compile(c, f"<{name}>", "eval")
|
|
131
|
+
except (SyntaxError, ValueError) as e:
|
|
132
|
+
msg = e.msg if isinstance(e, SyntaxError) else str(e)
|
|
133
|
+
raise PyIntentSpecError(
|
|
134
|
+
f"{name} condition {c!r} is not a valid Python expression: {msg}"
|
|
135
|
+
) from e
|
|
136
|
+
result.append(c)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _validate_effects(effects: object) -> list[Effect]:
|
|
141
|
+
if effects is None:
|
|
142
|
+
return []
|
|
143
|
+
if not isinstance(effects, (list, tuple)):
|
|
144
|
+
raise PyIntentSpecError("effects= must be a list of Effect objects")
|
|
145
|
+
for e in effects:
|
|
146
|
+
if not isinstance(e, Effect):
|
|
147
|
+
raise PyIntentSpecError(
|
|
148
|
+
f"effects must be Effect objects (pure, reads(...), writes(...), "
|
|
149
|
+
f"network(...), io, async_, throws(...)); got {e!r}"
|
|
150
|
+
)
|
|
151
|
+
return list(effects)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_perf(perf: object) -> Perf | None:
|
|
155
|
+
if perf is None:
|
|
156
|
+
return None
|
|
157
|
+
if not isinstance(perf, Perf):
|
|
158
|
+
raise PyIntentSpecError("perf= must be a Perf object, e.g. Perf(time='O(n)')")
|
|
159
|
+
return perf
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _validate_examples(ex: object) -> list[Example]:
|
|
163
|
+
if ex is None:
|
|
164
|
+
return []
|
|
165
|
+
if not isinstance(ex, (list, tuple)):
|
|
166
|
+
raise PyIntentSpecError("ex= must be a list of example strings")
|
|
167
|
+
return [parse_example(e) for e in ex]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def parse_invariant(text: object) -> Invariant:
|
|
171
|
+
if not isinstance(text, str) or not text.strip():
|
|
172
|
+
raise PyIntentSpecError("each invariant must be a non-empty string")
|
|
173
|
+
try:
|
|
174
|
+
compile(text, "<invariant>", "eval")
|
|
175
|
+
is_expr = True
|
|
176
|
+
except SyntaxError:
|
|
177
|
+
is_expr = False
|
|
178
|
+
return Invariant(text=text, is_expr=is_expr)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate_invariants(invariants: object) -> list[Invariant]:
|
|
182
|
+
if invariants is None:
|
|
183
|
+
return []
|
|
184
|
+
if not isinstance(invariants, (list, tuple)):
|
|
185
|
+
raise PyIntentSpecError("invariants= must be a list of strings")
|
|
186
|
+
return [parse_invariant(i) for i in invariants]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _reject_disallowed(level: SpecLevel, provided: dict[str, Any]) -> None:
|
|
190
|
+
allowed = _ALLOWED_FIELDS[level]
|
|
191
|
+
for name, value in provided.items():
|
|
192
|
+
if value and name not in allowed:
|
|
193
|
+
allowed_list = ", ".join(sorted(allowed)) or "(none)"
|
|
194
|
+
raise PyIntentSpecError(
|
|
195
|
+
f"{name}= is not valid on a {level.value} spec. "
|
|
196
|
+
f"Allowed fields: intent, {allowed_list}."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------- #
|
|
201
|
+
# Target introspection
|
|
202
|
+
# --------------------------------------------------------------------------- #
|
|
203
|
+
def _underlying(target: Any) -> Any:
|
|
204
|
+
if isinstance(target, (classmethod, staticmethod)):
|
|
205
|
+
return target.__func__
|
|
206
|
+
if isinstance(target, property):
|
|
207
|
+
return target.fget
|
|
208
|
+
return target
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _detect_level(target: Any) -> SpecLevel:
|
|
212
|
+
if isinstance(target, classmethod):
|
|
213
|
+
return SpecLevel.CLASSMETHOD
|
|
214
|
+
if isinstance(target, staticmethod):
|
|
215
|
+
return SpecLevel.STATICMETHOD
|
|
216
|
+
if isinstance(target, property):
|
|
217
|
+
return SpecLevel.PROPERTY
|
|
218
|
+
if isinstance(target, type):
|
|
219
|
+
return SpecLevel.CLASS
|
|
220
|
+
if getattr(target, "__isabstractmethod__", False):
|
|
221
|
+
return SpecLevel.ABSTRACT
|
|
222
|
+
if inspect.isfunction(target):
|
|
223
|
+
qualname = getattr(target, "__qualname__", "")
|
|
224
|
+
if "." in qualname:
|
|
225
|
+
parent = qualname.rsplit(".", 1)[0]
|
|
226
|
+
if not parent.endswith("<locals>"):
|
|
227
|
+
return SpecLevel.METHOD
|
|
228
|
+
return SpecLevel.FUNCTION
|
|
229
|
+
raise PyIntentSpecError(
|
|
230
|
+
f"@spec can only decorate functions, methods, or classes; got {target!r}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _target_name(target: Any, underlying: Any) -> str:
|
|
235
|
+
if isinstance(target, type):
|
|
236
|
+
return target.__qualname__
|
|
237
|
+
name = getattr(underlying, "__qualname__", None) or getattr(underlying, "__name__", None)
|
|
238
|
+
return name or repr(target)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _attach(target: Any, underlying: Any, ps: PyIntentSpec) -> None:
|
|
242
|
+
holder = underlying if underlying is not None else target
|
|
243
|
+
try:
|
|
244
|
+
setattr(holder, SPEC_ATTR, ps)
|
|
245
|
+
except (AttributeError, TypeError) as e: # pragma: no cover
|
|
246
|
+
raise PyIntentSpecError(
|
|
247
|
+
f"could not attach spec to {ps.target_name}: {e}"
|
|
248
|
+
) from e
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --------------------------------------------------------------------------- #
|
|
252
|
+
# The decorator
|
|
253
|
+
# --------------------------------------------------------------------------- #
|
|
254
|
+
def spec(
|
|
255
|
+
*,
|
|
256
|
+
intent: str,
|
|
257
|
+
where: list[str] | None = None,
|
|
258
|
+
ensures: list[str] | None = None,
|
|
259
|
+
effects: list[Effect] | None = None,
|
|
260
|
+
perf: Perf | None = None,
|
|
261
|
+
ex: list[str] | None = None,
|
|
262
|
+
invariants: list[str] | None = None,
|
|
263
|
+
) -> Callable[[Any], Any]:
|
|
264
|
+
"""Attach an intent specification to a function, method, or class.
|
|
265
|
+
|
|
266
|
+
``@spec`` must be the **outermost** decorator. It returns the target
|
|
267
|
+
**unchanged** — there is zero runtime overhead. All validation happens
|
|
268
|
+
eagerly at decoration time so a malformed spec fails on import.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
intent:
|
|
273
|
+
Required. One-line description of what the target does and why.
|
|
274
|
+
where:
|
|
275
|
+
Preconditions — Python expression strings evaluated over the input
|
|
276
|
+
parameters. Example: ``["n >= 0", "isinstance(n, int)"]``.
|
|
277
|
+
ensures:
|
|
278
|
+
Postconditions — Python expression strings evaluated over input
|
|
279
|
+
parameters and ``result`` (the return value).
|
|
280
|
+
Example: ``["result >= 0", "result == abs(x)"]``.
|
|
281
|
+
effects:
|
|
282
|
+
Declared side-effects. Use the helpers: ``pure``, ``reads(...)``,
|
|
283
|
+
``writes(...)``, ``network(...)``, ``io``, ``async_``,
|
|
284
|
+
``throws(...)``. Example: ``[reads("db"), throws(NotFoundError)]``.
|
|
285
|
+
perf:
|
|
286
|
+
Advisory complexity declaration. Example: ``Perf(time="O(n log n)")``.
|
|
287
|
+
Recorded but not measured in v0.1.
|
|
288
|
+
ex:
|
|
289
|
+
Runnable examples in ``"(args) -> expected"`` format.
|
|
290
|
+
Example: ``["(1, 2) -> 3", "(0,) -> raises ValueError", "() -> _"]``.
|
|
291
|
+
Values are evaluated in the target module's global namespace.
|
|
292
|
+
invariants:
|
|
293
|
+
Class/module-level invariants (plain text or Python expressions).
|
|
294
|
+
Valid only on class, module, and package specs.
|
|
295
|
+
|
|
296
|
+
Returns
|
|
297
|
+
-------
|
|
298
|
+
Callable[[Any], Any]
|
|
299
|
+
A decorator that attaches the spec and returns the target unchanged.
|
|
300
|
+
|
|
301
|
+
Raises
|
|
302
|
+
------
|
|
303
|
+
PyIntentSpecError
|
|
304
|
+
If ``intent`` is empty, any expression is syntactically invalid, an
|
|
305
|
+
unsupported field is used for the target level, or an ``ex`` entry
|
|
306
|
+
is malformed.
|
|
307
|
+
|
|
308
|
+
See Also
|
|
309
|
+
--------
|
|
310
|
+
``pyintent prompt`` : print the full spec-authoring reference guide.
|
|
311
|
+
``get_spec`` : retrieve a spec attached to a target.
|
|
312
|
+
"""
|
|
313
|
+
intent = _require_intent(intent)
|
|
314
|
+
|
|
315
|
+
def decorate(target: Any) -> Any:
|
|
316
|
+
level = _detect_level(target)
|
|
317
|
+
underlying = _underlying(target)
|
|
318
|
+
if underlying is None:
|
|
319
|
+
raise PyIntentSpecError(
|
|
320
|
+
"@spec on a property requires a getter (the property has no fget)"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
_reject_disallowed(
|
|
324
|
+
level,
|
|
325
|
+
{
|
|
326
|
+
"where": where,
|
|
327
|
+
"ensures": ensures,
|
|
328
|
+
"perf": perf,
|
|
329
|
+
"ex": ex,
|
|
330
|
+
"invariants": invariants,
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
is_async = bool(
|
|
335
|
+
inspect.iscoroutinefunction(underlying)
|
|
336
|
+
or inspect.isasyncgenfunction(underlying)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
ps = PyIntentSpec(
|
|
340
|
+
level=level,
|
|
341
|
+
intent=intent,
|
|
342
|
+
target_name=_target_name(target, underlying),
|
|
343
|
+
is_async=is_async,
|
|
344
|
+
where=_validate_conditions("where", where),
|
|
345
|
+
ensures=_validate_conditions("ensures", ensures),
|
|
346
|
+
effects=_validate_effects(effects),
|
|
347
|
+
perf=_validate_perf(perf),
|
|
348
|
+
examples=_validate_examples(ex),
|
|
349
|
+
invariants=_validate_invariants(invariants),
|
|
350
|
+
)
|
|
351
|
+
_attach(target, underlying, ps)
|
|
352
|
+
return target
|
|
353
|
+
|
|
354
|
+
return decorate
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_spec(obj: Any) -> PyIntentSpec | None:
|
|
358
|
+
"""Return the :class:`PyIntentSpec` attached to ``obj``, or ``None``.
|
|
359
|
+
|
|
360
|
+
Works for functions, methods, classmethods, staticmethods, properties,
|
|
361
|
+
and classes decorated with :func:`spec`.
|
|
362
|
+
|
|
363
|
+
Parameters
|
|
364
|
+
----------
|
|
365
|
+
obj:
|
|
366
|
+
Any Python object that may have a spec attached.
|
|
367
|
+
|
|
368
|
+
Returns
|
|
369
|
+
-------
|
|
370
|
+
PyIntentSpec | None
|
|
371
|
+
The attached spec, or ``None`` if ``obj`` was not decorated with
|
|
372
|
+
``@spec``.
|
|
373
|
+
"""
|
|
374
|
+
underlying = _underlying(obj)
|
|
375
|
+
return getattr(underlying, SPEC_ATTR, None)
|
pyintent/cli.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""``pyintent`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands
|
|
4
|
+
--------
|
|
5
|
+
* ``pyintent init`` write the agent guide into prompt files
|
|
6
|
+
* ``pyintent prompt`` print the agent guide to stdout
|
|
7
|
+
* ``pyintent check PATH`` validate spec structure (imports modules)
|
|
8
|
+
* ``pyintent verify PATH [--json]`` run the verifiers
|
|
9
|
+
|
|
10
|
+
Exit codes: ``0`` all good, ``1`` verification/spec failures, ``2`` usage or
|
|
11
|
+
load error (could not import a target, malformed spec at import time).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from ._errors import PyIntentSpecError
|
|
23
|
+
from ._loader import import_file, iter_python_files
|
|
24
|
+
from ._module_spec import MODULE_ATTR
|
|
25
|
+
from ._spec import SpecLevel, get_spec
|
|
26
|
+
from . import prompt as _prompt
|
|
27
|
+
from .verifier import ALL_VERIFIERS, run_all
|
|
28
|
+
from .verifier._result import Status
|
|
29
|
+
|
|
30
|
+
_STATUS_STYLE = {
|
|
31
|
+
Status.PASS: ("PASS", "green"),
|
|
32
|
+
Status.FAIL: ("FAIL", "red"),
|
|
33
|
+
Status.ERROR: ("ERR ", "red"),
|
|
34
|
+
Status.SKIPPED: ("SKIP", "yellow"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# config
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
def _load_config(start: Path) -> dict:
|
|
42
|
+
try:
|
|
43
|
+
import tomllib
|
|
44
|
+
except ModuleNotFoundError: # pragma: no cover - py<3.11
|
|
45
|
+
return {}
|
|
46
|
+
for directory in [start, *start.parents]:
|
|
47
|
+
cfg = directory / "pyproject.toml"
|
|
48
|
+
if cfg.is_file():
|
|
49
|
+
try:
|
|
50
|
+
data = tomllib.loads(cfg.read_text(encoding="utf-8"))
|
|
51
|
+
except Exception:
|
|
52
|
+
return {}
|
|
53
|
+
return data.get("tool", {}).get("pyintent", {}) or {}
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _collect_modules(path: Path):
|
|
58
|
+
"""Import every target file under ``path``. Returns (modules, load_errors)."""
|
|
59
|
+
modules = []
|
|
60
|
+
errors: list[tuple[Path, BaseException]] = []
|
|
61
|
+
for f in iter_python_files(path):
|
|
62
|
+
try:
|
|
63
|
+
modules.append((f, import_file(f)))
|
|
64
|
+
except PyIntentSpecError as e:
|
|
65
|
+
errors.append((f, e))
|
|
66
|
+
except BaseException as e: # noqa: BLE001 - report, keep going
|
|
67
|
+
errors.append((f, e))
|
|
68
|
+
return modules, errors
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
# reporting
|
|
73
|
+
# --------------------------------------------------------------------------- #
|
|
74
|
+
def _print_results(results, *, show_detail=True) -> dict[Status, int]:
|
|
75
|
+
counts: dict[Status, int] = {s: 0 for s in Status}
|
|
76
|
+
for r in results:
|
|
77
|
+
counts[r.status] += 1
|
|
78
|
+
label, color = _STATUS_STYLE[r.status]
|
|
79
|
+
tag = click.style(f"[{label}]", fg=color, bold=True)
|
|
80
|
+
loc = click.style(r.target, bold=True)
|
|
81
|
+
extra = f" {r.label}" if r.label else ""
|
|
82
|
+
summ = f" {r.summary}" if r.summary else ""
|
|
83
|
+
click.echo(f"{tag} {r.verifier:<10} {loc}{extra}{summ}")
|
|
84
|
+
if show_detail and r.status in (Status.FAIL, Status.ERROR) and r.detail:
|
|
85
|
+
for line in r.detail.splitlines():
|
|
86
|
+
click.echo(click.style(" " + line, fg="bright_black"))
|
|
87
|
+
return counts
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _summary_line(counts: dict[Status, int]) -> str:
|
|
91
|
+
parts = [
|
|
92
|
+
click.style(f"{counts[Status.PASS]} passed", fg="green"),
|
|
93
|
+
click.style(f"{counts[Status.FAIL]} failed", fg="red"),
|
|
94
|
+
click.style(f"{counts[Status.ERROR]} errored", fg="red"),
|
|
95
|
+
click.style(f"{counts[Status.SKIPPED]} skipped", fg="yellow"),
|
|
96
|
+
]
|
|
97
|
+
return " ".join(parts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
# commands
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
@click.group()
|
|
104
|
+
@click.version_option(package_name="pyintent", message="%(version)s")
|
|
105
|
+
def main() -> None:
|
|
106
|
+
"""pyintent — verify that implementations satisfy their @spec intent."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@main.command()
|
|
110
|
+
@click.option("--root", default=".", type=click.Path(file_okay=False),
|
|
111
|
+
help="Project root to write prompt files into.")
|
|
112
|
+
def init(root: str) -> None:
|
|
113
|
+
"""Write the pyintent authoring guide into agent prompt files."""
|
|
114
|
+
actions = _prompt.write_prompt_files(root)
|
|
115
|
+
for rel, action in actions.items():
|
|
116
|
+
click.echo(f" {action:<9} {rel}")
|
|
117
|
+
click.echo(click.style("pyintent guide written. Point your AI tools here.", fg="green"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@main.command()
|
|
121
|
+
def prompt() -> None:
|
|
122
|
+
"""Print the pyintent authoring guide to stdout."""
|
|
123
|
+
click.echo(_prompt.get_reference())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _iter_specs(module):
|
|
127
|
+
"""Yield (level, qualified_name, spec_or_None) for top-level defs in module."""
|
|
128
|
+
import inspect
|
|
129
|
+
|
|
130
|
+
modname = getattr(module, "__name__", "")
|
|
131
|
+
for name, obj in vars(module).items():
|
|
132
|
+
if name.startswith("__") and name.endswith("__"):
|
|
133
|
+
continue
|
|
134
|
+
if inspect.isfunction(obj) and getattr(obj, "__module__", None) == modname:
|
|
135
|
+
yield SpecLevel.FUNCTION, name, get_spec(obj)
|
|
136
|
+
elif inspect.isclass(obj) and getattr(obj, "__module__", None) == modname:
|
|
137
|
+
yield SpecLevel.CLASS, name, get_spec(obj)
|
|
138
|
+
for mname, mobj in vars(obj).items():
|
|
139
|
+
if mname.startswith("__") and mname.endswith("__"):
|
|
140
|
+
continue
|
|
141
|
+
inner = mobj
|
|
142
|
+
if isinstance(mobj, (staticmethod, classmethod)):
|
|
143
|
+
inner = mobj.__func__
|
|
144
|
+
elif isinstance(mobj, property):
|
|
145
|
+
inner = mobj.fget
|
|
146
|
+
if inner is not None and (inspect.isfunction(inner)):
|
|
147
|
+
yield SpecLevel.METHOD, f"{name}.{mname}", get_spec(inner)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@main.command()
|
|
151
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
152
|
+
@click.option("--require-specs", "require_specs", is_flag=True, default=False,
|
|
153
|
+
help="Fail if public functions/methods lack a @spec.")
|
|
154
|
+
@click.option("--all", "require_all", is_flag=True, default=False,
|
|
155
|
+
help="With --require-specs, also require class and module specs.")
|
|
156
|
+
def check(path: str, require_specs: bool, require_all: bool) -> None:
|
|
157
|
+
"""Validate spec structure by importing PATH (no execution)."""
|
|
158
|
+
target = Path(path)
|
|
159
|
+
cfg = _load_config(target if target.is_dir() else target.parent)
|
|
160
|
+
cfg_rs = cfg.get("require_specs")
|
|
161
|
+
if not require_specs and cfg_rs:
|
|
162
|
+
require_specs = True
|
|
163
|
+
require_all = require_all or (cfg_rs == "all" or cfg_rs is True)
|
|
164
|
+
|
|
165
|
+
level = "all" if (require_specs and require_all) else ("public" if require_specs else None)
|
|
166
|
+
|
|
167
|
+
modules, errors = _collect_modules(target)
|
|
168
|
+
|
|
169
|
+
if errors:
|
|
170
|
+
for f, e in errors:
|
|
171
|
+
kind = "spec error" if isinstance(e, PyIntentSpecError) else type(e).__name__
|
|
172
|
+
click.echo(click.style(f"[{kind}] {f}: {e}", fg="red"))
|
|
173
|
+
sys.exit(2)
|
|
174
|
+
|
|
175
|
+
spec_count = 0
|
|
176
|
+
missing: list[str] = []
|
|
177
|
+
for f, module in modules:
|
|
178
|
+
for lvl, qual, sp in _iter_specs(module):
|
|
179
|
+
if sp is not None:
|
|
180
|
+
spec_count += 1
|
|
181
|
+
elif level:
|
|
182
|
+
if lvl is SpecLevel.CLASS and level != "all":
|
|
183
|
+
continue
|
|
184
|
+
missing.append(f"{f}::{qual} ({lvl.value})")
|
|
185
|
+
if level == "all" and getattr(module, MODULE_ATTR, None) is None:
|
|
186
|
+
missing.append(f"{f}::<module> (module)")
|
|
187
|
+
|
|
188
|
+
click.echo(f"Imported {len(modules)} file(s); found {spec_count} spec(s).")
|
|
189
|
+
if missing:
|
|
190
|
+
click.echo(click.style(f"{len(missing)} definition(s) missing a @spec:", fg="red"))
|
|
191
|
+
for m in missing:
|
|
192
|
+
click.echo(f" - {m}")
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
click.echo(click.style("All specs valid.", fg="green"))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@main.command()
|
|
198
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
199
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
|
|
200
|
+
@click.option("--only", type=click.Choice(ALL_VERIFIERS), multiple=True,
|
|
201
|
+
help="Run only these verifiers (repeatable).")
|
|
202
|
+
def verify(path: str, as_json: bool, only: tuple[str, ...]) -> None:
|
|
203
|
+
"""Run pyintent verifiers over PATH and report pass/fail."""
|
|
204
|
+
target = Path(path)
|
|
205
|
+
which = set(only) if only else None
|
|
206
|
+
|
|
207
|
+
modules, errors = _collect_modules(target)
|
|
208
|
+
if errors:
|
|
209
|
+
if as_json:
|
|
210
|
+
click.echo(json.dumps({
|
|
211
|
+
"ok": False,
|
|
212
|
+
"load_errors": [{"file": str(f), "error": str(e)} for f, e in errors],
|
|
213
|
+
}, indent=2))
|
|
214
|
+
else:
|
|
215
|
+
for f, e in errors:
|
|
216
|
+
click.echo(click.style(f"[load error] {f}: {e}", fg="red"))
|
|
217
|
+
sys.exit(2)
|
|
218
|
+
|
|
219
|
+
all_results = []
|
|
220
|
+
for _f, module in modules:
|
|
221
|
+
all_results.extend(run_all(module, which))
|
|
222
|
+
|
|
223
|
+
if as_json:
|
|
224
|
+
payload = {
|
|
225
|
+
"ok": all(r.status is not Status.FAIL and r.status is not Status.ERROR
|
|
226
|
+
for r in all_results),
|
|
227
|
+
"results": [r.to_dict() for r in all_results],
|
|
228
|
+
}
|
|
229
|
+
click.echo(json.dumps(payload, indent=2))
|
|
230
|
+
else:
|
|
231
|
+
counts = _print_results(all_results)
|
|
232
|
+
click.echo("")
|
|
233
|
+
click.echo(_summary_line(counts))
|
|
234
|
+
|
|
235
|
+
failed = sum(1 for r in all_results if r.status in (Status.FAIL, Status.ERROR))
|
|
236
|
+
sys.exit(1 if failed else 0)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|