claude-subprocess 0.1.1__tar.gz

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,10 @@
1
+ __pycache__/
2
+ *.py[co]
3
+ .pytest_cache/
4
+ .venv/
5
+ *.egg-info/
6
+ graphify-out/
7
+
8
+ # Editor/agent tool deps
9
+ node_modules/
10
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniil Petrov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.5
2
+ Name: claude-subprocess
3
+ Version: 0.1.1
4
+ Summary: Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache
5
+ Project-URL: Homepage, https://github.com/octrow/claude-cli
6
+ Project-URL: Issues, https://github.com/octrow/claude-cli/issues
7
+ Author: Daniil Petrov
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.12
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # claude-subprocess
17
+
18
+ ```bash
19
+ uv add claude-subprocess # imported as `claude_cli`
20
+ ```
21
+
22
+ One hardened wrapper around the OmniRoute gateway: tool-less calls go over its HTTP API,
23
+ everything else (and any HTTP failure) over `omniroute run claude -- -p` (Claude Code
24
+ **subscription** / OAuth — there is no `ANTHROPIC_API_KEY`), extracted from six
25
+ independent copies of the same subprocess contract (dialogue-lens,
26
+ tiktok-lazy-follower, get_cool_event, finance-insights, chat-watch, buy-smarter).
27
+
28
+ stdlib only. Python ≥3.12.
29
+
30
+ ## Invariants (the reason this library exists)
31
+
32
+ * **NEVER pass `--bare`** — it forces API-key-only mode and breaks subscription
33
+ auth. The flag is intentionally absent from `build_args`; a test guards it.
34
+ * **OmniRoute is mandatory** — every Claude Code subprocess is launched via
35
+ `omniroute run claude`, and the HTTP route talks to the same gateway, so it owns routing.
36
+ * **Isolated surface**: an empty temp mcp-config + `--strict-mcp-config`,
37
+ `--setting-sources user`, `--disable-slash-commands`,
38
+ `--no-session-persistence`.
39
+ * **Throwaway cwd** for every call, so no project `CLAUDE.md` is auto-loaded.
40
+ * **`--tools ""` by default.** A non-empty `tools` value goes to *both* `--tools`
41
+ and `--allowed-tools` — headless `-p` auto-denies anything not allow-listed.
42
+ * `"Not logged in"` in stdout+stderr → `NotLoggedInError` (actionable).
43
+ * Usage/rate/session-limit signatures → `UsageLimitError`, raised *before* the
44
+ exit code so callers fail fast instead of burning the rest of a batch.
45
+ Deliberately narrow: `context limit exceeded` is **not** a usage limit.
46
+ * Both output shapes supported: `--output-format json` (one envelope) and
47
+ `--output-format stream-json` (final `{"type":"result"}` event); both are
48
+ normalized into the same `ClaudeResult`.
49
+
50
+ All errors derive from `ClaudeCliError(RuntimeError)`, so `except RuntimeError`
51
+ graceful-degrade paths keep working.
52
+
53
+ When the failure is the gateway's rather than Claude's, the error text says so and points at
54
+ OmniRoute's own docs and issues. A 503 is ambiguous there — a spent quota and
55
+ `ALL_TARGETS_SKIPPED` (no provider matched at all) share the number, and only the first is worth
56
+ waiting out. [docs/OMNIROUTE.md](docs/OMNIROUTE.md) has the diagnosis order, the recognisable
57
+ error strings, and the checks that cost no quota.
58
+
59
+ ## Usage
60
+
61
+ ```python
62
+ from claude_cli import run_claude, extract_json
63
+
64
+ res = run_claude("Reply with {\"ok\": true}", model="haiku", effort="low", timeout=60)
65
+ res.text # the model's answer
66
+ res.cost_usd # subscription cost the envelope reported (may be None)
67
+ res.duration_ms
68
+ res.raw # the full envelope dict
69
+ extract_json(res.text) # {'ok': True} — tolerates ```fences``` and prose
70
+ ```
71
+
72
+ HTTP route: `run_claude`/`arun_claude` first POST tool-less calls to OmniRoute's
73
+ `/v1/chat/completions` (key from `OMNIROUTE_API_KEY` or `~/.omniroute/.env`, URL from
74
+ `OMNIROUTE_URL`, default `http://localhost:20128`; no key = skipped). A failure logs a
75
+ warning, falls back to the CLI and parks HTTP for 600 s per process. Calls with
76
+ `tools`/`allowed_tools`, a project `cwd` (one holding `CLAUDE.md` or `.claude/`),
77
+ `slash_commands`, remote overrides or `output_format="stream-json"` stay on the CLI, as does
78
+ `ping`. A tool-less `stream_claude` goes over HTTP too and then emits no events. Opt out with
79
+ `backend="cli"` (or `CLAUDE_CLI_BACKEND=cli`, which wins over the kwarg); `backend="api"` is
80
+ HTTP only. HTTP results have `cost_usd=0.0` and `effort` is ignored. No `temperature` is
81
+ sent (it would enable OmniRoute's semantic cache). Consumer test suites that fake the CLI
82
+ must set `CLAUDE_CLI_BACKEND=cli`, or they reach the live gateway. Details, decisions,
83
+ consumer migration notes and the list of what still runs on the CLI:
84
+ [docs/OMNIROUTE.md](docs/OMNIROUTE.md#http-route-2026-10).
85
+
86
+ Streaming envelope (long runs, tlf/gce style):
87
+
88
+ ```python
89
+ res = run_claude(prompt, model="sonnet", output_format="stream-json")
90
+ ```
91
+
92
+ Live progress — `stream_claude` calls back per event *while the run is going*, instead of
93
+ handing you the whole stream at the end (stackpulse `apply_batch` style):
94
+
95
+ ```python
96
+ from claude_cli import stream_claude
97
+
98
+ res = stream_claude(prompt, lambda ev: print(ev["type"]), model="opus", timeout=3600)
99
+ ```
100
+
101
+ Project mode (opt-in, reverses the isolation invariants above) — run a project's own slash
102
+ command with its `CLAUDE.md` and settings loaded, and let it write files:
103
+
104
+ ```python
105
+ res = stream_claude(
106
+ "/apply data/export/interested/acme.md", on_event,
107
+ cwd="~/dev/cv-adapter", slash_commands=True,
108
+ setting_sources="user,project", permission_mode="acceptEdits",
109
+ tools="Read,Write,Edit,Bash,WebFetch",
110
+ )
111
+ ```
112
+
113
+ Async (buy-smarter style):
114
+
115
+ ```python
116
+ from claude_cli import arun_claude
117
+
118
+ res = await arun_claude(prompt, model="opus")
119
+ ```
120
+
121
+ Cached (chat-watch / finance-insights style):
122
+
123
+ ```python
124
+ from claude_cli import FileCacheStore, cached_run, run_claude
125
+
126
+ store = FileCacheStore("~/.cache/myapp/llm.db")
127
+ text = cached_run(run_claude, store, prompt, model="haiku",
128
+ purpose="classify", version="v3")
129
+ ```
130
+
131
+ `DictCacheStore` is the in-memory store for tests. `cache_key(model, prompt,
132
+ schema=..., version=..., purpose=..., tools=...)` is sha256 over the normalized
133
+ prompt; empty optional parts never change a key, so new dimensions don't
134
+ invalidate old rows.
135
+
136
+ Batch (dialogue-lens style) — one bad chunk never aborts the run:
137
+
138
+ ```python
139
+ from claude_cli import run_chunks
140
+
141
+ results, cost, failed = run_chunks(chunks, summarize, label=str, workers=4)
142
+ ```
143
+
144
+ `claude_available()` reports whether the CLI is on PATH, for callers that degrade
145
+ to a non-LLM path. `ClaudeCliProvider` / `LLMProvider` are the one-method
146
+ (`ask(prompt, *, model=None) -> str`) seam for swappable backends.
147
+
148
+ `ping(model="haiku", timeout=90) -> (bool, str)` makes one real call through
149
+ OmniRoute ("reply with the single word pong") for "doctor"-style startup
150
+ checks that want proof the whole path works, not just `claude_available()`'s
151
+ PATH check. Never raises: `(True, "pong via omniroute, 6.2s")` on success,
152
+ `(False, <cleaned error, ≤200 chars>)` on any exception or timeout.
153
+
154
+ Quota-free preflight (never raises, never spends quota):
155
+
156
+ ```python
157
+ from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
158
+
159
+ health() # (True, ...) — omniroute health
160
+ simulate("hi", combo="free-first")
161
+ dry_run_plan() # (True, '{"command", "args", "env_keys"}') — key *names* only
162
+ ```
163
+
164
+ The combo is picked by `--model` only: `haiku`/`sonnet`/`opus` go through the
165
+ gateway's model→combo mappings, or pass a combo name directly. `tier=`/`profile=`
166
+ only set `CLAUDE_CONFIG_DIR` (a separate Claude Code settings dir), not the route —
167
+ see `docs/OMNIROUTE.md`, "Как выбирается комбо":
168
+
169
+ ```python
170
+ res = run_claude(prompt, model="sub-first") # explicit combo
171
+ ```
172
+
173
+ Before classification and truncation, stderr/stdout are cleaned of ANSI codes
174
+ and OmniRoute's own banner/warning lines (env-file banner, "is ignored, ...
175
+ set it first", disabled-connectors notice) so a real error at the tail of a
176
+ noisy run isn't crowded out by wrapper noise; `ClaudeCliError.message` carries
177
+ the cleaned text.
178
+
179
+ `cached_run` never persists the prompt by default (`store_prompt=False`) — prompts
180
+ often embed privacy-gated payloads, so only the sha256 key touches disk. Callers
181
+ needing hit/miss visibility use `store.get`/`store.put` directly.
182
+
183
+ ## Migration notes (for repos replacing their local wrapper)
184
+
185
+ * **Mock seam**: consumer tests that used to patch their own `subprocess.run`
186
+ must now patch `claude_cli.runner.subprocess.run` (sync) or
187
+ `claude_cli.runner.asyncio.create_subprocess_exec` (async), and
188
+ `claude_cli.runner.shutil.which` for `claude_available`.
189
+ * **`extract_json` is stricter** than copy-pasted find-first-`{`/rfind-last-`}`
190
+ variants: it takes the first *balanced* `{...}` object. Replies with two
191
+ top-level objects now yield the first instead of a decode error.
192
+ * Per-project defaults: subclass `ClaudeCliProvider` and override the
193
+ `DEFAULT_MODEL` / `DEFAULT_TIMEOUT` class attributes — no `__init__` needed.
194
+
195
+ ## Tests
196
+
197
+ ```bash
198
+ cd claude-cli && uv run --extra dev pytest -q
199
+ ```
200
+
201
+ The suite fakes `subprocess.run` / `asyncio.create_subprocess_exec` — it never
202
+ invokes the real `claude` binary.
@@ -0,0 +1,187 @@
1
+ # claude-subprocess
2
+
3
+ ```bash
4
+ uv add claude-subprocess # imported as `claude_cli`
5
+ ```
6
+
7
+ One hardened wrapper around the OmniRoute gateway: tool-less calls go over its HTTP API,
8
+ everything else (and any HTTP failure) over `omniroute run claude -- -p` (Claude Code
9
+ **subscription** / OAuth — there is no `ANTHROPIC_API_KEY`), extracted from six
10
+ independent copies of the same subprocess contract (dialogue-lens,
11
+ tiktok-lazy-follower, get_cool_event, finance-insights, chat-watch, buy-smarter).
12
+
13
+ stdlib only. Python ≥3.12.
14
+
15
+ ## Invariants (the reason this library exists)
16
+
17
+ * **NEVER pass `--bare`** — it forces API-key-only mode and breaks subscription
18
+ auth. The flag is intentionally absent from `build_args`; a test guards it.
19
+ * **OmniRoute is mandatory** — every Claude Code subprocess is launched via
20
+ `omniroute run claude`, and the HTTP route talks to the same gateway, so it owns routing.
21
+ * **Isolated surface**: an empty temp mcp-config + `--strict-mcp-config`,
22
+ `--setting-sources user`, `--disable-slash-commands`,
23
+ `--no-session-persistence`.
24
+ * **Throwaway cwd** for every call, so no project `CLAUDE.md` is auto-loaded.
25
+ * **`--tools ""` by default.** A non-empty `tools` value goes to *both* `--tools`
26
+ and `--allowed-tools` — headless `-p` auto-denies anything not allow-listed.
27
+ * `"Not logged in"` in stdout+stderr → `NotLoggedInError` (actionable).
28
+ * Usage/rate/session-limit signatures → `UsageLimitError`, raised *before* the
29
+ exit code so callers fail fast instead of burning the rest of a batch.
30
+ Deliberately narrow: `context limit exceeded` is **not** a usage limit.
31
+ * Both output shapes supported: `--output-format json` (one envelope) and
32
+ `--output-format stream-json` (final `{"type":"result"}` event); both are
33
+ normalized into the same `ClaudeResult`.
34
+
35
+ All errors derive from `ClaudeCliError(RuntimeError)`, so `except RuntimeError`
36
+ graceful-degrade paths keep working.
37
+
38
+ When the failure is the gateway's rather than Claude's, the error text says so and points at
39
+ OmniRoute's own docs and issues. A 503 is ambiguous there — a spent quota and
40
+ `ALL_TARGETS_SKIPPED` (no provider matched at all) share the number, and only the first is worth
41
+ waiting out. [docs/OMNIROUTE.md](docs/OMNIROUTE.md) has the diagnosis order, the recognisable
42
+ error strings, and the checks that cost no quota.
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from claude_cli import run_claude, extract_json
48
+
49
+ res = run_claude("Reply with {\"ok\": true}", model="haiku", effort="low", timeout=60)
50
+ res.text # the model's answer
51
+ res.cost_usd # subscription cost the envelope reported (may be None)
52
+ res.duration_ms
53
+ res.raw # the full envelope dict
54
+ extract_json(res.text) # {'ok': True} — tolerates ```fences``` and prose
55
+ ```
56
+
57
+ HTTP route: `run_claude`/`arun_claude` first POST tool-less calls to OmniRoute's
58
+ `/v1/chat/completions` (key from `OMNIROUTE_API_KEY` or `~/.omniroute/.env`, URL from
59
+ `OMNIROUTE_URL`, default `http://localhost:20128`; no key = skipped). A failure logs a
60
+ warning, falls back to the CLI and parks HTTP for 600 s per process. Calls with
61
+ `tools`/`allowed_tools`, a project `cwd` (one holding `CLAUDE.md` or `.claude/`),
62
+ `slash_commands`, remote overrides or `output_format="stream-json"` stay on the CLI, as does
63
+ `ping`. A tool-less `stream_claude` goes over HTTP too and then emits no events. Opt out with
64
+ `backend="cli"` (or `CLAUDE_CLI_BACKEND=cli`, which wins over the kwarg); `backend="api"` is
65
+ HTTP only. HTTP results have `cost_usd=0.0` and `effort` is ignored. No `temperature` is
66
+ sent (it would enable OmniRoute's semantic cache). Consumer test suites that fake the CLI
67
+ must set `CLAUDE_CLI_BACKEND=cli`, or they reach the live gateway. Details, decisions,
68
+ consumer migration notes and the list of what still runs on the CLI:
69
+ [docs/OMNIROUTE.md](docs/OMNIROUTE.md#http-route-2026-10).
70
+
71
+ Streaming envelope (long runs, tlf/gce style):
72
+
73
+ ```python
74
+ res = run_claude(prompt, model="sonnet", output_format="stream-json")
75
+ ```
76
+
77
+ Live progress — `stream_claude` calls back per event *while the run is going*, instead of
78
+ handing you the whole stream at the end (stackpulse `apply_batch` style):
79
+
80
+ ```python
81
+ from claude_cli import stream_claude
82
+
83
+ res = stream_claude(prompt, lambda ev: print(ev["type"]), model="opus", timeout=3600)
84
+ ```
85
+
86
+ Project mode (opt-in, reverses the isolation invariants above) — run a project's own slash
87
+ command with its `CLAUDE.md` and settings loaded, and let it write files:
88
+
89
+ ```python
90
+ res = stream_claude(
91
+ "/apply data/export/interested/acme.md", on_event,
92
+ cwd="~/dev/cv-adapter", slash_commands=True,
93
+ setting_sources="user,project", permission_mode="acceptEdits",
94
+ tools="Read,Write,Edit,Bash,WebFetch",
95
+ )
96
+ ```
97
+
98
+ Async (buy-smarter style):
99
+
100
+ ```python
101
+ from claude_cli import arun_claude
102
+
103
+ res = await arun_claude(prompt, model="opus")
104
+ ```
105
+
106
+ Cached (chat-watch / finance-insights style):
107
+
108
+ ```python
109
+ from claude_cli import FileCacheStore, cached_run, run_claude
110
+
111
+ store = FileCacheStore("~/.cache/myapp/llm.db")
112
+ text = cached_run(run_claude, store, prompt, model="haiku",
113
+ purpose="classify", version="v3")
114
+ ```
115
+
116
+ `DictCacheStore` is the in-memory store for tests. `cache_key(model, prompt,
117
+ schema=..., version=..., purpose=..., tools=...)` is sha256 over the normalized
118
+ prompt; empty optional parts never change a key, so new dimensions don't
119
+ invalidate old rows.
120
+
121
+ Batch (dialogue-lens style) — one bad chunk never aborts the run:
122
+
123
+ ```python
124
+ from claude_cli import run_chunks
125
+
126
+ results, cost, failed = run_chunks(chunks, summarize, label=str, workers=4)
127
+ ```
128
+
129
+ `claude_available()` reports whether the CLI is on PATH, for callers that degrade
130
+ to a non-LLM path. `ClaudeCliProvider` / `LLMProvider` are the one-method
131
+ (`ask(prompt, *, model=None) -> str`) seam for swappable backends.
132
+
133
+ `ping(model="haiku", timeout=90) -> (bool, str)` makes one real call through
134
+ OmniRoute ("reply with the single word pong") for "doctor"-style startup
135
+ checks that want proof the whole path works, not just `claude_available()`'s
136
+ PATH check. Never raises: `(True, "pong via omniroute, 6.2s")` on success,
137
+ `(False, <cleaned error, ≤200 chars>)` on any exception or timeout.
138
+
139
+ Quota-free preflight (never raises, never spends quota):
140
+
141
+ ```python
142
+ from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
143
+
144
+ health() # (True, ...) — omniroute health
145
+ simulate("hi", combo="free-first")
146
+ dry_run_plan() # (True, '{"command", "args", "env_keys"}') — key *names* only
147
+ ```
148
+
149
+ The combo is picked by `--model` only: `haiku`/`sonnet`/`opus` go through the
150
+ gateway's model→combo mappings, or pass a combo name directly. `tier=`/`profile=`
151
+ only set `CLAUDE_CONFIG_DIR` (a separate Claude Code settings dir), not the route —
152
+ see `docs/OMNIROUTE.md`, "Как выбирается комбо":
153
+
154
+ ```python
155
+ res = run_claude(prompt, model="sub-first") # explicit combo
156
+ ```
157
+
158
+ Before classification and truncation, stderr/stdout are cleaned of ANSI codes
159
+ and OmniRoute's own banner/warning lines (env-file banner, "is ignored, ...
160
+ set it first", disabled-connectors notice) so a real error at the tail of a
161
+ noisy run isn't crowded out by wrapper noise; `ClaudeCliError.message` carries
162
+ the cleaned text.
163
+
164
+ `cached_run` never persists the prompt by default (`store_prompt=False`) — prompts
165
+ often embed privacy-gated payloads, so only the sha256 key touches disk. Callers
166
+ needing hit/miss visibility use `store.get`/`store.put` directly.
167
+
168
+ ## Migration notes (for repos replacing their local wrapper)
169
+
170
+ * **Mock seam**: consumer tests that used to patch their own `subprocess.run`
171
+ must now patch `claude_cli.runner.subprocess.run` (sync) or
172
+ `claude_cli.runner.asyncio.create_subprocess_exec` (async), and
173
+ `claude_cli.runner.shutil.which` for `claude_available`.
174
+ * **`extract_json` is stricter** than copy-pasted find-first-`{`/rfind-last-`}`
175
+ variants: it takes the first *balanced* `{...}` object. Replies with two
176
+ top-level objects now yield the first instead of a decode error.
177
+ * Per-project defaults: subclass `ClaudeCliProvider` and override the
178
+ `DEFAULT_MODEL` / `DEFAULT_TIMEOUT` class attributes — no `__init__` needed.
179
+
180
+ ## Tests
181
+
182
+ ```bash
183
+ cd claude-cli && uv run --extra dev pytest -q
184
+ ```
185
+
186
+ The suite fakes `subprocess.run` / `asyncio.create_subprocess_exec` — it never
187
+ invokes the real `claude` binary.
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "claude-subprocess"
3
+ version = "0.1.1"
4
+ license = "MIT"
5
+ readme = "README.md"
6
+ authors = [{ name = "Daniil Petrov" }]
7
+ description = "Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache"
8
+ requires-python = ">=3.12"
9
+ dependencies = [] # stdlib only, on purpose
10
+
11
+ [project.urls]
12
+ Homepage = "https://github.com/octrow/claude-cli"
13
+ Issues = "https://github.com/octrow/claude-cli/issues"
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/claude_cli"]
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ include = ["src", "tests", "README.md", "LICENSE"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ asyncio_mode = "auto"
@@ -0,0 +1,89 @@
1
+ """claude-cli — one hardened wrapper around the local ``claude -p`` CLI.
2
+
3
+ Subscription/OAuth auth (no ``ANTHROPIC_API_KEY``), a locked-down surface, both
4
+ output formats, JSON extraction from prose replies, and a sha256 response cache.
5
+ See :mod:`claude_cli.runner` for the invariants this library exists to protect.
6
+ """
7
+
8
+ from .batch import pack_batches, run_chunks
9
+ from .cache import (
10
+ CacheStore,
11
+ DictCacheStore,
12
+ FileCacheStore,
13
+ cache_key,
14
+ cached_run,
15
+ normalize_prompt,
16
+ )
17
+ from .errors import ClaudeCliError, NotLoggedInError, UsageLimitError
18
+ from .parsing import (
19
+ extract_json,
20
+ extract_json_or_none,
21
+ parse_envelope,
22
+ parse_stream,
23
+ strip_fences,
24
+ )
25
+ from .preflight import (
26
+ BENCHMARKS,
27
+ CONSTRAINTS,
28
+ GUIDES,
29
+ MATRIX,
30
+ QUOTA_FREE_SUBCOMMANDS,
31
+ TIERS,
32
+ doctor,
33
+ dry_run_plan,
34
+ health,
35
+ quota_status,
36
+ simulate,
37
+ )
38
+ from .runner import (
39
+ MODELS,
40
+ ClaudeCliProvider,
41
+ ClaudeResult,
42
+ LLMProvider,
43
+ arun_claude,
44
+ build_args,
45
+ claude_available,
46
+ ping,
47
+ run_claude,
48
+ stream_claude,
49
+ )
50
+
51
+ __all__ = [
52
+ "BENCHMARKS",
53
+ "CONSTRAINTS",
54
+ "GUIDES",
55
+ "MATRIX",
56
+ "MODELS",
57
+ "QUOTA_FREE_SUBCOMMANDS",
58
+ "TIERS",
59
+ "CacheStore",
60
+ "ClaudeCliError",
61
+ "ClaudeCliProvider",
62
+ "ClaudeResult",
63
+ "DictCacheStore",
64
+ "FileCacheStore",
65
+ "LLMProvider",
66
+ "NotLoggedInError",
67
+ "UsageLimitError",
68
+ "arun_claude",
69
+ "build_args",
70
+ "cache_key",
71
+ "cached_run",
72
+ "claude_available",
73
+ "doctor",
74
+ "dry_run_plan",
75
+ "extract_json",
76
+ "extract_json_or_none",
77
+ "health",
78
+ "normalize_prompt",
79
+ "pack_batches",
80
+ "parse_envelope",
81
+ "parse_stream",
82
+ "ping",
83
+ "quota_status",
84
+ "run_chunks",
85
+ "run_claude",
86
+ "simulate",
87
+ "stream_claude",
88
+ "strip_fences",
89
+ ]
@@ -0,0 +1,88 @@
1
+ """Batch helpers: pack items into prompt-sized chunks, and run a call per chunk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable, Iterable
7
+ from typing import Any
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ def pack_batches(
13
+ items: Iterable[Any],
14
+ budget: int,
15
+ *,
16
+ size_of: Callable[[Any], int],
17
+ max_items: int = 0,
18
+ ) -> list[list[Any]]:
19
+ """Greedily pack ``items`` into batches within a size budget, order preserved.
20
+
21
+ A batch is closed when adding the next item would push it over ``budget``
22
+ (chars, tokens — whatever ``size_of`` counts) or over ``max_items``. Both
23
+ caps are disabled when ``<= 0``. A batch always holds at least one item, so
24
+ an item larger than ``budget`` on its own rides alone.
25
+ """
26
+ batches: list[list[Any]] = []
27
+ cur: list[Any] = []
28
+ cur_size = 0
29
+ for item in items:
30
+ size = size_of(item)
31
+ over_count = max_items > 0 and len(cur) + 1 > max_items
32
+ over_budget = budget > 0 and cur_size + size > budget
33
+ if cur and (over_count or over_budget):
34
+ batches.append(cur)
35
+ cur, cur_size = [], 0
36
+ cur.append(item)
37
+ cur_size += size
38
+ if cur:
39
+ batches.append(cur)
40
+ return batches
41
+
42
+
43
+ def run_chunks(
44
+ chunks: Iterable[Any],
45
+ fn: Callable[[Any], tuple[Any, float]],
46
+ *,
47
+ label: Callable[[Any], str],
48
+ workers: int = 1,
49
+ progress: Callable[..., Iterable] | None = None,
50
+ ) -> tuple[list, float, list[str]]:
51
+ """Apply ``fn(chunk) -> (data, cost)`` over ``chunks``, optionally in parallel.
52
+
53
+ Each call shells out to a ``claude`` subprocess, so threads give real
54
+ concurrency (the GIL is released during ``subprocess.run``).
55
+
56
+ Returns ``(results, total_cost, failed_labels)``. A chunk that raises is
57
+ recorded in ``failed_labels`` and skipped — one bad chunk never aborts the
58
+ batch.
59
+ """
60
+ chunks = list(chunks)
61
+ results, spent, failed = [], 0.0, []
62
+
63
+ def _record(produce: Callable[[], tuple[Any, float]], chunk) -> None:
64
+ nonlocal spent
65
+ try:
66
+ data, cost = produce()
67
+ except Exception as exc: # noqa: BLE001 — keep the batch alive
68
+ failed.append(f"{label(chunk)}: {type(exc).__name__}: {str(exc)[:120]}")
69
+ return
70
+ spent += cost or 0.0
71
+ results.append(data)
72
+
73
+ if workers <= 1:
74
+ iterator = progress(chunks) if progress else chunks
75
+ for chunk in iterator:
76
+ _record(lambda c=chunk: fn(c), chunk)
77
+ return results, spent, failed
78
+
79
+ from concurrent.futures import ThreadPoolExecutor, as_completed
80
+
81
+ with ThreadPoolExecutor(max_workers=workers) as ex:
82
+ futs = {ex.submit(fn, c): c for c in chunks}
83
+ stream = as_completed(futs)
84
+ if progress:
85
+ stream = progress(stream, total=len(futs))
86
+ for fut in stream:
87
+ _record(fut.result, futs[fut])
88
+ return results, spent, failed