py-opencode-wrapper 0.1.4__py3-none-any.whl → 0.2.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.
- opencode_wrapper/client.py +109 -6
- opencode_wrapper/config.py +104 -2
- {py_opencode_wrapper-0.1.4.dist-info → py_opencode_wrapper-0.2.0.dist-info}/METADATA +2 -2
- py_opencode_wrapper-0.2.0.dist-info/RECORD +9 -0
- py_opencode_wrapper-0.1.4.dist-info/RECORD +0 -9
- {py_opencode_wrapper-0.1.4.dist-info → py_opencode_wrapper-0.2.0.dist-info}/WHEEL +0 -0
- {py_opencode_wrapper-0.1.4.dist-info → py_opencode_wrapper-0.2.0.dist-info}/top_level.txt +0 -0
opencode_wrapper/client.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import logging
|
|
7
8
|
import os
|
|
8
9
|
import shutil
|
|
9
10
|
import tempfile
|
|
@@ -12,6 +13,7 @@ from pathlib import Path
|
|
|
12
13
|
from typing import Any, AsyncIterator, Mapping
|
|
13
14
|
|
|
14
15
|
from opencode_wrapper.config import RunConfig, validate_config_for_run
|
|
16
|
+
from opencode_wrapper.config import loads_jsonc, sanitize_user_config_json
|
|
15
17
|
from opencode_wrapper.errors import (
|
|
16
18
|
OpenCodeBinaryNotFoundError,
|
|
17
19
|
OpenCodeProcessError,
|
|
@@ -158,6 +160,96 @@ def _is_sqlite_startup_error(stderr: str) -> bool:
|
|
|
158
160
|
return any(pat in lower for pat in _SQLITE_STARTUP_PATTERNS)
|
|
159
161
|
|
|
160
162
|
|
|
163
|
+
_LOG = logging.getLogger(__name__)
|
|
164
|
+
|
|
165
|
+
# Filenames opencode reads from its global config dir + ~/.opencode.
|
|
166
|
+
# Legacy TOML ("config" without extension) is intentionally skipped — it's rare
|
|
167
|
+
# and would require a TOML parser dep; the wrapper aims for stdlib-only.
|
|
168
|
+
_GLOBAL_CONFIG_FILENAMES: tuple[str, ...] = (
|
|
169
|
+
"config.json",
|
|
170
|
+
"opencode.json",
|
|
171
|
+
"opencode.jsonc",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _resolve_real_xdg_config_opencode_dir(env: Mapping[str, str]) -> Path:
|
|
176
|
+
xdg = env.get("XDG_CONFIG_HOME")
|
|
177
|
+
base = Path(xdg).expanduser() if xdg else Path(env.get("HOME", str(Path.home()))).expanduser() / ".config"
|
|
178
|
+
return base / "opencode"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_real_home_opencode_dir(env: Mapping[str, str]) -> Path:
|
|
182
|
+
return Path(env.get("HOME", str(Path.home()))).expanduser() / ".opencode"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _sanitize_and_copy(src_dir: Path, dst_dir: Path) -> None:
|
|
186
|
+
"""Read each known opencode config file in *src_dir*, sanitize, write to *dst_dir*.
|
|
187
|
+
|
|
188
|
+
Strict JSON files parse on the fast path; JSONC (comments, trailing commas)
|
|
189
|
+
falls back to ``loads_jsonc``. Files that aren't valid in either form are
|
|
190
|
+
skipped with a warning — for benchmark reproducibility the wrapper would
|
|
191
|
+
rather hide a file it can't safely strip than risk leaking capability keys.
|
|
192
|
+
Missing source files are silently skipped. ``dst_dir`` is always created
|
|
193
|
+
so opencode finds the directory (even if empty).
|
|
194
|
+
"""
|
|
195
|
+
from opencode_wrapper.config import sanitize_user_config_json
|
|
196
|
+
|
|
197
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
if not src_dir.is_dir():
|
|
199
|
+
return
|
|
200
|
+
for fname in _GLOBAL_CONFIG_FILENAMES:
|
|
201
|
+
src = src_dir / fname
|
|
202
|
+
if not src.is_file():
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
text = src.read_text(encoding="utf-8")
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
_LOG.warning("user-config isolation: cannot read %s: %s", src, exc)
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
raw = loads_jsonc(text)
|
|
211
|
+
except json.JSONDecodeError as exc:
|
|
212
|
+
_LOG.warning(
|
|
213
|
+
"user-config isolation: skipping %s (not parseable as JSON or JSONC): %s",
|
|
214
|
+
src, exc,
|
|
215
|
+
)
|
|
216
|
+
continue
|
|
217
|
+
if not isinstance(raw, dict):
|
|
218
|
+
_LOG.warning(
|
|
219
|
+
"user-config isolation: skipping %s (root is not a JSON object)", src
|
|
220
|
+
)
|
|
221
|
+
continue
|
|
222
|
+
sanitized = sanitize_user_config_json(raw)
|
|
223
|
+
(dst_dir / fname).write_text(
|
|
224
|
+
json.dumps(sanitized, ensure_ascii=False), encoding="utf-8"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _isolate_user_config(env: dict[str, str], tmp_root: Path) -> dict[str, str]:
|
|
229
|
+
"""Mutate *env* so opencode sees a sanitized copy of the user's global config.
|
|
230
|
+
|
|
231
|
+
Reads the real ``$XDG_CONFIG_HOME/opencode`` and ``$HOME/.opencode``,
|
|
232
|
+
filters each file through ``sanitize_user_config_json`` (keeping only
|
|
233
|
+
``provider`` / ``disabled_providers`` / ``enabled_providers`` / ``$schema``),
|
|
234
|
+
writes the results under *tmp_root*, and points ``XDG_CONFIG_HOME`` /
|
|
235
|
+
``OPENCODE_TEST_HOME`` at those tmpdir locations. Strips
|
|
236
|
+
``OPENCODE_CONFIG`` / ``OPENCODE_CONFIG_DIR`` so the parent shell can't
|
|
237
|
+
re-introduce extras. Project-level config (cwd walk + ``.opencode/``)
|
|
238
|
+
is untouched.
|
|
239
|
+
"""
|
|
240
|
+
iso_xdg = tmp_root / "xdg"
|
|
241
|
+
iso_home = tmp_root / "home"
|
|
242
|
+
|
|
243
|
+
_sanitize_and_copy(_resolve_real_xdg_config_opencode_dir(env), iso_xdg / "opencode")
|
|
244
|
+
_sanitize_and_copy(_resolve_real_home_opencode_dir(env), iso_home / ".opencode")
|
|
245
|
+
|
|
246
|
+
env["XDG_CONFIG_HOME"] = str(iso_xdg)
|
|
247
|
+
env["OPENCODE_TEST_HOME"] = str(iso_home)
|
|
248
|
+
env.pop("OPENCODE_CONFIG", None)
|
|
249
|
+
env.pop("OPENCODE_CONFIG_DIR", None)
|
|
250
|
+
return env
|
|
251
|
+
|
|
252
|
+
|
|
161
253
|
class AsyncOpenCodeClient:
|
|
162
254
|
"""
|
|
163
255
|
One-shot async wrapper around the OpenCode CLI.
|
|
@@ -207,13 +299,16 @@ class AsyncOpenCodeClient:
|
|
|
207
299
|
argv: list[str],
|
|
208
300
|
cwd: str,
|
|
209
301
|
env: dict[str, str],
|
|
302
|
+
run_cfg: RunConfig,
|
|
210
303
|
) -> AsyncIterator[tuple[asyncio.subprocess.Process, list[str]]]:
|
|
211
304
|
stderr_lines: list[str] = []
|
|
305
|
+
cleanup_tmpdirs: list[str] = []
|
|
212
306
|
# Give each process its own XDG_DATA_HOME so opencode.db is isolated.
|
|
213
307
|
# Without this, all concurrent processes share ~/.local/share/opencode/opencode.db
|
|
214
308
|
# and SQLite write locks during tool execution serialize the runs (37–46s delays).
|
|
215
309
|
if self._isolate_db:
|
|
216
310
|
xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
|
|
311
|
+
cleanup_tmpdirs.append(xdg_tmpdir)
|
|
217
312
|
# Symlink auth.json so provider API keys (stored by `opencode auth`)
|
|
218
313
|
# are visible in the isolated data dir. Without this, providers
|
|
219
314
|
# that rely on auth.json (rather than env-var keys) fail with
|
|
@@ -225,8 +320,16 @@ class AsyncOpenCodeClient:
|
|
|
225
320
|
iso_oc_dir.mkdir(parents=True, exist_ok=True)
|
|
226
321
|
(iso_oc_dir / "auth.json").symlink_to(real_auth)
|
|
227
322
|
env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
|
|
228
|
-
|
|
229
|
-
|
|
323
|
+
# When the caller has not opted into host-config inheritance (the
|
|
324
|
+
# default), redirect XDG_CONFIG_HOME / OPENCODE_TEST_HOME at a sanitized
|
|
325
|
+
# tmpdir copy of the user's global config — only provider settings are
|
|
326
|
+
# carried over, all capability keys (mcp / agent / command / tools /
|
|
327
|
+
# plugin / skills / instructions / permission / model / ...) are stripped.
|
|
328
|
+
# Project-level config (cwd walk + .opencode/) is untouched.
|
|
329
|
+
if not run_cfg.inherit_user_config:
|
|
330
|
+
cfg_tmpdir = tempfile.mkdtemp(prefix="oc_cfg_")
|
|
331
|
+
cleanup_tmpdirs.append(cfg_tmpdir)
|
|
332
|
+
env = _isolate_user_config(dict(env), Path(cfg_tmpdir))
|
|
230
333
|
# Serialise process startup to avoid the SQLite WAL-pragma race.
|
|
231
334
|
# The semaphore is released as soon as the startup window has elapsed,
|
|
232
335
|
# so all processes run concurrently after their individual delay.
|
|
@@ -257,8 +360,8 @@ class AsyncOpenCodeClient:
|
|
|
257
360
|
await stderr_task
|
|
258
361
|
except asyncio.CancelledError:
|
|
259
362
|
pass
|
|
260
|
-
|
|
261
|
-
shutil.rmtree(
|
|
363
|
+
for path in cleanup_tmpdirs:
|
|
364
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
262
365
|
|
|
263
366
|
async def async_stream(
|
|
264
367
|
self,
|
|
@@ -283,7 +386,7 @@ class AsyncOpenCodeClient:
|
|
|
283
386
|
events_acc: list[dict[str, Any]] = []
|
|
284
387
|
raw_acc: list[str] = []
|
|
285
388
|
|
|
286
|
-
async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
|
|
389
|
+
async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
|
|
287
390
|
async for line, ev in _stdout_line_event_iter(proc):
|
|
288
391
|
raw_acc.append(line)
|
|
289
392
|
events_acc.append(ev)
|
|
@@ -341,7 +444,7 @@ class AsyncOpenCodeClient:
|
|
|
341
444
|
|
|
342
445
|
log_fh = open(log_file, "w") if log_file is not None else None
|
|
343
446
|
try:
|
|
344
|
-
async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
|
|
447
|
+
async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
|
|
345
448
|
async for line, ev in _stdout_line_event_iter(proc):
|
|
346
449
|
raw_acc.append(line)
|
|
347
450
|
events_acc.append(ev)
|
opencode_wrapper/config.py
CHANGED
|
@@ -5,13 +5,114 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any, Mapping
|
|
8
|
+
from typing import Any, Dict, Mapping
|
|
9
9
|
|
|
10
10
|
# Permission values accepted by OpenCode
|
|
11
11
|
PermissionAction = str # "allow" | "ask" | "deny"
|
|
12
12
|
|
|
13
13
|
# Nested permission maps: tool name -> action or pattern -> action
|
|
14
|
-
PermissionMap =
|
|
14
|
+
PermissionMap = Dict[str, Any]
|
|
15
|
+
|
|
16
|
+
# Top-level keys retained from the host's global opencode config when
|
|
17
|
+
# inherit_user_config=False. Everything else (mcp / agent / command / skills /
|
|
18
|
+
# plugin / tools / instructions / permission / model / ...) is dropped to make
|
|
19
|
+
# benchmark runs reproducible.
|
|
20
|
+
PRESERVE_KEYS: frozenset[str] = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"$schema",
|
|
23
|
+
"provider",
|
|
24
|
+
"disabled_providers",
|
|
25
|
+
"enabled_providers",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sanitize_user_config_json(raw: Mapping[str, Any]) -> dict[str, Any]:
|
|
31
|
+
"""Filter a parsed opencode config dict to the benchmark-safe preserve set.
|
|
32
|
+
|
|
33
|
+
Only ``PRESERVE_KEYS`` survive; everything else — including capability keys
|
|
34
|
+
like ``mcp`` / ``agent`` / ``command`` / ``tools`` / ``plugin`` / ``skills``
|
|
35
|
+
and the default ``model`` — is removed. Benchmark callers re-inject the
|
|
36
|
+
bits they need via ``RunConfig``.
|
|
37
|
+
"""
|
|
38
|
+
return {k: v for k, v in raw.items() if k in PRESERVE_KEYS}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def strip_jsonc(source: str) -> str:
|
|
42
|
+
"""Convert a JSONC source string to strict JSON.
|
|
43
|
+
|
|
44
|
+
Supports the JSONC superset accepted by opencode (and VS Code's
|
|
45
|
+
jsonc-parser): ``//`` line comments, ``/* ... */`` block comments, and
|
|
46
|
+
trailing commas in objects/arrays. Strings — including ones that contain
|
|
47
|
+
``//`` or ``/*`` — are preserved verbatim, with backslash escape sequences
|
|
48
|
+
handled. Unterminated block comments swallow the rest of the input.
|
|
49
|
+
|
|
50
|
+
Stdlib-only — keeps the wrapper's zero-runtime-deps invariant.
|
|
51
|
+
"""
|
|
52
|
+
out: list[str] = []
|
|
53
|
+
i = 0
|
|
54
|
+
n = len(source)
|
|
55
|
+
while i < n:
|
|
56
|
+
c = source[i]
|
|
57
|
+
# JSON string: copy verbatim, handle escapes
|
|
58
|
+
if c == '"':
|
|
59
|
+
out.append(c)
|
|
60
|
+
i += 1
|
|
61
|
+
while i < n:
|
|
62
|
+
cc = source[i]
|
|
63
|
+
out.append(cc)
|
|
64
|
+
if cc == "\\" and i + 1 < n:
|
|
65
|
+
out.append(source[i + 1])
|
|
66
|
+
i += 2
|
|
67
|
+
continue
|
|
68
|
+
i += 1
|
|
69
|
+
if cc == '"':
|
|
70
|
+
break
|
|
71
|
+
continue
|
|
72
|
+
# Line comment: drop through newline (keep newline for line accuracy)
|
|
73
|
+
if c == "/" and i + 1 < n and source[i + 1] == "/":
|
|
74
|
+
nl = source.find("\n", i + 2)
|
|
75
|
+
i = n if nl == -1 else nl
|
|
76
|
+
continue
|
|
77
|
+
# Block comment: drop through closing */
|
|
78
|
+
if c == "/" and i + 1 < n and source[i + 1] == "*":
|
|
79
|
+
end = source.find("*/", i + 2)
|
|
80
|
+
i = n if end == -1 else end + 2
|
|
81
|
+
continue
|
|
82
|
+
# Possible trailing comma: peek ahead past whitespace/comments
|
|
83
|
+
if c == ",":
|
|
84
|
+
j = i + 1
|
|
85
|
+
while j < n:
|
|
86
|
+
cj = source[j]
|
|
87
|
+
if cj.isspace():
|
|
88
|
+
j += 1
|
|
89
|
+
elif cj == "/" and j + 1 < n and source[j + 1] == "/":
|
|
90
|
+
nl = source.find("\n", j + 2)
|
|
91
|
+
j = n if nl == -1 else nl
|
|
92
|
+
elif cj == "/" and j + 1 < n and source[j + 1] == "*":
|
|
93
|
+
end = source.find("*/", j + 2)
|
|
94
|
+
j = n if end == -1 else end + 2
|
|
95
|
+
else:
|
|
96
|
+
break
|
|
97
|
+
if j < n and source[j] in "}]":
|
|
98
|
+
i += 1 # drop the trailing comma
|
|
99
|
+
continue
|
|
100
|
+
out.append(c)
|
|
101
|
+
i += 1
|
|
102
|
+
return "".join(out)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def loads_jsonc(source: str) -> Any:
|
|
106
|
+
"""Parse a JSON or JSONC string, falling back to JSONC stripping on failure.
|
|
107
|
+
|
|
108
|
+
The fast path is plain ``json.loads`` — most files are strict JSON.
|
|
109
|
+
On failure we strip JSONC syntax and retry once. The caller catches
|
|
110
|
+
``json.JSONDecodeError`` if even the stripped form is invalid.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(source)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return json.loads(strip_jsonc(source))
|
|
15
116
|
|
|
16
117
|
|
|
17
118
|
def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -50,6 +151,7 @@ class RunConfig:
|
|
|
50
151
|
print_logs: bool | None = None
|
|
51
152
|
log_level: str | None = None
|
|
52
153
|
disable_autoupdate: bool = True
|
|
154
|
+
inherit_user_config: bool = False
|
|
53
155
|
extra_env: Mapping[str, str] | None = None
|
|
54
156
|
# Injected as JSON via OPENCODE_CONFIG_CONTENT (merged with config_overrides)
|
|
55
157
|
permission: PermissionMap | None = None
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-opencode-wrapper
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
|
|
5
5
|
Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
|
|
6
6
|
Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
|
|
7
7
|
Project-URL: Issues, https://github.com/idailylife/oc_py_wrapper/issues
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Provides-Extra: dev
|
|
11
11
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
opencode_wrapper/__init__.py,sha256=iCkMcrh7P35jHFq8gH-GKjaKqwnvmOkGCLRfgnb0moE,1013
|
|
2
|
+
opencode_wrapper/client.py,sha256=TDDCHCRb2WGk4Pipb2nJLLhWt2nz43wYsreKvSYl9EQ,19003
|
|
3
|
+
opencode_wrapper/config.py,sha256=JraBPkX95GXFoOvn181BRv_8YPvsUDXiZMN1hZWhSf0,7823
|
|
4
|
+
opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
|
|
5
|
+
opencode_wrapper/events.py,sha256=PHz04DcB0K0JfIRxgV8GQ3psl7VjQXZ25gIrDCOgAHQ,6478
|
|
6
|
+
py_opencode_wrapper-0.2.0.dist-info/METADATA,sha256=Tw2pl9daegsqTvvx0RIciN2idvIhYzVPQIHORVmWoEI,5940
|
|
7
|
+
py_opencode_wrapper-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
py_opencode_wrapper-0.2.0.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
|
|
9
|
+
py_opencode_wrapper-0.2.0.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
opencode_wrapper/__init__.py,sha256=iCkMcrh7P35jHFq8gH-GKjaKqwnvmOkGCLRfgnb0moE,1013
|
|
2
|
-
opencode_wrapper/client.py,sha256=wVVxBHyYey-uqwRn4Q6QKazWNOJ3gXrN-ltO5W66CqM,14523
|
|
3
|
-
opencode_wrapper/config.py,sha256=fu4irU22Dj__E6WIVqM2m61vc9J3nQyWV0FPp8Xo3VM,4040
|
|
4
|
-
opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
|
|
5
|
-
opencode_wrapper/events.py,sha256=PHz04DcB0K0JfIRxgV8GQ3psl7VjQXZ25gIrDCOgAHQ,6478
|
|
6
|
-
py_opencode_wrapper-0.1.4.dist-info/METADATA,sha256=-7aVOAiIIWQWDoOEzjoY6Lwa7Op_siT7C2P6Zp0RgnU,5941
|
|
7
|
-
py_opencode_wrapper-0.1.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
-
py_opencode_wrapper-0.1.4.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
|
|
9
|
-
py_opencode_wrapper-0.1.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|