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.
- oneharness_sdk/__init__.py +35 -0
- oneharness_sdk/_client.py +332 -0
- oneharness_sdk/_errors.py +18 -0
- oneharness_sdk/_generated/__init__.py +1 -0
- oneharness_sdk/_generated/input-keys.json +41 -0
- oneharness_sdk/_generated/schemas.json +3192 -0
- oneharness_sdk/_generated_types.py +61 -0
- oneharness_sdk/_version.py +3 -0
- oneharness_sdk/py.typed +1 -0
- oneharness_sdk-0.4.0.dist-info/METADATA +67 -0
- oneharness_sdk-0.4.0.dist-info/RECORD +13 -0
- oneharness_sdk-0.4.0.dist-info/WHEEL +4 -0
- oneharness_sdk-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|