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,95 @@
1
+ """Fluent expect() API; maps to wire matchers internally."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+ from typing import Any
7
+
8
+ from reeval_python.wire import MATCHER_TO_WIRE, subject_for_matcher, wire_args
9
+
10
+ SubmitFn = Any
11
+
12
+ _submit: ContextVar[SubmitFn | None] = ContextVar("_submit", default=None)
13
+
14
+
15
+ def bind_expect(submit: SubmitFn) -> None:
16
+ _submit.set(submit)
17
+
18
+
19
+ def expect(actual: Any, label: str | None = None) -> _Expectation:
20
+ submit = _submit.get()
21
+ if submit is None:
22
+ raise RuntimeError("expect() must run inside an eval body")
23
+ return _Expectation(actual, submit, label=label)
24
+
25
+
26
+ class _Expectation:
27
+ def __init__(
28
+ self,
29
+ actual: Any,
30
+ submit: SubmitFn,
31
+ *,
32
+ label: str | None = None,
33
+ not_: bool = False,
34
+ soft: bool = False,
35
+ ) -> None:
36
+ self._actual = actual
37
+ self._submit = submit
38
+ self._label = label
39
+ self._not = not_
40
+ self._soft = soft
41
+ self._sync = bool(getattr(submit, "_sync_mode", False))
42
+
43
+ @property
44
+ def not_(self) -> _Expectation:
45
+ return _Expectation(
46
+ self._actual,
47
+ self._submit,
48
+ label=self._label,
49
+ not_=not self._not,
50
+ soft=self._soft,
51
+ )
52
+
53
+ def __getattr__(self, name: str) -> Any:
54
+ if not name.startswith("to_"):
55
+ raise AttributeError(name)
56
+ wire = MATCHER_TO_WIRE.get(name)
57
+ if wire is None:
58
+ raise AttributeError(f"unknown matcher {name}")
59
+
60
+ async def _async_matcher(*args: Any) -> None:
61
+ subject = subject_for_matcher(self._actual, wire)
62
+ await self._submit(
63
+ subject=subject,
64
+ matcher=wire,
65
+ args=wire_args(args),
66
+ label=self._label,
67
+ not_=self._not,
68
+ soft=self._soft,
69
+ )
70
+
71
+ def _sync_matcher(*args: Any) -> None:
72
+ subject = subject_for_matcher(self._actual, wire)
73
+ self._submit(
74
+ subject=subject,
75
+ matcher=wire,
76
+ args=wire_args(args),
77
+ label=self._label,
78
+ not_=self._not,
79
+ soft=self._soft,
80
+ )
81
+
82
+ return _sync_matcher if self._sync else _async_matcher
83
+
84
+
85
+ class soft: # noqa: N801
86
+ """expect.soft(subject, label?) — soft assertion."""
87
+
88
+ def __init__(self, actual: Any, label: str | None = None) -> None:
89
+ submit = _submit.get()
90
+ if submit is None:
91
+ raise RuntimeError("expect.soft() must run inside an eval body")
92
+ self._inner = _Expectation(actual, submit, label=label, soft=True)
93
+
94
+ def __getattr__(self, name: str) -> Any:
95
+ return getattr(self._inner, name)
reeval_python/py.typed ADDED
File without changes
@@ -0,0 +1,190 @@
1
+ """Out-of-process runner: collect declaration trees and run eval bodies over attempt protocol v2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import os
9
+ import sys
10
+ import traceback
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from reeval_python.context import AttemptContext, SyncAttemptContext
16
+ from reeval_python.declare import collect_eval_file, get_body
17
+ from reeval_python.errors import ReevalAssertionError
18
+
19
+ RUNNER_PROTOCOL = 2
20
+
21
+
22
+ def sdk_version() -> str:
23
+ try:
24
+ return version("reeval-python")
25
+ except PackageNotFoundError:
26
+ return "0.0.0"
27
+
28
+
29
+ class _Connection:
30
+ def __init__(self, writer: asyncio.StreamWriter) -> None:
31
+ self._writer = writer
32
+ self._pending: dict[tuple[str, str], asyncio.Future[dict[str, Any]]] = {}
33
+
34
+ def write(self, msg: dict[str, Any]) -> None:
35
+ self._writer.write((json.dumps(msg) + "\n").encode())
36
+
37
+ async def drain(self) -> None:
38
+ await self._writer.drain()
39
+
40
+ def complete_reply(self, msg: dict[str, Any]) -> None:
41
+ attempt = msg.get("attempt")
42
+ req_id = msg.get("id")
43
+ if attempt is None or req_id is None:
44
+ return
45
+ key = (str(attempt), str(req_id))
46
+ fut = self._pending.pop(key, None)
47
+ if fut is not None and not fut.done():
48
+ fut.set_result(msg)
49
+
50
+ async def request(self, attempt_id: str, req_id: str, call: dict[str, Any]) -> dict[str, Any]:
51
+ key = (attempt_id, req_id)
52
+ fut = asyncio.get_running_loop().create_future()
53
+ self._pending[key] = fut
54
+ self.write({"id": req_id, "call": call, "attempt": attempt_id})
55
+ await self.drain()
56
+ return await fut
57
+
58
+
59
+ async def _run_body(conn: _Connection, attempt_id: str, body_id: str) -> None:
60
+ fn = get_body(body_id)
61
+ seq = 0
62
+
63
+ async def send_request(message: dict[str, Any]) -> dict[str, Any]:
64
+ nonlocal seq
65
+ seq += 1
66
+ req_id = str(seq)
67
+ return await conn.request(attempt_id, req_id, message["call"])
68
+
69
+ loop = asyncio.get_running_loop()
70
+ async_ctx = AttemptContext(attempt_id, send_request)
71
+ error: dict[str, Any] | None = None
72
+
73
+ async def run_async_body() -> None:
74
+ async_ctx.bind_expect()
75
+ await fn(async_ctx)
76
+
77
+ async def run_sync_body() -> None:
78
+ sync_ctx = SyncAttemptContext(async_ctx, loop)
79
+ sync_ctx.bind_expect()
80
+ await asyncio.to_thread(fn, sync_ctx)
81
+
82
+ try:
83
+ if inspect.iscoroutinefunction(fn):
84
+ await run_async_body()
85
+ else:
86
+ await run_sync_body()
87
+ except ReevalAssertionError as exc:
88
+ error = {
89
+ "name": "AssertionError",
90
+ "message": str(exc),
91
+ "stack": traceback.format_exc(),
92
+ }
93
+ except AssertionError as exc:
94
+ try:
95
+ await async_ctx.record_plain_assert(exc)
96
+ except ReevalAssertionError as recorded:
97
+ error = {
98
+ "name": "AssertionError",
99
+ "message": str(recorded),
100
+ "stack": traceback.format_exc(),
101
+ }
102
+ except AssertionError as nested:
103
+ error = {
104
+ "name": "AssertionError",
105
+ "message": str(nested),
106
+ "stack": traceback.format_exc(),
107
+ }
108
+ except Exception as exc: # noqa: BLE001
109
+ error = {"name": type(exc).__name__, "message": str(exc), "stack": traceback.format_exc()}
110
+ conn.write({"type": "done", "attempt": attempt_id, **({"error": error} if error else {})})
111
+ await conn.drain()
112
+
113
+
114
+ async def serve() -> None:
115
+ host_port = os.environ.get("REEVAL_RUNNER", "")
116
+ token = os.environ.get("REEVAL_RUNNER_TOKEN", "")
117
+ root = Path(os.environ.get("REEVAL_PROJECT_ROOT", os.getcwd()))
118
+ if ":" not in host_port or not token:
119
+ print("This is reeval's Python runner; run evals with reeval run.", file=sys.stderr)
120
+ sys.exit(2)
121
+
122
+ host, port_str = host_port.rsplit(":", 1)
123
+ reader, writer = await asyncio.open_connection(host, int(port_str))
124
+ conn = _Connection(writer)
125
+
126
+ hello = {
127
+ "t": "hello",
128
+ "protocol": RUNNER_PROTOCOL,
129
+ "token": token,
130
+ "sdk": "python",
131
+ "version": sdk_version(),
132
+ "runtime": sys.version.split()[0],
133
+ }
134
+ writer.write((json.dumps(hello) + "\n").encode())
135
+ await writer.drain()
136
+
137
+ tasks: set[asyncio.Task[None]] = set()
138
+ buf = b""
139
+ while True:
140
+ chunk = await reader.read(65536)
141
+ if chunk == b"":
142
+ break
143
+ buf += chunk
144
+ while True:
145
+ newline = buf.find(b"\n")
146
+ if newline < 0:
147
+ break
148
+ line = buf[:newline]
149
+ buf = buf[newline + 1 :]
150
+ if not line.strip():
151
+ continue
152
+ msg = cast(dict[str, Any], json.loads(line.decode()))
153
+ if msg.get("t") == "collect":
154
+ rel = str(msg["file"])
155
+ try:
156
+ tree = collect_eval_file(root / rel)
157
+ conn.write({"t": "declared", "file": rel, "tree": tree})
158
+ except Exception as exc: # noqa: BLE001
159
+ conn.write(
160
+ {
161
+ "t": "load-error",
162
+ "error": {
163
+ "name": type(exc).__name__,
164
+ "message": str(exc),
165
+ "stack": traceback.format_exc(),
166
+ },
167
+ }
168
+ )
169
+ await conn.drain()
170
+ elif msg.get("type") == "start":
171
+ attempt_id = str(msg["attempt"]["id"])
172
+ body_id = str(msg["body"])
173
+ task = asyncio.create_task(_run_body(conn, attempt_id, body_id))
174
+ tasks.add(task)
175
+ task.add_done_callback(tasks.discard)
176
+ elif msg.get("type") == "abort":
177
+ for task in list(tasks):
178
+ task.cancel()
179
+ elif "id" in msg and "ok" in msg:
180
+ conn.complete_reply(msg)
181
+
182
+ writer.close()
183
+
184
+
185
+ def main() -> None:
186
+ asyncio.run(serve())
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
reeval_python/types.py ADDED
@@ -0,0 +1,97 @@
1
+ """Typed results for attempt calls (public, not wire-shaped)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal, cast
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ExecResult:
11
+ command: str
12
+ exit_code: int
13
+ stdout: str
14
+ stderr: str
15
+
16
+ @property
17
+ def ok(self) -> bool:
18
+ return self.exit_code == 0
19
+
20
+ @classmethod
21
+ def from_wire(cls, command: str, data: dict[str, Any]) -> ExecResult:
22
+ return cls(
23
+ command=command,
24
+ exit_code=int(data.get("exitCode", data.get("exit_code", 1))),
25
+ stdout=str(data.get("stdout") or ""),
26
+ stderr=str(data.get("stderr") or ""),
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class BackgroundHandle:
32
+ id: str
33
+
34
+ @classmethod
35
+ def from_wire(cls, data: dict[str, Any]) -> BackgroundHandle:
36
+ return cls(id=str(data.get("handle") or data.get("id") or ""))
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Turn:
41
+ index: int
42
+ raw: dict[str, Any]
43
+
44
+ @property
45
+ def prompt(self) -> str:
46
+ return str(self.raw.get("prompt") or "")
47
+
48
+ @property
49
+ def text(self) -> str:
50
+ return str(self.raw.get("text") or "")
51
+
52
+ @property
53
+ def status(self) -> str:
54
+ return str(self.raw.get("status") or "")
55
+
56
+ @property
57
+ def diff(self) -> dict[str, Any] | None:
58
+ diff = self.raw.get("diff")
59
+ if isinstance(diff, dict):
60
+ return cast(dict[str, Any], diff)
61
+ return None
62
+
63
+
64
+ VerdictKind = Literal["pass", "fail", "error"]
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class Verdict:
69
+ verdict: VerdictKind
70
+ score: float
71
+ threshold: float
72
+ question: str
73
+ model: str
74
+ raw: dict[str, Any]
75
+
76
+ @property
77
+ def passed(self) -> bool:
78
+ return self.verdict == "pass"
79
+
80
+ @property
81
+ def reason(self) -> str | None:
82
+ for key in ("reason", "explanation", "rationale"):
83
+ value = self.raw.get(key)
84
+ if isinstance(value, str) and value:
85
+ return value
86
+ return None
87
+
88
+ @classmethod
89
+ def from_wire(cls, data: dict[str, Any]) -> Verdict:
90
+ return cls(
91
+ verdict=data.get("verdict", "fail"), # type: ignore[arg-type]
92
+ score=float(data.get("score", 0)),
93
+ threshold=float(data.get("threshold", 0)),
94
+ question=str(data.get("question") or ""),
95
+ model=str(data.get("model") or ""),
96
+ raw=dict(data),
97
+ )
reeval_python/wire.py ADDED
@@ -0,0 +1,81 @@
1
+ """Map public Python values to attempt-protocol wire shapes (internal)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, cast
7
+
8
+ from reeval_python.types import ExecResult, Turn, Verdict
9
+
10
+ # Python snake_case matcher method -> wire matcher name (Vitest + reeval table).
11
+ MATCHER_TO_WIRE: dict[str, str] = {
12
+ "to_succeed": "toSucceed",
13
+ "to_pass": "toPass",
14
+ "to_equal": "toEqual",
15
+ "to_contain": "toContain",
16
+ "to_be_truthy": "toBeTruthy",
17
+ "to_be": "toBe",
18
+ "to_be_null": "toBeNull",
19
+ "to_have_file": "toHaveFile",
20
+ "to_have_edited": "toHaveEdited",
21
+ "to_have_run": "toHaveRun",
22
+ "to_have_searched": "toHaveSearched",
23
+ "to_have_fetched": "toHaveFetched",
24
+ "to_have_called_skill": "toHaveCalledSkill",
25
+ "to_have_called_mcp": "toHaveCalledMcp",
26
+ "to_have_called_tool": "toHaveCalledTool",
27
+ "to_have_changed": "toHaveChanged",
28
+ }
29
+
30
+ # Judge / evaluate options: snake_case kwargs -> camelCase keys on the wire.
31
+ OPTION_KEY_TO_WIRE: dict[str, str] = {
32
+ "max_retries": "maxRetries",
33
+ "max_tokens": "maxTokens",
34
+ "read_only": "readOnly",
35
+ }
36
+
37
+
38
+ def options_to_wire(options: dict[str, Any] | None) -> dict[str, Any] | None:
39
+ if not options:
40
+ return None
41
+ out: dict[str, Any] = {}
42
+ for key, value in options.items():
43
+ wire_key = OPTION_KEY_TO_WIRE.get(key, key)
44
+ out[wire_key] = value
45
+ return out
46
+
47
+
48
+ def _pattern_arg(value: re.Pattern[str]) -> Any:
49
+ return {"regexp": value.pattern, "flags": value.flags and int(value.flags) or None}
50
+
51
+
52
+ def wire_args(args: tuple[Any, ...]) -> list[Any]:
53
+ out: list[Any] = []
54
+ for a in args:
55
+ if isinstance(a, re.Pattern):
56
+ out.append(_pattern_arg(cast(re.Pattern[str], a)))
57
+ else:
58
+ out.append(a)
59
+ return out
60
+
61
+
62
+ def subject_for_matcher(actual: Any, matcher: str) -> dict[str, Any]:
63
+ if isinstance(actual, ExecResult) and matcher == "toSucceed":
64
+ return {"exec": actual.command}
65
+ if isinstance(actual, ExecResult):
66
+ return {
67
+ "value": {
68
+ "exitCode": actual.exit_code,
69
+ "stdout": actual.stdout,
70
+ "stderr": actual.stderr,
71
+ }
72
+ }
73
+ if isinstance(actual, Verdict):
74
+ return {"value": actual.raw}
75
+ if isinstance(actual, Turn):
76
+ return {"turn": actual.index}
77
+ if isinstance(actual, dict):
78
+ wire_keys = ("exec", "turn", "attempt", "readFile", "value")
79
+ if any(k in actual for k in wire_keys):
80
+ return cast(dict[str, Any], actual)
81
+ return {"value": actual}
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.5
2
+ Name: reeval-python
3
+ Version: 0.10.0
4
+ Summary: Python authoring SDK for reeval (*.eval.py eval files)
5
+ Project-URL: Homepage, https://reeval.ai
6
+ Project-URL: Repository, https://github.com/invertase/reeval
7
+ Project-URL: Documentation, https://github.com/invertase/reeval/tree/main/packages/reeval-python#readme
8
+ Project-URL: Issues, https://github.com/invertase/reeval/issues
9
+ Author: Invertase
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ # reeval-python
25
+
26
+ Python authoring SDK for [reeval](https://github.com/invertase/reeval). Write `*.eval.py` files,
27
+ declare evals with the same options as TypeScript, and run them through reeval's language runner
28
+ (loopback TCP, attempt protocol v2).
29
+
30
+ Import as `reeval_python` (`from reeval_python import evaluate`). The PyPI distribution is
31
+ **reeval-python** (the name `reeval` is already taken on PyPI by another project).
32
+
33
+ With reeval installed, default discovery includes `evals/**/*.eval.py`. When your project has
34
+ `pyproject.toml` or `uv.lock` and [uv](https://docs.astral.sh/uv/) is on `PATH`, reeval starts the
35
+ runner with `uv run --project <root> python -m reeval_python`. Otherwise it uses `<root>/.venv/bin/python`
36
+ when present, then `python3`. Set `REEVAL_PYTHON` to override the interpreter.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uv add reeval-python
42
+ # or: pip install reeval-python
43
+ ```
44
+
45
+ Monorepo development:
46
+
47
+ ```bash
48
+ cd packages/reeval-python
49
+ uv sync --dev
50
+ uv run pytest
51
+ uv run ruff check .
52
+ uv run pyright
53
+ ```
54
+
55
+ Declare trees are checked against `packages/reeval/test/conformance/declare/` goldens. From the
56
+ reeval package, `test/python-sdk.test.ts` runs the same suite in CI.
57
+
58
+ ## Writing evals
59
+
60
+ ```python
61
+ from reeval_python import evaluate, expect
62
+
63
+
64
+ @evaluate("lands")
65
+ def lands(c):
66
+ c.send("Create greet.py that prints hi")
67
+ assert c.exec("test -f greet.py").ok
68
+ expect(c.judge("Does greet.py print hi?")).to_pass()
69
+ ```
70
+
71
+ Plain `assert` is first-class: failures become hard checks in the run record (same shape as
72
+ TypeScript). Use typed results (`ExecResult.ok`, `Verdict.passed`, `Turn.text`, …) and `c.file(path)`
73
+ for quick checks. Keep `expect` for reeval matchers (`to_have_file`, `to_succeed`, soft checks,
74
+ labels) — Playwright-style.
75
+
76
+ Use snake_case on the context (`read_file`, `write_file`, `file`) and on matchers (`to_succeed`,
77
+ `to_pass`, `to_equal`, `to_contain`, …). Matcher failures raise `ReevalAssertionError`. Async bodies use
78
+ `async def`, `await c.send(...)`, and `await expect(await c.exec(...)).to_succeed()`; sync bodies
79
+ use plain `def` and block on the runner loop.
80
+
81
+ ## Examples
82
+
83
+ - Feature smoke: `packages/reeval/test/features/python-sdk/hello/` (uv project with a project dependency)
84
+ - Plan case: `packages/reeval/test/plan/python/`
85
+ - Contributor guide: `.agents/skills/add-a-language/SKILL.md` in the reeval repo
@@ -0,0 +1,15 @@
1
+ reeval_python/__init__.py,sha256=Mzv45KSuKaWmcKO7yZ-jzk3DcnYDrvzavbxZzJsf6AM,655
2
+ reeval_python/__main__.py,sha256=0AezY7FdEV8RqwkKhR0twWdvhaiJkt02ps7U1TEujzs,77
3
+ reeval_python/assertion.py,sha256=AnRa_8KAWBw-RHnZFG2NuO2qimLvGS3ZuxsBq79Oqv8,5077
4
+ reeval_python/context.py,sha256=i0__DQIZ0KBCqRgbhjjZ-VZGd_9O5NBWIPYRQT2grQA,5818
5
+ reeval_python/declare.py,sha256=7ceRmccd2GrvX_yVQfqfzU5o3W4v3_SjEwQvkEgE8OY,7090
6
+ reeval_python/errors.py,sha256=q6xm8zwEzS4LewZf1h5oKYfnXHc2SvuKghew0ryia4A,208
7
+ reeval_python/expect.py,sha256=giZTP3h2xavXow2GOe4N9QaxLXG1wV6IeblA0Otaym0,2777
8
+ reeval_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ reeval_python/runner.py,sha256=-9-QSh5cPr-xCbYLEoa2D4CtVWT7z1CCam4mnMeEx4Q,6230
10
+ reeval_python/types.py,sha256=qcZA1u0c7uxu4hI5tATXylH0ru0_V65QuWja7B3UeXI,2453
11
+ reeval_python/wire.py,sha256=ECX1t8ETFigEOFHGhXmSn7YnYYU-27tNCJMyh-eUJG4,2564
12
+ reeval_python-0.10.0.dist-info/METADATA,sha256=vVKz67mwpgt_x3KKjFFrSZY4KF7m6zUFfXBd0ldArTA,3308
13
+ reeval_python-0.10.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ reeval_python-0.10.0.dist-info/licenses/LICENSE,sha256=ksfhGdGZnBIw2o77d0o71LHzgK-bRGUx1qS6nzYMjK4,11348
15
+ reeval_python-0.10.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any