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/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")
|