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.
@@ -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,4 @@
1
+ from reeval_python.runner import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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)
@@ -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)
@@ -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)
@@ -0,0 +1,7 @@
1
+ """Errors raised by the public Python API."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ReevalAssertionError(AssertionError):
7
+ """An expect matcher failed (mirrors reeval's assertion record message)."""