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/__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
|
pyintent/_module_spec.py
ADDED
|
@@ -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)
|