oneharness-sdk 0.4.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,35 @@
1
+ """Async Python SDK for the oneharness CLI."""
2
+
3
+ from ._client import OneHarness
4
+ from ._errors import ContractError, HistoryNotFoundError, OneHarnessProcessError
5
+ from ._generated_types import (
6
+ Detection,
7
+ HarnessInfo,
8
+ HistoryListOptions,
9
+ HistoryLookup,
10
+ HistoryRecord,
11
+ HistoryStreamEnvelope,
12
+ HistoryWatchOptions,
13
+ RunOptions,
14
+ RunReport,
15
+ RunStreamEnvelope,
16
+ )
17
+ from ._version import __version__
18
+
19
+ __all__ = [
20
+ "ContractError",
21
+ "Detection",
22
+ "HarnessInfo",
23
+ "HistoryListOptions",
24
+ "HistoryLookup",
25
+ "HistoryNotFoundError",
26
+ "HistoryRecord",
27
+ "HistoryStreamEnvelope",
28
+ "HistoryWatchOptions",
29
+ "OneHarness",
30
+ "OneHarnessProcessError",
31
+ "RunOptions",
32
+ "RunReport",
33
+ "RunStreamEnvelope",
34
+ "__version__",
35
+ ]
@@ -0,0 +1,332 @@
1
+ """Async subprocess client for the oneharness JSON and JSONL interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import builtins
7
+ import json
8
+ import os
9
+ import shutil
10
+ from collections.abc import AsyncIterator, Mapping, Sequence
11
+ from functools import cache
12
+ from pathlib import Path
13
+ from typing import Any, Optional, cast
14
+
15
+ from jsonschema import Draft202012Validator
16
+ from jsonschema.protocols import Validator
17
+
18
+ from ._errors import ContractError, HistoryNotFoundError, OneHarnessProcessError
19
+ from ._generated_types import (
20
+ Detection,
21
+ HarnessInfo,
22
+ HistoryListOptions,
23
+ HistoryLookup,
24
+ HistoryRecord,
25
+ HistoryStreamEnvelope,
26
+ HistoryWatchOptions,
27
+ RunOptions,
28
+ RunReport,
29
+ RunStreamEnvelope,
30
+ )
31
+
32
+ _STREAM_LIMIT = 16 * 1024 * 1024
33
+ _INPUT_ROOTS = {
34
+ "history_list_options",
35
+ "history_lookup",
36
+ "history_watch_options",
37
+ "run_options",
38
+ }
39
+
40
+
41
+ def _load_json(name: str) -> dict[str, Any]:
42
+ path = Path(__file__).with_name("_generated") / name
43
+ return cast("dict[str, Any]", json.loads(path.read_text(encoding="utf-8")))
44
+
45
+
46
+ _SCHEMAS = _load_json("schemas.json")
47
+ _INPUT_KEYS = cast("dict[str, dict[str, str]]", _load_json("input-keys.json"))
48
+
49
+
50
+ @cache
51
+ def _validator(root: str) -> Validator:
52
+ schema = _SCHEMAS[root]
53
+ Draft202012Validator.check_schema(schema)
54
+ return Draft202012Validator(schema)
55
+
56
+
57
+ def _validate(root: str, value: Any, label: str) -> Any:
58
+ errors = sorted(_validator(root).iter_errors(value), key=lambda error: list(error.path))
59
+ if not errors:
60
+ return value
61
+ details = []
62
+ for error in errors:
63
+ path = ".".join(str(part) for part in error.absolute_path) or "<root>"
64
+ details.append(f"{path}: {error.message}")
65
+ raise ContractError(f"{label}: {'; '.join(details)}")
66
+
67
+
68
+ def _input(root: str, value: Any, label: str) -> dict[str, Any]:
69
+ if root not in _INPUT_ROOTS: # pragma: no cover - internal programming guard
70
+ raise AssertionError(f"{root} is not an input schema")
71
+ checked = cast("Mapping[str, Any]", _validate(root, value, label))
72
+ keys = _INPUT_KEYS[root]
73
+ return {keys.get(key, key): item for key, item in checked.items()}
74
+
75
+
76
+ def _many(args: list[str], flag: str, values: Optional[Sequence[str]]) -> None:
77
+ for value in values or ():
78
+ args.extend((flag, value))
79
+
80
+
81
+ def _run_arguments(options: Mapping[str, Any], *, stream: bool) -> list[str]:
82
+ args = ["run", "--prompt", cast("str", options["prompt"]), "--compact"]
83
+ _many(args, "--harness", cast("Optional[Sequence[str]]", options.get("harnesses")))
84
+ _many(args, "--model", cast("Optional[Sequence[str]]", options.get("models")))
85
+ scalar_flags = {
86
+ "system": "--system",
87
+ "reasoning": "--reasoning",
88
+ "resume": "--resume",
89
+ "session": "--session",
90
+ "mode": "--mode",
91
+ "historyName": "--history-name",
92
+ "historyDir": "--history-dir",
93
+ }
94
+ for key, flag in scalar_flags.items():
95
+ if key in options:
96
+ args.extend((flag, cast("str", options[key])))
97
+ if options.get("fork"):
98
+ args.append("--fork")
99
+ if "timeoutSeconds" in options:
100
+ args.extend(("--timeout", str(options["timeoutSeconds"])))
101
+ if options.get("events"):
102
+ args.append("--events")
103
+ if stream:
104
+ args.append("--stream")
105
+ if options.get("history"):
106
+ args.append("--history")
107
+ for key, value in cast("Mapping[str, str]", options.get("historyLabels", {})).items():
108
+ args.extend(("--history-label", f"{key}={value}"))
109
+ for key, value in cast("Mapping[str, str]", options.get("env", {})).items():
110
+ args.extend(("--env", f"{key}={value}"))
111
+ for key, value in cast("Mapping[str, str]", options.get("bins", {})).items():
112
+ args.extend(("--bin", f"{key}={value}"))
113
+ return args
114
+
115
+
116
+ async def _terminate(process: asyncio.subprocess.Process) -> None:
117
+ if process.returncode is not None:
118
+ return
119
+ try:
120
+ process.terminate()
121
+ except ProcessLookupError: # pragma: no cover - OS race after returncode check
122
+ return
123
+ try:
124
+ await asyncio.wait_for(process.wait(), timeout=2)
125
+ except asyncio.TimeoutError: # pragma: no cover - defensive hard-kill fallback
126
+ process.kill()
127
+ await process.wait()
128
+
129
+
130
+ def _process_error(returncode: int, stderr: str, *, history: bool) -> OneHarnessProcessError:
131
+ if history and (returncode == 1 or "was not found" in stderr):
132
+ return HistoryNotFoundError(returncode, stderr)
133
+ return OneHarnessProcessError(returncode, stderr)
134
+
135
+
136
+ class OneHarness:
137
+ """Validated async access to an installed oneharness CLI."""
138
+
139
+ def __init__(
140
+ self,
141
+ *,
142
+ executable: Optional[str] = None,
143
+ executable_args: Sequence[str] = (),
144
+ env: Optional[Mapping[str, str]] = None,
145
+ ) -> None:
146
+ self._executable = executable
147
+ self._executable_args = tuple(executable_args)
148
+ self._env = dict(env or {})
149
+
150
+ def _command(self, args: Sequence[str]) -> tuple[str, ...]:
151
+ command = self._executable or os.environ.get("ONEHARNESS_BIN")
152
+ if command is None:
153
+ command = shutil.which("oneharness") or "oneharness"
154
+ return (command, *self._executable_args, *args)
155
+
156
+ async def _spawn(
157
+ self, args: Sequence[str], cwd: Optional[str] = None
158
+ ) -> asyncio.subprocess.Process:
159
+ return await asyncio.create_subprocess_exec(
160
+ *self._command(args),
161
+ cwd=cwd,
162
+ env={**os.environ, **self._env},
163
+ stdout=asyncio.subprocess.PIPE,
164
+ stderr=asyncio.subprocess.PIPE,
165
+ limit=_STREAM_LIMIT,
166
+ )
167
+
168
+ async def _invoke(
169
+ self,
170
+ args: Sequence[str],
171
+ *,
172
+ cwd: Optional[str] = None,
173
+ accept_json_on_nonzero: bool = False,
174
+ history: bool = False,
175
+ ) -> Any:
176
+ process = await self._spawn(args, cwd)
177
+ try:
178
+ stdout_bytes, stderr_bytes = await process.communicate()
179
+ except BaseException:
180
+ await _terminate(process)
181
+ raise
182
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
183
+ if process.returncode != 0 and not accept_json_on_nonzero:
184
+ raise _process_error(process.returncode or 0, stderr, history=history)
185
+ try:
186
+ return json.loads(stdout_bytes)
187
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
188
+ if process.returncode != 0:
189
+ raise _process_error(process.returncode or 0, stderr, history=history) from error
190
+ raise ContractError(f"oneharness returned invalid JSON: {error}") from error
191
+
192
+ async def _stream(
193
+ self,
194
+ args: Sequence[str],
195
+ root: str,
196
+ label: str,
197
+ *,
198
+ cwd: Optional[str] = None,
199
+ history: bool = False,
200
+ ) -> AsyncIterator[dict[str, Any]]:
201
+ process = await self._spawn(args, cwd)
202
+ if process.stdout is None or process.stderr is None: # pragma: no cover - PIPE invariant
203
+ await _terminate(process)
204
+ raise RuntimeError("oneharness stream pipes were not created")
205
+ stderr_task = asyncio.create_task(process.stderr.read())
206
+ try:
207
+ while line := await process.stdout.readline():
208
+ if not line.strip():
209
+ continue
210
+ try:
211
+ value = json.loads(line)
212
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
213
+ raise ContractError(f"{label}: invalid JSON: {error}") from error
214
+ yield cast("dict[str, Any]", _validate(root, value, label))
215
+ returncode = await process.wait()
216
+ stderr = (await stderr_task).decode("utf-8", errors="replace")
217
+ if returncode != 0:
218
+ raise _process_error(returncode, stderr, history=history)
219
+ finally:
220
+ await _terminate(process)
221
+ if not stderr_task.done():
222
+ stderr_task.cancel()
223
+ await asyncio.gather(stderr_task, return_exceptions=True)
224
+
225
+ async def run(self, options: RunOptions) -> RunReport:
226
+ """Run one prompt and return a validated report."""
227
+ parsed = _input("run_options", options, "invalid oneharness run options")
228
+ value = await self._invoke(
229
+ _run_arguments(parsed, stream=False),
230
+ cwd=cast("Optional[str]", parsed.get("cwd")),
231
+ accept_json_on_nonzero=True,
232
+ )
233
+ return cast("RunReport", _validate("run_report", value, "invalid oneharness run contract"))
234
+
235
+ def run_stream(self, options: RunOptions) -> AsyncIterator[RunStreamEnvelope]:
236
+ """Yield validated action/result envelopes as the harness runs."""
237
+ parsed = _input("run_options", options, "invalid oneharness run options")
238
+ return self._stream(
239
+ _run_arguments(parsed, stream=True),
240
+ "run_stream_envelope",
241
+ "invalid oneharness run stream contract",
242
+ cwd=cast("Optional[str]", parsed.get("cwd")),
243
+ )
244
+
245
+ async def list(self) -> builtins.list[HarnessInfo]:
246
+ """Return the validated harness registry."""
247
+ value = _validate(
248
+ "list_report",
249
+ await self._invoke(("list", "--compact")),
250
+ "invalid oneharness list contract",
251
+ )
252
+ return cast("builtins.list[HarnessInfo]", value["harnesses"])
253
+
254
+ async def detect(self, harnesses: Sequence[str] = ()) -> builtins.list[Detection]:
255
+ """Probe selected harness binaries."""
256
+ if isinstance(harnesses, (str, bytes)) or not all(
257
+ isinstance(harness, str) for harness in harnesses
258
+ ):
259
+ raise ContractError("invalid oneharness detect options: harnesses must be strings")
260
+ args = ["detect", "--compact"]
261
+ _many(args, "--harness", harnesses)
262
+ value = _validate(
263
+ "detect_report",
264
+ await self._invoke(args),
265
+ "invalid oneharness detect contract",
266
+ )
267
+ return cast("builtins.list[Detection]", value["detected"])
268
+
269
+ async def history(self, lookup: HistoryLookup) -> builtins.list[HistoryRecord]:
270
+ """Resolve one history record or session."""
271
+ parsed = _input("history_lookup", lookup, "invalid oneharness history options")
272
+ args = ["history", "show", "--compact"]
273
+ if parsed.get("last") is True:
274
+ args.append("--last")
275
+ else:
276
+ args.append(cast("str", parsed["session"]))
277
+ if parsed.get("project"):
278
+ args.extend(("--project", cast("str", parsed["project"])))
279
+ if parsed.get("allProjects"):
280
+ args.append("--all-projects")
281
+ if parsed.get("historyDir"):
282
+ args.extend(("--history-dir", cast("str", parsed["historyDir"])))
283
+ value = await self._invoke(args, history=True)
284
+ return cast(
285
+ "builtins.list[HistoryRecord]",
286
+ _validate("history_records", value, "invalid oneharness history contract"),
287
+ )
288
+
289
+ async def history_list(
290
+ self, options: Optional[HistoryListOptions] = None
291
+ ) -> builtins.list[dict[str, Any]]:
292
+ """List standardized history sessions."""
293
+ parsed = _input(
294
+ "history_list_options", options or {}, "invalid oneharness history list options"
295
+ )
296
+ args = ["history", "list", "--compact"]
297
+ if parsed.get("project"):
298
+ args.extend(("--project", cast("str", parsed["project"])))
299
+ if parsed.get("allProjects"):
300
+ args.append("--all-projects")
301
+ if parsed.get("historyDir"):
302
+ args.extend(("--history-dir", cast("str", parsed["historyDir"])))
303
+ value = await self._invoke(args)
304
+ return cast(
305
+ "builtins.list[dict[str, Any]]",
306
+ _validate("history_list", value, "invalid oneharness history list contract"),
307
+ )
308
+
309
+ def history_watch(
310
+ self, options: Optional[HistoryWatchOptions] = None
311
+ ) -> AsyncIterator[HistoryStreamEnvelope]:
312
+ """Follow validated standardized history records."""
313
+ parsed = _input(
314
+ "history_watch_options", options or {}, "invalid oneharness history watch options"
315
+ )
316
+ args = ["history", "watch", "--format", "jsonl"]
317
+ if parsed.get("after"):
318
+ args.extend(("--after", cast("str", parsed["after"])))
319
+ for key, value in cast("Mapping[str, str]", parsed.get("labels", {})).items():
320
+ args.extend(("--label", f"{key}={value}"))
321
+ if parsed.get("project"):
322
+ args.extend(("--project", cast("str", parsed["project"])))
323
+ if parsed.get("allProjects"):
324
+ args.append("--all-projects")
325
+ if parsed.get("historyDir"):
326
+ args.extend(("--history-dir", cast("str", parsed["historyDir"])))
327
+ return self._stream(
328
+ args,
329
+ "history_stream_envelope",
330
+ "invalid oneharness history watch contract",
331
+ history=True,
332
+ )
@@ -0,0 +1,18 @@
1
+ """Typed public errors raised by the Python SDK."""
2
+
3
+
4
+ class ContractError(ValueError):
5
+ """A value did not match its Rust-owned SDK contract."""
6
+
7
+
8
+ class OneHarnessProcessError(RuntimeError):
9
+ """The oneharness subprocess exited unsuccessfully."""
10
+
11
+ def __init__(self, returncode: int, stderr: str) -> None:
12
+ self.returncode = returncode
13
+ self.stderr = stderr
14
+ super().__init__(f"oneharness exited {returncode}: {stderr.strip()}")
15
+
16
+
17
+ class HistoryNotFoundError(OneHarnessProcessError):
18
+ """A history session, record, or watch cursor could not be resolved."""
@@ -0,0 +1 @@
1
+ """Rust-generated Python SDK assets."""
@@ -0,0 +1,41 @@
1
+ {
2
+ "history_list_options": {
3
+ "all_projects": "allProjects",
4
+ "history_dir": "historyDir",
5
+ "project": "project"
6
+ },
7
+ "history_lookup": {
8
+ "all_projects": "allProjects",
9
+ "history_dir": "historyDir",
10
+ "last": "last",
11
+ "project": "project",
12
+ "session": "session"
13
+ },
14
+ "history_watch_options": {
15
+ "after": "after",
16
+ "all_projects": "allProjects",
17
+ "history_dir": "historyDir",
18
+ "labels": "labels",
19
+ "project": "project"
20
+ },
21
+ "run_options": {
22
+ "bins": "bins",
23
+ "cwd": "cwd",
24
+ "env": "env",
25
+ "events": "events",
26
+ "fork": "fork",
27
+ "harnesses": "harnesses",
28
+ "history": "history",
29
+ "history_dir": "historyDir",
30
+ "history_labels": "historyLabels",
31
+ "history_name": "historyName",
32
+ "mode": "mode",
33
+ "models": "models",
34
+ "prompt": "prompt",
35
+ "reasoning": "reasoning",
36
+ "resume": "resume",
37
+ "session": "session",
38
+ "system": "system",
39
+ "timeout_seconds": "timeoutSeconds"
40
+ }
41
+ }