reeval-python 0.10.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.
- reeval_python/__init__.py +23 -0
- reeval_python/__main__.py +4 -0
- reeval_python/assertion.py +168 -0
- reeval_python/context.py +163 -0
- reeval_python/declare.py +263 -0
- reeval_python/errors.py +7 -0
- reeval_python/expect.py +95 -0
- reeval_python/py.typed +0 -0
- reeval_python/runner.py +190 -0
- reeval_python/types.py +97 -0
- reeval_python/wire.py +81 -0
- reeval_python-0.10.0.dist-info/METADATA +85 -0
- reeval_python-0.10.0.dist-info/RECORD +15 -0
- reeval_python-0.10.0.dist-info/WHEEL +4 -0
- reeval_python-0.10.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Python eval authoring API for reeval (*.eval.py)."""
|
|
2
|
+
|
|
3
|
+
from reeval_python.context import AttemptContext, SyncAttemptContext
|
|
4
|
+
from reeval_python.declare import collect_module_tree, evaluate, group, reset_collector
|
|
5
|
+
from reeval_python.errors import ReevalAssertionError
|
|
6
|
+
from reeval_python.expect import expect, soft
|
|
7
|
+
from reeval_python.types import BackgroundHandle, ExecResult, Turn, Verdict
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AttemptContext",
|
|
11
|
+
"BackgroundHandle",
|
|
12
|
+
"ExecResult",
|
|
13
|
+
"ReevalAssertionError",
|
|
14
|
+
"SyncAttemptContext",
|
|
15
|
+
"Turn",
|
|
16
|
+
"Verdict",
|
|
17
|
+
"collect_module_tree",
|
|
18
|
+
"evaluate",
|
|
19
|
+
"expect",
|
|
20
|
+
"group",
|
|
21
|
+
"reset_collector",
|
|
22
|
+
"soft",
|
|
23
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Turn plain Python assert failures into attempt-protocol expect calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import linecache
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class AssertExpect:
|
|
14
|
+
subject: dict[str, Any]
|
|
15
|
+
matcher: str
|
|
16
|
+
args: list[Any]
|
|
17
|
+
label: str | None
|
|
18
|
+
not_: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class _EvalFrame:
|
|
23
|
+
filename: str
|
|
24
|
+
lineno: int
|
|
25
|
+
frame: FrameType
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_eval(node: ast.expr, frame: FrameType) -> Any:
|
|
29
|
+
code = compile(ast.Expression(node), "<assert>", "eval")
|
|
30
|
+
return eval(code, frame.f_globals, frame.f_locals) # noqa: S307
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _show(value: Any) -> str:
|
|
34
|
+
try:
|
|
35
|
+
return repr(value)
|
|
36
|
+
except Exception: # noqa: BLE001
|
|
37
|
+
return "<unrepr>"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _frame_for_assert(exc: BaseException) -> _EvalFrame | None:
|
|
41
|
+
tb = exc.__traceback__
|
|
42
|
+
found: _EvalFrame | None = None
|
|
43
|
+
while tb is not None:
|
|
44
|
+
if tb.tb_frame.f_code.co_filename.endswith(".eval.py"):
|
|
45
|
+
found = _EvalFrame(tb.tb_frame.f_code.co_filename, tb.tb_lineno, tb.tb_frame)
|
|
46
|
+
tb = tb.tb_next
|
|
47
|
+
return found
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_assert_line(source: str, lineno: int, filename: str) -> ast.Assert | None:
|
|
51
|
+
try:
|
|
52
|
+
tree = ast.parse(source, mode="exec")
|
|
53
|
+
except SyntaxError:
|
|
54
|
+
tree = None
|
|
55
|
+
if tree is not None:
|
|
56
|
+
for node in ast.walk(tree):
|
|
57
|
+
if isinstance(node, ast.Assert) and node.lineno == lineno:
|
|
58
|
+
return node
|
|
59
|
+
line = linecache.getline(filename, lineno).strip()
|
|
60
|
+
if not line.startswith("assert"):
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
parsed = ast.parse(line)
|
|
64
|
+
except SyntaxError:
|
|
65
|
+
return None
|
|
66
|
+
stmt = parsed.body[0] if parsed.body else None
|
|
67
|
+
return stmt if isinstance(stmt, ast.Assert) else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _label_from_assert(node: ast.Assert, exc: AssertionError) -> str | None:
|
|
71
|
+
if node.msg is not None:
|
|
72
|
+
try:
|
|
73
|
+
if isinstance(node.msg, ast.Constant) and isinstance(node.msg.value, str):
|
|
74
|
+
return node.msg.value
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
pass
|
|
77
|
+
if exc.args and isinstance(exc.args[0], str):
|
|
78
|
+
return exc.args[0]
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _wire_for_test(test: ast.expr, frame: FrameType, label: str | None) -> AssertExpect:
|
|
83
|
+
if isinstance(test, ast.Compare) and len(test.ops) == 1 and len(test.comparators) == 1:
|
|
84
|
+
op = test.ops[0]
|
|
85
|
+
left = _safe_eval(test.left, frame)
|
|
86
|
+
right = _safe_eval(test.comparators[0], frame)
|
|
87
|
+
if isinstance(op, ast.Eq):
|
|
88
|
+
enriched = label or f"assert {_show(left)} == {_show(right)}"
|
|
89
|
+
return AssertExpect(
|
|
90
|
+
subject={"value": left},
|
|
91
|
+
matcher="toEqual",
|
|
92
|
+
args=[right],
|
|
93
|
+
label=enriched,
|
|
94
|
+
)
|
|
95
|
+
if isinstance(op, ast.Is):
|
|
96
|
+
enriched = label or f"assert {_show(left)} is {_show(right)}"
|
|
97
|
+
return AssertExpect(
|
|
98
|
+
subject={"value": left},
|
|
99
|
+
matcher="toBe",
|
|
100
|
+
args=[right],
|
|
101
|
+
label=enriched,
|
|
102
|
+
)
|
|
103
|
+
if isinstance(op, ast.IsNot):
|
|
104
|
+
enriched = label or f"assert {_show(left)} is not {_show(right)}"
|
|
105
|
+
return AssertExpect(
|
|
106
|
+
subject={"value": left},
|
|
107
|
+
matcher="toBe",
|
|
108
|
+
args=[right],
|
|
109
|
+
label=enriched,
|
|
110
|
+
not_=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if isinstance(test, ast.Constant) and test.value is False:
|
|
114
|
+
return AssertExpect(
|
|
115
|
+
subject={"value": False},
|
|
116
|
+
matcher="toBeTruthy",
|
|
117
|
+
args=[],
|
|
118
|
+
label=label or "assert False",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
actual = _safe_eval(test, frame)
|
|
122
|
+
enriched = label
|
|
123
|
+
if enriched is None:
|
|
124
|
+
enriched = f"assert {_show(actual)}"
|
|
125
|
+
elif not actual:
|
|
126
|
+
enriched = f"{enriched} ({_show(actual)} is falsy)"
|
|
127
|
+
return AssertExpect(
|
|
128
|
+
subject={"value": actual},
|
|
129
|
+
matcher="toBeTruthy",
|
|
130
|
+
args=[],
|
|
131
|
+
label=enriched,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def assert_to_expect(exc: AssertionError) -> AssertExpect:
|
|
136
|
+
frame = _frame_for_assert(exc)
|
|
137
|
+
if frame is None:
|
|
138
|
+
msg = str(exc) if str(exc) else "assertion failed"
|
|
139
|
+
return AssertExpect(
|
|
140
|
+
subject={"value": False},
|
|
141
|
+
matcher="toBeTruthy",
|
|
142
|
+
args=[],
|
|
143
|
+
label=msg,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
source = linecache.getlines(frame.filename)
|
|
147
|
+
if not source:
|
|
148
|
+
msg = str(exc) if str(exc) else "assertion failed"
|
|
149
|
+
return AssertExpect(
|
|
150
|
+
subject={"value": False},
|
|
151
|
+
matcher="toBeTruthy",
|
|
152
|
+
args=[],
|
|
153
|
+
label=msg,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
block = "".join(source)
|
|
157
|
+
node = _parse_assert_line(block, frame.lineno, frame.filename)
|
|
158
|
+
if node is None:
|
|
159
|
+
msg = str(exc) if str(exc) else "assertion failed"
|
|
160
|
+
return AssertExpect(
|
|
161
|
+
subject={"value": False},
|
|
162
|
+
matcher="toBeTruthy",
|
|
163
|
+
args=[],
|
|
164
|
+
label=msg,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
label = _label_from_assert(node, exc)
|
|
168
|
+
return _wire_for_test(node.test, frame.frame, label)
|
reeval_python/context.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Attempt context: native Python API over the attempt protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from reeval_python.assertion import assert_to_expect
|
|
9
|
+
from reeval_python.errors import ReevalAssertionError
|
|
10
|
+
from reeval_python.expect import bind_expect
|
|
11
|
+
from reeval_python.types import BackgroundHandle, ExecResult, Turn, Verdict
|
|
12
|
+
from reeval_python.wire import options_to_wire
|
|
13
|
+
|
|
14
|
+
SendFn = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttemptContext:
|
|
18
|
+
def __init__(self, attempt_id: str, send: SendFn) -> None:
|
|
19
|
+
self._attempt = attempt_id
|
|
20
|
+
self._send = send
|
|
21
|
+
self._seq = 0
|
|
22
|
+
self.turns: list[Turn] = []
|
|
23
|
+
|
|
24
|
+
async def _call(self, op: str, **fields: Any) -> dict[str, Any]:
|
|
25
|
+
self._seq += 1
|
|
26
|
+
call: dict[str, Any] = {"op": op, **fields}
|
|
27
|
+
reply = await self._send({"call": call})
|
|
28
|
+
if not reply.get("ok", False):
|
|
29
|
+
err = reply.get("error") or {"name": "Error", "message": "call failed"}
|
|
30
|
+
raise RuntimeError(f"{err.get('name')}: {err.get('message')}")
|
|
31
|
+
return reply
|
|
32
|
+
|
|
33
|
+
async def send(self, prompt: str) -> Turn:
|
|
34
|
+
reply = await self._call("send", prompt=prompt)
|
|
35
|
+
turn = Turn(index=len(self.turns), raw=cast(dict[str, Any], reply["turn"]))
|
|
36
|
+
self.turns.append(turn)
|
|
37
|
+
return turn
|
|
38
|
+
|
|
39
|
+
async def exec(
|
|
40
|
+
self,
|
|
41
|
+
command: str,
|
|
42
|
+
*,
|
|
43
|
+
background: bool = False,
|
|
44
|
+
) -> ExecResult | BackgroundHandle:
|
|
45
|
+
fields: dict[str, Any] = {"command": command}
|
|
46
|
+
if background:
|
|
47
|
+
fields["background"] = True
|
|
48
|
+
reply = await self._call("exec", **fields)
|
|
49
|
+
result = cast(dict[str, Any], reply["result"])
|
|
50
|
+
if background:
|
|
51
|
+
return BackgroundHandle.from_wire(result)
|
|
52
|
+
return ExecResult.from_wire(command, result)
|
|
53
|
+
|
|
54
|
+
async def read_file(self, path: str) -> str | None:
|
|
55
|
+
reply = await self._call("readFile", path=path)
|
|
56
|
+
contents = reply.get("contents")
|
|
57
|
+
return None if contents is None else str(contents)
|
|
58
|
+
|
|
59
|
+
async def file(self, path: str) -> str | None:
|
|
60
|
+
return await self.read_file(path)
|
|
61
|
+
|
|
62
|
+
async def write_file(self, path: str, content: str) -> None:
|
|
63
|
+
await self._call("writeFile", path=path, content=content)
|
|
64
|
+
|
|
65
|
+
async def kill(self, handle: BackgroundHandle) -> None:
|
|
66
|
+
await self._call("kill", handle=handle.id)
|
|
67
|
+
|
|
68
|
+
async def judge(self, question: str, **options: Any) -> Verdict:
|
|
69
|
+
wire_opts = options_to_wire(options) if options else None
|
|
70
|
+
reply = await self._call("judge", question=question, options=wire_opts)
|
|
71
|
+
return Verdict.from_wire(cast(dict[str, Any], reply["verdict"]))
|
|
72
|
+
|
|
73
|
+
async def run_expect(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
subject: dict[str, Any],
|
|
77
|
+
matcher: str,
|
|
78
|
+
args: list[Any],
|
|
79
|
+
label: str | None,
|
|
80
|
+
not_: bool,
|
|
81
|
+
soft: bool,
|
|
82
|
+
) -> None:
|
|
83
|
+
call: dict[str, Any] = {
|
|
84
|
+
"op": "expect",
|
|
85
|
+
"subject": subject,
|
|
86
|
+
"matcher": matcher,
|
|
87
|
+
"args": args,
|
|
88
|
+
"not": not_,
|
|
89
|
+
"soft": soft,
|
|
90
|
+
}
|
|
91
|
+
if label is not None:
|
|
92
|
+
call["label"] = label
|
|
93
|
+
reply = await self._send({"call": call})
|
|
94
|
+
if not reply.get("ok", False):
|
|
95
|
+
err = reply.get("error") or {"name": "Error", "message": "call failed"}
|
|
96
|
+
raise RuntimeError(f"{err.get('name')}: {err.get('message')}")
|
|
97
|
+
assertion_raw = reply.get("assertion")
|
|
98
|
+
assertion = cast(dict[str, Any], assertion_raw) if isinstance(assertion_raw, dict) else {}
|
|
99
|
+
if assertion.get("status") == "fail":
|
|
100
|
+
raise ReevalAssertionError(str(assertion.get("message") or "expect failed"))
|
|
101
|
+
|
|
102
|
+
async def record_plain_assert(self, exc: AssertionError) -> None:
|
|
103
|
+
wire = assert_to_expect(exc)
|
|
104
|
+
await self.run_expect(
|
|
105
|
+
subject=wire.subject,
|
|
106
|
+
matcher=wire.matcher,
|
|
107
|
+
args=wire.args,
|
|
108
|
+
label=wire.label,
|
|
109
|
+
not_=wire.not_,
|
|
110
|
+
soft=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def bind_expect(self) -> None:
|
|
114
|
+
async def submit(**kwargs: Any) -> None:
|
|
115
|
+
await self.run_expect(**kwargs)
|
|
116
|
+
|
|
117
|
+
submit._sync_mode = False # type: ignore[attr-defined]
|
|
118
|
+
bind_expect(submit)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SyncAttemptContext:
|
|
122
|
+
"""Sync façade: each method blocks on the runner's asyncio loop."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, async_ctx: AttemptContext, loop: Any) -> None:
|
|
125
|
+
self._ctx = async_ctx
|
|
126
|
+
self._loop = loop
|
|
127
|
+
|
|
128
|
+
def _run(self, coro: Coroutine[Any, Any, Any]) -> Any:
|
|
129
|
+
import asyncio
|
|
130
|
+
|
|
131
|
+
fut = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
132
|
+
return fut.result()
|
|
133
|
+
|
|
134
|
+
def send(self, prompt: str) -> Turn:
|
|
135
|
+
return self._run(self._ctx.send(prompt))
|
|
136
|
+
|
|
137
|
+
def exec(self, command: str, *, background: bool = False) -> ExecResult | BackgroundHandle:
|
|
138
|
+
return cast(
|
|
139
|
+
ExecResult | BackgroundHandle,
|
|
140
|
+
self._run(self._ctx.exec(command, background=background)), # type: ignore[arg-type]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def read_file(self, path: str) -> str | None:
|
|
144
|
+
return self._run(self._ctx.read_file(path))
|
|
145
|
+
|
|
146
|
+
def file(self, path: str) -> str | None:
|
|
147
|
+
return self._run(self._ctx.file(path))
|
|
148
|
+
|
|
149
|
+
def write_file(self, path: str, content: str) -> None:
|
|
150
|
+
self._run(self._ctx.write_file(path, content))
|
|
151
|
+
|
|
152
|
+
def kill(self, handle: BackgroundHandle) -> None:
|
|
153
|
+
self._run(self._ctx.kill(handle))
|
|
154
|
+
|
|
155
|
+
def judge(self, question: str, **options: Any) -> Verdict:
|
|
156
|
+
return self._run(self._ctx.judge(question, **options))
|
|
157
|
+
|
|
158
|
+
def bind_expect(self) -> None:
|
|
159
|
+
def submit(**kwargs: Any) -> None:
|
|
160
|
+
self._run(self._ctx.run_expect(**kwargs))
|
|
161
|
+
|
|
162
|
+
submit._sync_mode = True # type: ignore[attr-defined]
|
|
163
|
+
bind_expect(submit)
|
reeval_python/declare.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Build a declaration tree matching reeval's TypeScript collector ordering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal, cast
|
|
10
|
+
|
|
11
|
+
from reeval_python.wire import options_to_wire
|
|
12
|
+
|
|
13
|
+
Mode = Literal["run", "skip", "only", "todo"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DeclaredFamily:
|
|
18
|
+
name: str
|
|
19
|
+
case: int
|
|
20
|
+
cases: int
|
|
21
|
+
|
|
22
|
+
def as_json(self) -> dict[str, Any]:
|
|
23
|
+
return {"name": self.name, "case": self.case, "cases": self.cases}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DeclaredEval:
|
|
28
|
+
name: str
|
|
29
|
+
mode: Mode
|
|
30
|
+
options: dict[str, Any]
|
|
31
|
+
body: str
|
|
32
|
+
family: DeclaredFamily | None = None
|
|
33
|
+
|
|
34
|
+
def as_json(self) -> dict[str, Any]:
|
|
35
|
+
out: dict[str, Any] = {
|
|
36
|
+
"type": "eval",
|
|
37
|
+
"name": self.name,
|
|
38
|
+
"mode": self.mode,
|
|
39
|
+
"options": self.options,
|
|
40
|
+
"body": self.body,
|
|
41
|
+
}
|
|
42
|
+
if self.family is not None:
|
|
43
|
+
out["family"] = self.family.as_json()
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class DeclaredGroup:
|
|
49
|
+
name: str
|
|
50
|
+
mode: Mode
|
|
51
|
+
options: dict[str, Any]
|
|
52
|
+
children: list[DeclNode] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
53
|
+
|
|
54
|
+
def as_json(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"type": "group",
|
|
57
|
+
"name": self.name,
|
|
58
|
+
"mode": self.mode,
|
|
59
|
+
"options": self.options,
|
|
60
|
+
"children": [c.as_json() for c in self.children],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class DeclaredTodo:
|
|
66
|
+
name: str
|
|
67
|
+
|
|
68
|
+
def as_json(self) -> dict[str, Any]:
|
|
69
|
+
return {"type": "todo", "name": self.name}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
DeclNode = DeclaredEval | DeclaredGroup | DeclaredTodo
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class _GroupFrame:
|
|
77
|
+
name: str
|
|
78
|
+
options: dict[str, Any]
|
|
79
|
+
node: DeclaredGroup
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _empty_suites() -> list[_Suite]:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class _Suite:
|
|
88
|
+
factory: Callable[[], None] | None = None
|
|
89
|
+
suites: list[_Suite] = field(default_factory=_empty_suites)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_tree_children: list[DeclNode] | None = None
|
|
93
|
+
_group_stack: list[_GroupFrame] = []
|
|
94
|
+
_suites: list[_Suite] = []
|
|
95
|
+
_bodies: dict[str, Callable[..., Any]] = {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def reset_collector() -> None:
|
|
99
|
+
global _tree_children, _group_stack, _suites, _bodies
|
|
100
|
+
_tree_children = []
|
|
101
|
+
_group_stack = []
|
|
102
|
+
_suites = [_Suite()]
|
|
103
|
+
_bodies = {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _next_body(fn: Callable[..., Any]) -> str:
|
|
107
|
+
body = f"b{len(_bodies) + 1}"
|
|
108
|
+
_bodies[body] = fn
|
|
109
|
+
return body
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _declare(node: DeclNode) -> None:
|
|
113
|
+
parent = _group_stack[-1].node if _group_stack else None
|
|
114
|
+
if parent is not None:
|
|
115
|
+
parent.children.append(node)
|
|
116
|
+
else:
|
|
117
|
+
assert _tree_children is not None
|
|
118
|
+
_tree_children.append(node)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _register_group(
|
|
122
|
+
name: str, mode: Mode, options: dict[str, Any], factory: Callable[[], None]
|
|
123
|
+
) -> None:
|
|
124
|
+
suite = _Suite(factory=factory)
|
|
125
|
+
_suites[-1].suites.append(suite)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _wire_options(options: dict[str, Any]) -> dict[str, Any]:
|
|
129
|
+
return options_to_wire(options) or {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def group(
|
|
133
|
+
name: str,
|
|
134
|
+
arg: Callable[[], None] | dict[str, Any] | None = None,
|
|
135
|
+
arg2: Callable[[], None] | None = None,
|
|
136
|
+
/,
|
|
137
|
+
*,
|
|
138
|
+
mode: Mode = "run",
|
|
139
|
+
) -> None:
|
|
140
|
+
if callable(arg) and arg2 is None:
|
|
141
|
+
fn, opts = arg, {}
|
|
142
|
+
elif isinstance(arg, dict) and callable(arg2):
|
|
143
|
+
opts, fn = dict[str, Any](cast(dict[str, Any], arg)), arg2
|
|
144
|
+
else:
|
|
145
|
+
raise TypeError("group(name, options, fn) or group(name, fn)")
|
|
146
|
+
|
|
147
|
+
node = DeclaredGroup(name=name, mode=mode, options=_wire_options(opts), children=[])
|
|
148
|
+
_declare(node)
|
|
149
|
+
frame = _GroupFrame(name=name, options=opts, node=node)
|
|
150
|
+
|
|
151
|
+
def factory() -> None:
|
|
152
|
+
outer = _group_stack.copy()
|
|
153
|
+
_group_stack.clear()
|
|
154
|
+
_group_stack.extend([*outer, frame])
|
|
155
|
+
try:
|
|
156
|
+
fn()
|
|
157
|
+
finally:
|
|
158
|
+
_group_stack.clear()
|
|
159
|
+
_group_stack.extend(outer)
|
|
160
|
+
|
|
161
|
+
_register_group(name, mode, opts, factory)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def evaluate(
|
|
165
|
+
name: str,
|
|
166
|
+
arg: Callable[..., Any] | dict[str, Any] | None = None,
|
|
167
|
+
arg2: Callable[..., Any] | None = None,
|
|
168
|
+
/,
|
|
169
|
+
*,
|
|
170
|
+
mode: Mode = "run",
|
|
171
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]] | None:
|
|
172
|
+
if callable(arg) and arg2 is None:
|
|
173
|
+
_register_eval(name, mode, {}, arg, None)
|
|
174
|
+
return arg
|
|
175
|
+
if isinstance(arg, dict) and callable(arg2):
|
|
176
|
+
_register_eval(name, mode, dict[str, Any](cast(dict[str, Any], arg)), arg2, None)
|
|
177
|
+
return arg2
|
|
178
|
+
|
|
179
|
+
opts = dict[str, Any](cast(dict[str, Any], arg)) if isinstance(arg, dict) else {}
|
|
180
|
+
|
|
181
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
182
|
+
_register_eval(name, mode, opts, fn, None)
|
|
183
|
+
return fn
|
|
184
|
+
|
|
185
|
+
return decorator
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def evaluate_each(cases: Sequence[Any], name_template: str, options: dict[str, Any] | None = None):
|
|
189
|
+
opts = dict[str, Any](options or {})
|
|
190
|
+
|
|
191
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
192
|
+
for index, case in enumerate(cases):
|
|
193
|
+
family = DeclaredFamily(name=name_template, case=index, cases=len(cases))
|
|
194
|
+
case_value: Any = case
|
|
195
|
+
if isinstance(case_value, dict) and "n" in case_value:
|
|
196
|
+
display = name_template.replace("$n", str(cast(Any, case_value["n"])))
|
|
197
|
+
else:
|
|
198
|
+
display = name_template.replace("$n", str(index + 1)).replace("%#", str(index))
|
|
199
|
+
_register_eval(display, "run", opts, fn, family)
|
|
200
|
+
return fn
|
|
201
|
+
|
|
202
|
+
return decorator
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _register_eval(
|
|
206
|
+
name: str,
|
|
207
|
+
mode: Mode,
|
|
208
|
+
options: dict[str, Any],
|
|
209
|
+
fn: Callable[..., Any],
|
|
210
|
+
family: DeclaredFamily | None,
|
|
211
|
+
) -> None:
|
|
212
|
+
body = _next_body(fn)
|
|
213
|
+
_declare(
|
|
214
|
+
DeclaredEval(
|
|
215
|
+
name=name,
|
|
216
|
+
mode=mode,
|
|
217
|
+
options=_wire_options(options),
|
|
218
|
+
body=body,
|
|
219
|
+
family=family,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _collect_suite_sync(suite: _Suite) -> None:
|
|
225
|
+
if suite.factory is not None:
|
|
226
|
+
suite.factory()
|
|
227
|
+
for nested in suite.suites:
|
|
228
|
+
_collect_suite_sync(nested)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _tree_after_loader(loader: Callable[[], None]) -> dict[str, Any]:
|
|
232
|
+
reset_collector()
|
|
233
|
+
loader()
|
|
234
|
+
_collect_suite_sync(_suites[0])
|
|
235
|
+
assert _tree_children is not None
|
|
236
|
+
return {"children": [c.as_json() for c in _tree_children]}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def collect_module_tree(loader: Callable[[], None]) -> dict[str, Any]:
|
|
240
|
+
return _tree_after_loader(loader)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def collect_module_tree_sync(loader: Callable[[], None]) -> dict[str, Any]:
|
|
244
|
+
return _tree_after_loader(loader)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_body(body_id: str) -> Callable[..., Any]:
|
|
248
|
+
if body_id not in _bodies:
|
|
249
|
+
raise KeyError(f"unknown eval body {body_id}")
|
|
250
|
+
return _bodies[body_id]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def collect_eval_file(path: Path) -> dict[str, Any]:
|
|
254
|
+
"""Import one *.eval.py file and return its declaration tree JSON."""
|
|
255
|
+
|
|
256
|
+
def loader() -> None:
|
|
257
|
+
spec = importlib.util.spec_from_file_location("reeval_eval", path)
|
|
258
|
+
if spec is None or spec.loader is None:
|
|
259
|
+
raise ImportError(f"can't load {path}")
|
|
260
|
+
module = importlib.util.module_from_spec(spec)
|
|
261
|
+
spec.loader.exec_module(module)
|
|
262
|
+
|
|
263
|
+
return collect_module_tree_sync(loader)
|