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 ADDED
@@ -0,0 +1,56 @@
1
+ """pyintent — verify that implementations satisfy intent specifications.
2
+
3
+ A pure verifier: it checks, it never generates. See ``pyintent prompt`` for the
4
+ full spec-authoring reference.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._effects import (
10
+ Effect,
11
+ EffectKind,
12
+ async_,
13
+ io,
14
+ network,
15
+ pure,
16
+ reads,
17
+ throws,
18
+ writes,
19
+ )
20
+ from ._errors import PyIntentError, PyIntentSpecError
21
+ from ._module_spec import module_spec, package_spec
22
+ from ._parser import Example
23
+ from ._perf import Perf
24
+ from ._spec import (
25
+ Invariant,
26
+ PyIntentSpec,
27
+ SpecLevel,
28
+ get_spec,
29
+ spec,
30
+ )
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "spec",
36
+ "get_spec",
37
+ "module_spec",
38
+ "package_spec",
39
+ "Perf",
40
+ "pure",
41
+ "reads",
42
+ "writes",
43
+ "network",
44
+ "io",
45
+ "async_",
46
+ "throws",
47
+ "Effect",
48
+ "EffectKind",
49
+ "PyIntentSpec",
50
+ "SpecLevel",
51
+ "Invariant",
52
+ "Example",
53
+ "PyIntentError",
54
+ "PyIntentSpecError",
55
+ "__version__",
56
+ ]
pyintent/_discovery.py ADDED
@@ -0,0 +1,105 @@
1
+ """Find every spec attached to a module's functions, classes, and methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from types import ModuleType
8
+ from typing import Any
9
+
10
+ from ._module_spec import MODULE_ATTR
11
+ from ._spec import PyIntentSpec, SpecLevel, get_spec
12
+
13
+
14
+ @dataclass
15
+ class SpecTarget:
16
+ """A discovered spec plus everything a verifier needs to act on it."""
17
+
18
+ qualname: str
19
+ spec: PyIntentSpec
20
+ globalns: dict[str, Any]
21
+ module_name: str
22
+ filename: str | None = None
23
+ invoke: Any | None = None # the callable to execute, when runnable
24
+ owner: type | None = None # the owning class, for methods
25
+
26
+
27
+ def discover_in_module(module: ModuleType) -> list[SpecTarget]:
28
+ targets: list[SpecTarget] = []
29
+ globalns = vars(module)
30
+ modname = getattr(module, "__name__", "<module>")
31
+ filename = getattr(module, "__file__", None)
32
+
33
+ mod_spec = globalns.get(MODULE_ATTR)
34
+ if isinstance(mod_spec, PyIntentSpec):
35
+ targets.append(
36
+ SpecTarget(
37
+ qualname=modname,
38
+ spec=mod_spec,
39
+ globalns=globalns,
40
+ module_name=modname,
41
+ filename=filename,
42
+ )
43
+ )
44
+
45
+ for name, obj in list(globalns.items()):
46
+ if inspect.isfunction(obj):
47
+ sp = get_spec(obj)
48
+ if sp is not None and getattr(obj, "__module__", None) == modname:
49
+ targets.append(
50
+ SpecTarget(
51
+ qualname=obj.__qualname__,
52
+ spec=sp,
53
+ globalns=globalns,
54
+ module_name=modname,
55
+ filename=filename,
56
+ invoke=obj,
57
+ )
58
+ )
59
+ elif inspect.isclass(obj) and getattr(obj, "__module__", None) == modname:
60
+ csp = get_spec(obj)
61
+ if csp is not None:
62
+ targets.append(
63
+ SpecTarget(
64
+ qualname=obj.__qualname__,
65
+ spec=csp,
66
+ globalns=globalns,
67
+ module_name=modname,
68
+ filename=filename,
69
+ owner=obj,
70
+ )
71
+ )
72
+ targets.extend(_discover_in_class(obj, globalns, modname, filename))
73
+
74
+ return targets
75
+
76
+
77
+ def _discover_in_class(
78
+ cls: type, globalns: dict[str, Any], modname: str, filename: str | None
79
+ ) -> list[SpecTarget]:
80
+ targets: list[SpecTarget] = []
81
+ for name, member in list(vars(cls).items()):
82
+ sp = get_spec(member)
83
+ if sp is None:
84
+ continue
85
+ invoke: Any | None = None
86
+ if sp.level is SpecLevel.CLASSMETHOD:
87
+ invoke = getattr(cls, name) # bound to cls
88
+ elif sp.level is SpecLevel.STATICMETHOD:
89
+ invoke = member.__func__
90
+ elif sp.level is SpecLevel.PROPERTY:
91
+ invoke = member.fget
92
+ elif sp.level in (SpecLevel.METHOD, SpecLevel.ABSTRACT):
93
+ invoke = member
94
+ targets.append(
95
+ SpecTarget(
96
+ qualname=sp.target_name,
97
+ spec=sp,
98
+ globalns=globalns,
99
+ module_name=modname,
100
+ filename=filename,
101
+ invoke=invoke,
102
+ owner=cls,
103
+ )
104
+ )
105
+ return targets
pyintent/_effects.py ADDED
@@ -0,0 +1,166 @@
1
+ """Effect declarations for a spec.
2
+
3
+ An effect describes how a function interacts with the world. Effects are plain
4
+ immutable value objects, validated at construction time. In v0.1 only ``pure``,
5
+ ``async_`` and ``throws`` are *verified* (see ``verifier/effects.py``); the rest
6
+ are declaration-only and recorded for documentation and future versions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+
14
+ from ._errors import PyIntentSpecError
15
+
16
+
17
+ class EffectKind(Enum):
18
+ PURE = "pure"
19
+ READS = "reads"
20
+ WRITES = "writes"
21
+ NETWORK = "network"
22
+ IO = "io"
23
+ ASYNC = "async"
24
+ THROWS = "throws"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Effect:
29
+ """A single declared effect. Construct via the helpers below, not directly."""
30
+
31
+ kind: EffectKind
32
+ resource: str | None = None
33
+ exceptions: tuple[type[BaseException], ...] = field(default=())
34
+
35
+ def __repr__(self) -> str:
36
+ if self.kind in (EffectKind.READS, EffectKind.WRITES, EffectKind.NETWORK):
37
+ return f"{self.kind.value}({self.resource!r})"
38
+ if self.kind is EffectKind.THROWS:
39
+ names = ", ".join(e.__name__ for e in self.exceptions)
40
+ return f"throws({names})"
41
+ return self.kind.value
42
+
43
+
44
+ def _require_resource(fn_name: str, resource: object) -> str:
45
+ if not isinstance(resource, str) or not resource.strip():
46
+ raise PyIntentSpecError(
47
+ f"{fn_name}() requires a non-empty string naming the resource, "
48
+ f"e.g. {fn_name}('db'); got {resource!r}"
49
+ )
50
+ return resource
51
+
52
+
53
+ #: The function has no observable side effects and is deterministic.
54
+ #: **Actively verified** by the effects verifier via AST analysis.
55
+ #: Calls to impure builtins (``print``, ``open``, ``input``, …) or modules
56
+ #: (``os``, ``sys``, ``random``, ``requests``, …) and ``global``/``nonlocal``
57
+ #: writes are reported as violations.
58
+ pure = Effect(EffectKind.PURE)
59
+
60
+ #: The function performs filesystem / stdout / stdin style I/O.
61
+ #: Declaration-only in v0.1 — recorded for documentation, not yet enforced.
62
+ io = Effect(EffectKind.IO)
63
+
64
+ #: The function is a coroutine defined with ``async def``.
65
+ #: **Actively verified**: the effects verifier checks that the function really
66
+ #: is a coroutine function.
67
+ async_ = Effect(EffectKind.ASYNC)
68
+
69
+
70
+ def reads(resource: str) -> Effect:
71
+ """Declare that the function reads from a named resource.
72
+
73
+ This effect is **declaration-only** in v0.1 — it is recorded for
74
+ documentation but not actively verified.
75
+
76
+ Parameters
77
+ ----------
78
+ resource:
79
+ Non-empty string naming the resource, e.g. ``"db"``, ``"config"``.
80
+
81
+ Examples
82
+ --------
83
+ ::
84
+
85
+ @spec(intent="fetch user", effects=[reads("db")])
86
+ def get_user(user_id: int) -> User: ...
87
+ """
88
+ return Effect(EffectKind.READS, resource=_require_resource("reads", resource))
89
+
90
+
91
+ def writes(resource: str) -> Effect:
92
+ """Declare that the function writes to a named resource.
93
+
94
+ This effect is **declaration-only** in v0.1 — it is recorded for
95
+ documentation but not actively verified.
96
+
97
+ Parameters
98
+ ----------
99
+ resource:
100
+ Non-empty string naming the resource, e.g. ``"db"``, ``"cache"``.
101
+
102
+ Examples
103
+ --------
104
+ ::
105
+
106
+ @spec(intent="save user", effects=[writes("db")])
107
+ def save_user(user: User) -> None: ...
108
+ """
109
+ return Effect(EffectKind.WRITES, resource=_require_resource("writes", resource))
110
+
111
+
112
+ def network(service: str) -> Effect:
113
+ """Declare that the function calls an external network service.
114
+
115
+ This effect is **declaration-only** in v0.1 — it is recorded for
116
+ documentation but not actively verified.
117
+
118
+ Parameters
119
+ ----------
120
+ service:
121
+ Non-empty string naming the service, e.g. ``"stripe"``, ``"sendgrid"``.
122
+
123
+ Examples
124
+ --------
125
+ ::
126
+
127
+ @spec(intent="charge card", effects=[network("stripe")])
128
+ def charge(amount: int) -> str: ...
129
+ """
130
+ return Effect(EffectKind.NETWORK, resource=_require_resource("network", service))
131
+
132
+
133
+ def throws(*exceptions: type[BaseException]) -> Effect:
134
+ """Declare the exception types the function may raise as part of its contract.
135
+
136
+ **Actively verified** by the effects verifier: the AST is checked to ensure
137
+ every explicitly raised exception type is listed here.
138
+
139
+ Parameters
140
+ ----------
141
+ *exceptions:
142
+ One or more exception classes (subclasses of :class:`BaseException`).
143
+
144
+ Raises
145
+ ------
146
+ PyIntentSpecError
147
+ If no arguments are given, or any argument is not an exception class.
148
+
149
+ Examples
150
+ --------
151
+ ::
152
+
153
+ @spec(intent="parse int", effects=[throws(ValueError)])
154
+ def parse_int(s: str) -> int:
155
+ return int(s)
156
+ """
157
+ if not exceptions:
158
+ raise PyIntentSpecError(
159
+ "throws() requires at least one exception type, e.g. throws(ValueError)"
160
+ )
161
+ for exc in exceptions:
162
+ if not (isinstance(exc, type) and issubclass(exc, BaseException)):
163
+ raise PyIntentSpecError(
164
+ f"throws() arguments must be exception classes, got {exc!r}"
165
+ )
166
+ return Effect(EffectKind.THROWS, exceptions=tuple(exceptions))
pyintent/_errors.py ADDED
@@ -0,0 +1,15 @@
1
+ """Exception types raised by pyintent."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PyIntentError(Exception):
7
+ """Base class for every error pyintent raises."""
8
+
9
+
10
+ class PyIntentSpecError(PyIntentError):
11
+ """A spec is malformed.
12
+
13
+ Raised eagerly at decoration / construction time (never during verification)
14
+ so that a bad spec fails as soon as the module is imported.
15
+ """
pyintent/_loader.py ADDED
@@ -0,0 +1,56 @@
1
+ """Import Python files by path so their specs register (used by CLI and plugin)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from types import ModuleType
10
+ from typing import Iterator
11
+
12
+ _SKIP_DIRS = {
13
+ "__pycache__", ".git", ".venv", "venv", "node_modules",
14
+ ".mypy_cache", ".pytest_cache", "build", "dist", ".tox", ".eggs",
15
+ }
16
+
17
+
18
+ def import_file(path: str | Path) -> ModuleType:
19
+ """Import a single ``.py`` file as a uniquely-named module.
20
+
21
+ Scope (v0.1): this loads files as standalone modules under a synthetic name,
22
+ with the file's directory prepended to ``sys.path``. That is reliable for
23
+ self-contained scripts and flat layouts, but **explicit relative imports**
24
+ (``from . import x``) inside the target file are not resolved, since the file
25
+ is not imported as part of its package. For package-aware verification,
26
+ import the package normally and run the verifier against its modules.
27
+ """
28
+ p = Path(path).resolve()
29
+ modname = "pyintent_target_" + re.sub(r"\W", "_", str(p.with_suffix("")))
30
+ if modname in sys.modules:
31
+ return sys.modules[modname]
32
+
33
+ parent = str(p.parent)
34
+ if parent not in sys.path:
35
+ sys.path.insert(0, parent)
36
+
37
+ spec = importlib.util.spec_from_file_location(modname, str(p))
38
+ if spec is None or spec.loader is None:
39
+ raise ImportError(f"could not create import spec for {p}")
40
+ module = importlib.util.module_from_spec(spec)
41
+ sys.modules[modname] = module
42
+ spec.loader.exec_module(module)
43
+ return module
44
+
45
+
46
+ def iter_python_files(root: str | Path) -> Iterator[Path]:
47
+ """Yield ``.py`` files under ``root`` (or just ``root`` if it is a file)."""
48
+ p = Path(root)
49
+ if p.is_file():
50
+ if p.suffix == ".py":
51
+ yield p
52
+ return
53
+ for child in sorted(p.rglob("*.py")):
54
+ if any(part in _SKIP_DIRS for part in child.parts):
55
+ continue
56
+ yield child
@@ -0,0 +1,68 @@
1
+ """Module- and package-level specs.
2
+
3
+ Assign the result to ``__pyintent__`` in a module (or a package ``__init__.py``)::
4
+
5
+ from pyintent import module_spec, reads
6
+
7
+ __pyintent__ = module_spec(
8
+ intent = "Order persistence and retrieval.",
9
+ invariants = ["every public function validates its inputs"],
10
+ effects = [reads("db")],
11
+ )
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ._effects import Effect
17
+ from ._errors import PyIntentSpecError
18
+ from ._spec import (
19
+ PyIntentSpec,
20
+ SpecLevel,
21
+ _reject_disallowed,
22
+ _require_intent,
23
+ _validate_effects,
24
+ _validate_invariants,
25
+ )
26
+
27
+ MODULE_ATTR = "__pyintent__"
28
+
29
+
30
+ def module_spec(
31
+ *,
32
+ intent: str,
33
+ invariants: list[str] | None = None,
34
+ effects: list[Effect] | None = None,
35
+ ) -> PyIntentSpec:
36
+ """Build a module-level spec. Assign it to ``__pyintent__``."""
37
+ intent = _require_intent(intent)
38
+ return PyIntentSpec(
39
+ level=SpecLevel.MODULE,
40
+ intent=intent,
41
+ target_name="<module>",
42
+ invariants=_validate_invariants(invariants),
43
+ effects=_validate_effects(effects),
44
+ )
45
+
46
+
47
+ def package_spec(
48
+ *,
49
+ intent: str,
50
+ modules: list[str] | None = None,
51
+ invariants: list[str] | None = None,
52
+ effects: list[Effect] | None = None,
53
+ ) -> PyIntentSpec:
54
+ """Build a package-level spec. Assign it to ``__pyintent__`` in ``__init__.py``."""
55
+ intent = _require_intent(intent)
56
+ if modules is not None:
57
+ if not isinstance(modules, (list, tuple)) or not all(
58
+ isinstance(m, str) and m.strip() for m in modules
59
+ ):
60
+ raise PyIntentSpecError("modules= must be a list of non-empty module-name strings")
61
+ return PyIntentSpec(
62
+ level=SpecLevel.PACKAGE,
63
+ intent=intent,
64
+ target_name="<package>",
65
+ invariants=_validate_invariants(invariants),
66
+ effects=_validate_effects(effects),
67
+ modules=list(modules) if modules else [],
68
+ )
pyintent/_parser.py ADDED
@@ -0,0 +1,198 @@
1
+ """Parser for ``ex`` example strings.
2
+
3
+ Grammar (informal)::
4
+
5
+ example := args "->" expected
6
+ args := "(" python-tuple ")" # literal tuple; () means no args
7
+ expected := "_" # returns anything, raises nothing
8
+ | "raises" dotted-name # must raise this exception type
9
+ | python-expression # must == this value
10
+
11
+ The *format* is validated eagerly at decoration time (so a malformed ``ex``
12
+ fails at import). The actual values are evaluated lazily at verification time
13
+ against the target's module globals, so domain objects and enums resolve
14
+ correctly.
15
+
16
+ For methods, ``ex`` tuples exclude ``self`` / ``cls``. For properties, the
17
+ input tuple is empty: ``"() -> value"``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ from dataclasses import dataclass
24
+ from typing import Any, Mapping
25
+
26
+ from ._errors import PyIntentSpecError
27
+
28
+
29
+ class _Wildcard:
30
+ __slots__ = ()
31
+
32
+ def __repr__(self) -> str:
33
+ return "_"
34
+
35
+
36
+ #: Sentinel: the call must return *something* without raising.
37
+ WILDCARD = _Wildcard()
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Raises:
42
+ """Expected outcome: the call must raise ``exc_name``."""
43
+
44
+ exc_name: str
45
+
46
+ def resolve(self, globalns: Mapping[str, Any]) -> type[BaseException]:
47
+ try:
48
+ exc = eval(self.exc_name, dict(globalns)) # noqa: S307 - dev-authored
49
+ except Exception as e: # pragma: no cover - surfaced to user
50
+ raise PyIntentSpecError(
51
+ f"could not resolve exception {self.exc_name!r} in example: {e}"
52
+ ) from e
53
+ if not (isinstance(exc, type) and issubclass(exc, BaseException)):
54
+ raise PyIntentSpecError(
55
+ f"example 'raises {self.exc_name}' does not name an exception type"
56
+ )
57
+ return exc
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ReturnsValue:
62
+ """Expected outcome: the call must return a value equal to ``value_src``."""
63
+
64
+ value_src: str
65
+
66
+ def resolve(self, globalns: Mapping[str, Any]) -> Any:
67
+ try:
68
+ return eval(self.value_src, dict(globalns)) # noqa: S307 - dev-authored
69
+ except Exception as e: # pragma: no cover - surfaced to user
70
+ raise PyIntentSpecError(
71
+ f"could not evaluate expected value {self.value_src!r}: {e}"
72
+ ) from e
73
+
74
+
75
+ Expected = _Wildcard | Raises | ReturnsValue
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Example:
80
+ """A single parsed ``ex`` case."""
81
+
82
+ raw: str
83
+ args_src: str
84
+ expected: Expected
85
+
86
+ def eval_args(self, globalns: Mapping[str, Any]) -> tuple[Any, ...]:
87
+ try:
88
+ value = eval(self.args_src, dict(globalns)) # noqa: S307 - dev-authored
89
+ except Exception as e: # pragma: no cover - surfaced to user
90
+ raise PyIntentSpecError(
91
+ f"could not evaluate example args {self.args_src!r}: {e}"
92
+ ) from e
93
+ if not isinstance(value, tuple):
94
+ raise PyIntentSpecError(
95
+ f"example args must evaluate to a tuple, got {type(value).__name__}"
96
+ )
97
+ return value
98
+
99
+
100
+ def _split_on_arrow(text: str) -> tuple[str, str]:
101
+ """Split on the first top-level ``->`` outside of strings and brackets."""
102
+ depth = 0
103
+ quote: str | None = None
104
+ i, n = 0, len(text)
105
+ while i < n:
106
+ c = text[i]
107
+ if quote is not None:
108
+ if c == "\\":
109
+ i += 2
110
+ continue
111
+ if c == quote:
112
+ quote = None
113
+ i += 1
114
+ continue
115
+ if c in "\"'":
116
+ quote = c
117
+ elif c in "([{":
118
+ depth += 1
119
+ elif c in ")]}":
120
+ depth -= 1
121
+ elif c == "-" and depth == 0 and i + 1 < n and text[i + 1] == ">":
122
+ return text[:i], text[i + 2 :]
123
+ i += 1
124
+ raise PyIntentSpecError(
125
+ f"example {text!r} is missing the '->' separator "
126
+ f"(expected '(args) -> result')"
127
+ )
128
+
129
+
130
+ def _validate_args_tuple(args_src: str, raw: str) -> str:
131
+ args_src = args_src.strip()
132
+ if not args_src:
133
+ raise PyIntentSpecError(
134
+ f"example {raw!r} has no argument tuple before '->' "
135
+ f"(use '()' for no arguments)"
136
+ )
137
+ try:
138
+ node = ast.parse(args_src, mode="eval")
139
+ except (SyntaxError, ValueError) as e:
140
+ msg = e.msg if isinstance(e, SyntaxError) else str(e)
141
+ raise PyIntentSpecError(
142
+ f"example args {args_src!r} are not valid Python: {msg}"
143
+ ) from e
144
+ if not isinstance(node.body, ast.Tuple):
145
+ raise PyIntentSpecError(
146
+ f"example args must be a tuple, got {args_src!r}. "
147
+ f"For a single argument add a trailing comma, e.g. '(42,) -> ...'."
148
+ )
149
+ return args_src
150
+
151
+
152
+ def _parse_expected(expected_src: str, raw: str) -> Expected:
153
+ expected_src = expected_src.strip()
154
+ if not expected_src:
155
+ raise PyIntentSpecError(
156
+ f"example {raw!r} has nothing after '->' "
157
+ f"(use '_' for 'returns without raising')"
158
+ )
159
+ if expected_src == "_":
160
+ return WILDCARD
161
+ if expected_src == "raises" or expected_src.startswith("raises "):
162
+ exc_name = expected_src[len("raises") :].strip()
163
+ if not exc_name:
164
+ raise PyIntentSpecError(
165
+ f"example {raw!r}: 'raises' must be followed by an exception type"
166
+ )
167
+ try:
168
+ name_node = ast.parse(exc_name, mode="eval")
169
+ except (SyntaxError, ValueError) as e:
170
+ msg = e.msg if isinstance(e, SyntaxError) else str(e)
171
+ raise PyIntentSpecError(
172
+ f"example {raw!r}: invalid exception name {exc_name!r}: {msg}"
173
+ ) from e
174
+ if not isinstance(name_node.body, (ast.Name, ast.Attribute)):
175
+ raise PyIntentSpecError(
176
+ f"example {raw!r}: 'raises' must name an exception type, got {exc_name!r}"
177
+ )
178
+ return Raises(exc_name)
179
+ try:
180
+ ast.parse(expected_src, mode="eval")
181
+ except (SyntaxError, ValueError) as e:
182
+ msg = e.msg if isinstance(e, SyntaxError) else str(e)
183
+ raise PyIntentSpecError(
184
+ f"example {raw!r}: expected value {expected_src!r} is not valid Python: {msg}"
185
+ ) from e
186
+ return ReturnsValue(expected_src)
187
+
188
+
189
+ def parse_example(raw: str) -> Example:
190
+ """Parse and format-validate one ``ex`` string. Raises ``PyIntentSpecError``."""
191
+ if not isinstance(raw, str):
192
+ raise PyIntentSpecError(
193
+ f"each ex entry must be a string like '(1, 2) -> 3', got {raw!r}"
194
+ )
195
+ left, right = _split_on_arrow(raw)
196
+ args_src = _validate_args_tuple(left, raw)
197
+ expected = _parse_expected(right, raw)
198
+ return Example(raw=raw, args_src=args_src, expected=expected)