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/plugin.py ADDED
@@ -0,0 +1,155 @@
1
+ """pytest plugin — surface pyintent checks as ordinary pytest items.
2
+
3
+ Registered via the ``pytest11`` entry point but **opt-in**: it does nothing
4
+ unless you enable it with ``--pyintent`` on the command line or ``pyintent =
5
+ true`` under ``[tool.pytest.ini_options]`` (or ``[pytest]``). This keeps a mere
6
+ install of pyintent from changing how unrelated ``pytest`` runs behave —
7
+ without the opt-in, pyintent never imports your application files.
8
+
9
+ When enabled, for every non-test ``.py`` file that contains specs it collects:
10
+
11
+ * one item per ``ex`` case,
12
+ * one item per function with ``ensures`` (property test),
13
+ * one item per file for the type check.
14
+
15
+ Effects/perf are intentionally **not** pytest items (run ``pyintent verify``
16
+ for those). Test files are left to pytest's own collector to avoid importing a
17
+ module twice.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fnmatch
23
+ import os
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ from ._discovery import discover_in_module
29
+ from ._loader import import_file
30
+ from .verifier._result import Status
31
+ from .verifier.examples import verify_example_case
32
+ from .verifier.properties import verify_properties
33
+ from .verifier.types import verify_types
34
+
35
+
36
+ def pytest_addoption(parser) -> None:
37
+ group = parser.getgroup("pyintent")
38
+ group.addoption(
39
+ "--pyintent", action="store_true", default=False,
40
+ help="Collect pyintent @spec checks (examples/properties/types) as pytest items.",
41
+ )
42
+ parser.addini(
43
+ "pyintent", "Collect pyintent @spec checks as pytest items.",
44
+ type="bool", default=False,
45
+ )
46
+
47
+
48
+ def _enabled(config) -> bool:
49
+ try:
50
+ if config.getoption("--pyintent"):
51
+ return True
52
+ except (ValueError, KeyError):
53
+ pass
54
+ return bool(config.getini("pyintent"))
55
+
56
+
57
+ def _pkg_dir() -> str:
58
+ return os.path.dirname(os.path.abspath(__file__))
59
+
60
+
61
+ def _looks_like_specs(path: Path) -> bool:
62
+ try:
63
+ text = path.read_text(encoding="utf-8")
64
+ except (OSError, UnicodeDecodeError):
65
+ return False
66
+ if "pyintent" not in text:
67
+ return False
68
+ return "@spec(" in text or "module_spec(" in text or "package_spec(" in text
69
+
70
+
71
+ def pytest_collect_file(file_path: Path, parent): # type: ignore[override]
72
+ if not _enabled(parent.config):
73
+ return None
74
+ if file_path.suffix != ".py":
75
+ return None
76
+
77
+ # Never collect pyintent's own source or installed third-party code.
78
+ resolved = str(file_path.resolve())
79
+ if resolved.startswith(_pkg_dir()) or "site-packages" in resolved:
80
+ return None
81
+
82
+ # Leave files matching pytest's own python_files patterns to pytest.
83
+ patterns = parent.config.getini("python_files") or ["test_*.py", "*_test.py"]
84
+ if any(fnmatch.fnmatch(file_path.name, pat) for pat in patterns):
85
+ return None
86
+
87
+ if not _looks_like_specs(file_path):
88
+ return None
89
+
90
+ return PyIntentFile.from_parent(parent, path=file_path)
91
+
92
+
93
+ class _CheckFailure(Exception):
94
+ """Carries a failing/erroring CheckResult to repr_failure."""
95
+
96
+ def __init__(self, result) -> None:
97
+ super().__init__(result.summary)
98
+ self.result = result
99
+
100
+
101
+ class PyIntentFile(pytest.File):
102
+ def collect(self):
103
+ try:
104
+ module = import_file(self.path)
105
+ except Exception as exc: # noqa: BLE001
106
+ raise self.CollectError(f"pyintent: failed to import {self.path}: {exc}") from exc
107
+
108
+ targets = discover_in_module(module)
109
+ for t in targets:
110
+ sp = t.spec
111
+ base = sp.target_name
112
+ for ex in sp.examples:
113
+ yield PyIntentItem.from_parent(
114
+ self, name=f"{base}::ex[{ex.raw}]",
115
+ thunk=lambda t=t, ex=ex: verify_example_case(t, ex),
116
+ )
117
+ if sp.ensures:
118
+ yield PyIntentItem.from_parent(
119
+ self, name=f"{base}::properties",
120
+ thunk=lambda t=t: _first(verify_properties(t)),
121
+ )
122
+
123
+ yield PyIntentItem.from_parent(
124
+ self, name=f"{self.path.stem}::types",
125
+ thunk=lambda: _first(verify_types(str(self.path))),
126
+ )
127
+
128
+
129
+ def _first(results):
130
+ from .verifier._result import CheckResult
131
+ if results:
132
+ return results[0]
133
+ return CheckResult("", "", Status.SKIPPED, summary="nothing to check")
134
+
135
+
136
+ class PyIntentItem(pytest.Item):
137
+ def __init__(self, *, name, parent, thunk):
138
+ super().__init__(name, parent)
139
+ self._thunk = thunk
140
+
141
+ def runtest(self) -> None:
142
+ result = self._thunk()
143
+ if result.status is Status.SKIPPED:
144
+ pytest.skip(result.summary or "skipped")
145
+ if result.status in (Status.FAIL, Status.ERROR):
146
+ raise _CheckFailure(result)
147
+
148
+ def repr_failure(self, excinfo, style=None): # type: ignore[override]
149
+ if isinstance(excinfo.value, _CheckFailure):
150
+ r = excinfo.value.result
151
+ return r.detail or r.summary or "pyintent check failed"
152
+ return super().repr_failure(excinfo)
153
+
154
+ def reportinfo(self):
155
+ return self.path, 0, self.name
pyintent/prompt.py ADDED
@@ -0,0 +1,142 @@
1
+ """The canonical pyintent authoring guide + writers for AI-tool prompt files.
2
+
3
+ ``init`` writes this reference into the well-known prompt files that coding
4
+ agents read (AGENTS.md, CLAUDE.md, Copilot, Cursor). ``prompt`` prints it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ REFERENCE = '''\
12
+ # pyintent — writing specifications
13
+
14
+ `pyintent` is a **pure verifier**. You (the AI implementer) write the
15
+ implementation; the human (or you) writes `@spec` decorators describing
16
+ *intent*. pyintent then checks that your implementation satisfies the spec.
17
+ pyintent NEVER generates or edits implementation code, and NEVER calls an LLM.
18
+
19
+ Your job: **make the implementation satisfy every `@spec`.** Run
20
+ `pyintent verify <path>` (or `pytest --pyintent`) and fix real code until checks
21
+ pass. Do not weaken a spec to make it pass unless explicitly told to.
22
+
23
+ ## The decorator
24
+
25
+ ```python
26
+ from pyintent import spec, pure, reads, writes, network, io, async_, throws, Perf
27
+
28
+ @spec(
29
+ intent="One-line description of what this does and why.",
30
+ where=["n >= 0"], # preconditions (Python expressions)
31
+ ensures=["result >= 0", # postconditions; `result` = return value
32
+ "result == n * 2"],
33
+ effects=[pure], # see effects below
34
+ ex=["(0,) -> 0", # runnable examples; see format below
35
+ "(3,) -> 6",
36
+ "(-1,) -> raises ValueError"],
37
+ perf=Perf(time="O(n)"), # optional, advisory
38
+ )
39
+ def double_nonneg(n: int) -> int:
40
+ if n < 0:
41
+ raise ValueError("n must be >= 0")
42
+ return n * 2
43
+ ```
44
+
45
+ `@spec` must be the **outermost** decorator and returns the target unchanged —
46
+ it only attaches metadata, so it never changes runtime behaviour.
47
+
48
+ ## Example format: `"(args) -> expected"`
49
+
50
+ - Left side is a tuple of call arguments: `"(1, 2)"`, `"('hi',)"` (one-element
51
+ tuples need the trailing comma), `"()"` for no args.
52
+ - `->` separates input from expected output.
53
+ - Right side is one of:
54
+ - a Python literal/expression evaluated in the target's module: `42`, `[1,2]`,
55
+ `"ok"`, `MyEnum.A`
56
+ - `raises ExceptionType` — the call must raise that type (or a subclass)
57
+ - `_` — wildcard: any return value is accepted (only that it does not raise)
58
+ - Arguments and expected values are evaluated at verify time in the module's
59
+ namespace, so you may reference names defined in the module.
60
+
61
+ ## Effects (checked where possible)
62
+
63
+ - `pure` — no I/O, no global state, no randomness. **Verified** by AST: calls
64
+ into `os/sys/random/requests/httpx/socket/subprocess/urllib/time/...` and
65
+ builtins `print/open/input/exec/eval` are violations, as are `global`/
66
+ `nonlocal` writes. (Shallow: it does not follow calls into helpers.)
67
+ - `async_` — **verified**: the function must be `async def`.
68
+ - `throws(ExcA, ExcB)` — **verified** by AST: every exception type you `raise`
69
+ explicitly must be declared here.
70
+ - `reads(...)`, `writes(...)`, `network(...)`, `io` — recorded as documentation
71
+ in v0.1 (declaration-only, not yet enforced).
72
+
73
+ A function may declare multiple effects, e.g. `effects=[reads("db"), throws(KeyError)]`.
74
+
75
+ ## Properties (`where` + `ensures`)
76
+
77
+ For **pure** functions, pyintent uses Hypothesis to generate inputs from your
78
+ type hints, filters them through `where`, and asserts every `ensures`
79
+ expression. `ensures` may reference parameters and `result`. Effectful or
80
+ un-annotatable functions are skipped (not failed).
81
+
82
+ ## Methods, classes, modules
83
+
84
+ - On methods, examples exclude `self`/`cls` from the argument tuple. Instance
85
+ methods and properties are **skipped** for example/property execution in v0.1
86
+ (planned for v0.2); their specs are still validated structurally.
87
+ - Properties: use `()` as the example input and do not use `where`.
88
+ - `@spec` on a **class** records invariants (documentation in v0.1).
89
+ - `module_spec(...)` / `package_spec(...)` record module/package intent.
90
+ - Abstract methods may carry a `@spec` to define the contract subclasses must meet.
91
+
92
+ ## Workflow
93
+
94
+ 1. Read the specs. 2. Implement. 3. Run `pyintent verify <path>` (or
95
+ `pytest --pyintent`). 4. Read failures (they show expected vs actual). 5. Fix the implementation.
96
+ 6. Repeat until green. Treat the spec as the source of truth.
97
+ '''
98
+
99
+
100
+ def get_reference() -> str:
101
+ return REFERENCE
102
+
103
+
104
+ _HEADER = "<!-- pyintent:begin -->"
105
+ _FOOTER = "<!-- pyintent:end -->"
106
+ _BLOCK = f"{_HEADER}\n{REFERENCE}\n{_FOOTER}\n"
107
+
108
+ #: Files coding agents commonly read, relative to project root.
109
+ PROMPT_FILES = (
110
+ "AGENTS.md",
111
+ "CLAUDE.md",
112
+ ".github/copilot-instructions.md",
113
+ ".cursor/rules/pyintent.md",
114
+ )
115
+
116
+
117
+ def _upsert(path: Path, block: str) -> str:
118
+ """Insert or replace the pyintent block in ``path``. Returns action taken."""
119
+ if not path.exists():
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ path.write_text(block, encoding="utf-8")
122
+ return "created"
123
+
124
+ text = path.read_text(encoding="utf-8")
125
+ if _HEADER in text and _FOOTER in text:
126
+ pre = text[: text.index(_HEADER)]
127
+ post = text[text.index(_FOOTER) + len(_FOOTER):]
128
+ path.write_text(pre + block.rstrip("\n") + post, encoding="utf-8")
129
+ return "updated"
130
+
131
+ sep = "" if text.endswith("\n\n") else ("\n" if text.endswith("\n") else "\n\n")
132
+ path.write_text(text + sep + block, encoding="utf-8")
133
+ return "appended"
134
+
135
+
136
+ def write_prompt_files(root: str | Path = ".") -> dict[str, str]:
137
+ """Write/refresh the pyintent guide into all known prompt files."""
138
+ root = Path(root)
139
+ actions: dict[str, str] = {}
140
+ for rel in PROMPT_FILES:
141
+ actions[rel] = _upsert(root / rel, _BLOCK)
142
+ return actions
pyintent/py.typed ADDED
File without changes
@@ -0,0 +1,64 @@
1
+ """Verifier orchestration.
2
+
3
+ ``run_all`` discovers every spec in a module and runs the selected verifiers.
4
+ ``types`` runs once per source file; the other verifiers run per callable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from types import ModuleType
10
+
11
+ from .._spec import SpecLevel
12
+ from .._discovery import SpecTarget, discover_in_module
13
+ from ._result import CheckResult, Status
14
+ from .effects import verify_effects
15
+ from .examples import verify_examples
16
+ from .properties import verify_properties
17
+ from .types import verify_types
18
+
19
+ ALL_VERIFIERS = ("examples", "properties", "types", "effects")
20
+
21
+ #: Levels with no executable verification in v0.1 (specs stored only).
22
+ _NON_EXECUTABLE = {SpecLevel.CLASS, SpecLevel.MODULE, SpecLevel.PACKAGE}
23
+
24
+
25
+ def run_targets(targets: list[SpecTarget], which: set[str] | None = None) -> list[CheckResult]:
26
+ which = set(which) if which else set(ALL_VERIFIERS)
27
+ results: list[CheckResult] = []
28
+
29
+ for t in targets:
30
+ if t.spec.level in _NON_EXECUTABLE:
31
+ continue
32
+ if "examples" in which:
33
+ results.extend(verify_examples(t))
34
+ if "properties" in which:
35
+ results.extend(verify_properties(t))
36
+ if "effects" in which:
37
+ results.extend(verify_effects(t))
38
+
39
+ if "types" in which:
40
+ seen: set[str] = set()
41
+ for t in targets:
42
+ if t.filename and t.filename not in seen:
43
+ seen.add(t.filename)
44
+ results.extend(verify_types(t.filename))
45
+
46
+ return results
47
+
48
+
49
+ def run_all(module: ModuleType, which: set[str] | None = None) -> list[CheckResult]:
50
+ return run_targets(discover_in_module(module), which)
51
+
52
+
53
+ __all__ = [
54
+ "ALL_VERIFIERS",
55
+ "CheckResult",
56
+ "Status",
57
+ "SpecTarget",
58
+ "run_all",
59
+ "run_targets",
60
+ "verify_examples",
61
+ "verify_properties",
62
+ "verify_effects",
63
+ "verify_types",
64
+ ]
@@ -0,0 +1,42 @@
1
+ """The result type produced by every verifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+
9
+ class Status(Enum):
10
+ PASS = "pass"
11
+ FAIL = "fail"
12
+ SKIPPED = "skipped"
13
+ ERROR = "error" # the verifier could not run (e.g. bad spec value)
14
+
15
+
16
+ @dataclass
17
+ class CheckResult:
18
+ verifier: str # "examples" | "properties" | "types" | "effects"
19
+ target: str # qualname of the spec'd thing
20
+ status: Status
21
+ summary: str = "" # one-line outcome
22
+ detail: str = "" # full, paste-ready detail for a repair prompt
23
+ label: str = "" # sub-identifier, e.g. the ex case raw string
24
+
25
+ @property
26
+ def ok(self) -> bool:
27
+ return self.status in (Status.PASS, Status.SKIPPED)
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ base = f"{self.target}::{self.verifier}"
32
+ return f"{base}[{self.label}]" if self.label else base
33
+
34
+ def to_dict(self) -> dict:
35
+ return {
36
+ "verifier": self.verifier,
37
+ "target": self.target,
38
+ "status": self.status.value,
39
+ "summary": self.summary,
40
+ "detail": self.detail,
41
+ "label": self.label,
42
+ }
@@ -0,0 +1,169 @@
1
+ """Effects verifier — shallow AST checks of declared effects.
2
+
3
+ In v0.1 three effects are checked:
4
+
5
+ * ``pure`` — no calls into known impure modules/builtins, no global/nonlocal writes
6
+ * ``async_`` — the function really is a coroutine
7
+ * ``throws`` — every explicitly raised exception type is declared
8
+
9
+ All other effects are recorded as declaration-only. The purity check is
10
+ intentionally shallow (it does not follow calls into helpers) and reports the
11
+ specific offending lines.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import inspect
18
+ import textwrap
19
+
20
+ from .._effects import EffectKind
21
+ from .._discovery import SpecTarget
22
+ from ._result import CheckResult, Status
23
+
24
+ _IMPURE_BUILTINS = {"print", "open", "input", "exec", "eval", "breakpoint"}
25
+ _IMPURE_ROOTS = {
26
+ "os", "sys", "random", "requests", "httpx", "socket",
27
+ "subprocess", "urllib", "time", "shutil",
28
+ }
29
+
30
+
31
+ def _root_name(node: ast.AST) -> str | None:
32
+ while isinstance(node, ast.Attribute):
33
+ node = node.value
34
+ if isinstance(node, ast.Name):
35
+ return node.id
36
+ return None
37
+
38
+
39
+ class _PurityVisitor(ast.NodeVisitor):
40
+ def __init__(self) -> None:
41
+ self.violations: list[tuple[int, str]] = []
42
+
43
+ def visit_Global(self, node: ast.Global) -> None:
44
+ self.violations.append((node.lineno, "global statement"))
45
+
46
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
47
+ self.violations.append((node.lineno, "nonlocal statement"))
48
+
49
+ def visit_Call(self, node: ast.Call) -> None:
50
+ f = node.func
51
+ if isinstance(f, ast.Name) and f.id in _IMPURE_BUILTINS:
52
+ self.violations.append((node.lineno, f"call to {f.id}()"))
53
+ elif isinstance(f, ast.Attribute):
54
+ root = _root_name(f)
55
+ if root in _IMPURE_ROOTS:
56
+ self.violations.append((node.lineno, f"call into '{root}'"))
57
+ self.generic_visit(node)
58
+
59
+
60
+ class _RaiseCollector(ast.NodeVisitor):
61
+ def __init__(self) -> None:
62
+ self.raised: list[tuple[int, str]] = []
63
+
64
+ def visit_Raise(self, node: ast.Raise) -> None:
65
+ exc = node.exc
66
+ if exc is None: # bare re-raise
67
+ return
68
+ target = exc.func if isinstance(exc, ast.Call) else exc
69
+ name = None
70
+ if isinstance(target, ast.Name):
71
+ name = target.id
72
+ elif isinstance(target, ast.Attribute):
73
+ name = target.attr
74
+ if name:
75
+ self.raised.append((node.lineno, name))
76
+ self.generic_visit(node)
77
+
78
+
79
+ def _get_ast(target: SpecTarget) -> ast.AST | None:
80
+ fn = target.invoke
81
+ if fn is None:
82
+ return None
83
+ try:
84
+ src = textwrap.dedent(inspect.getsource(fn))
85
+ except (OSError, TypeError):
86
+ return None
87
+ try:
88
+ tree = ast.parse(src)
89
+ except SyntaxError:
90
+ return None
91
+ for node in ast.walk(tree):
92
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
93
+ return node
94
+ return None
95
+
96
+
97
+ def verify_effects(target: SpecTarget) -> list[CheckResult]:
98
+ sp = target.spec
99
+ name = sp.target_name
100
+ kinds = {e.kind for e in sp.effects}
101
+ if not kinds:
102
+ return []
103
+
104
+ results: list[CheckResult] = []
105
+ fn_ast = None
106
+ if kinds & {EffectKind.PURE, EffectKind.THROWS}:
107
+ fn_ast = _get_ast(target)
108
+
109
+ if EffectKind.PURE in kinds:
110
+ results.append(_check_pure(name, fn_ast))
111
+ if EffectKind.ASYNC in kinds:
112
+ results.append(_check_async(name, sp.is_async))
113
+ if EffectKind.THROWS in kinds:
114
+ results.append(_check_throws(name, sp, fn_ast))
115
+
116
+ declared_only = kinds - {EffectKind.PURE, EffectKind.ASYNC, EffectKind.THROWS}
117
+ if declared_only:
118
+ labels = ", ".join(sorted(k.value for k in declared_only))
119
+ results.append(
120
+ CheckResult("effects", name, Status.SKIPPED,
121
+ summary=f"declaration-only in v0.1: {labels}")
122
+ )
123
+ return results
124
+
125
+
126
+ def _check_pure(name: str, fn_ast) -> CheckResult:
127
+ if fn_ast is None:
128
+ return CheckResult("effects", name, Status.SKIPPED,
129
+ summary="could not read source for purity check", label="pure")
130
+ v = _PurityVisitor()
131
+ v.visit(fn_ast)
132
+ if not v.violations:
133
+ return CheckResult("effects", name, Status.PASS, summary="pure", label="pure")
134
+ lines = "\n".join(f" line {ln}: {what}" for ln, what in v.violations)
135
+ detail = f"{name} is declared pure but performs side effects:\n{lines}"
136
+ return CheckResult("effects", name, Status.FAIL,
137
+ summary=f"declared pure but has {len(v.violations)} side effect(s)",
138
+ detail=detail, label="pure")
139
+
140
+
141
+ def _check_async(name: str, is_async: bool) -> CheckResult:
142
+ if is_async:
143
+ return CheckResult("effects", name, Status.PASS, summary="async", label="async")
144
+ return CheckResult("effects", name, Status.FAIL,
145
+ summary="declared async_ but is not a coroutine function",
146
+ detail=f"{name} declares async_ but was not defined with 'async def'.",
147
+ label="async")
148
+
149
+
150
+ def _check_throws(name: str, sp, fn_ast) -> CheckResult:
151
+ if fn_ast is None:
152
+ return CheckResult("effects", name, Status.SKIPPED,
153
+ summary="could not read source for throws check", label="throws")
154
+ declared = {e.__name__ for e in sp.thrown_exceptions}
155
+ c = _RaiseCollector()
156
+ c.visit(fn_ast)
157
+ undeclared = [(ln, nm) for ln, nm in c.raised if nm not in declared]
158
+ if not undeclared:
159
+ return CheckResult("effects", name, Status.PASS,
160
+ summary=f"raises only declared: {', '.join(sorted(declared))}",
161
+ label="throws")
162
+ lines = "\n".join(f" line {ln}: raises {nm} (not in throws(...))" for ln, nm in undeclared)
163
+ detail = (
164
+ f"{name} raises exception types not declared in throws(...):\n{lines}\n"
165
+ f" declared: {', '.join(sorted(declared)) or '(none)'}"
166
+ )
167
+ return CheckResult("effects", name, Status.FAIL,
168
+ summary=f"{len(undeclared)} undeclared raise(s)",
169
+ detail=detail, label="throws")