claude-dev-env 8.37.3 → 8.38.1
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.
- package/docs/account-broker.md +32 -0
- package/package.json +1 -1
- package/scripts/account_broker.py +387 -0
- package/scripts/account_broker_support.py +445 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +302 -0
- package/scripts/test_account_broker.py +501 -0
- package/scripts/test_account_broker_guard.py +109 -0
- package/scripts/test_account_broker_support.py +127 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Choose and run Claude or Codex jobs through one account roster."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterator, Sequence
|
|
15
|
+
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
import msvcrt
|
|
18
|
+
else:
|
|
19
|
+
import fcntl
|
|
20
|
+
|
|
21
|
+
import codex_account_choice
|
|
22
|
+
import codex_account_meters
|
|
23
|
+
from claude_account_profile import default_profile_home, validate_profile_name
|
|
24
|
+
from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
|
|
25
|
+
from dev_env_scripts_constants.account_broker_constants import (
|
|
26
|
+
BROKER_STATE_DIRECTORY_NAME,
|
|
27
|
+
BROKER_STATE_FILE_NAME,
|
|
28
|
+
BROKER_STATE_LOCK_SUFFIX,
|
|
29
|
+
BROKER_STATE_TEMP_SUFFIX,
|
|
30
|
+
Account,
|
|
31
|
+
BrokerConfigurationError,
|
|
32
|
+
Decision,
|
|
33
|
+
JobOutcome,
|
|
34
|
+
Meters,
|
|
35
|
+
Product,
|
|
36
|
+
ProductAdapter,
|
|
37
|
+
Reading,
|
|
38
|
+
Report,
|
|
39
|
+
REPORT_INDENT_SPACES,
|
|
40
|
+
SubprocessRunner,
|
|
41
|
+
codex_usage_limit_signatures,
|
|
42
|
+
parse_utc_time,
|
|
43
|
+
utc_time_text,
|
|
44
|
+
)
|
|
45
|
+
from dev_env_scripts_constants.claude_account_constants import (
|
|
46
|
+
CREDENTIALS_FILE_NAME,
|
|
47
|
+
EXTRA_PROFILES_FILE_NAME,
|
|
48
|
+
FULL_PERCENT,
|
|
49
|
+
MAIN_CLAUDE_HOME_DIRECTORY_NAME,
|
|
50
|
+
)
|
|
51
|
+
from dev_env_scripts_constants.claude_chain_constants import ALL_USAGE_LIMIT_SIGNATURES
|
|
52
|
+
from dev_env_scripts_constants.codex_account_constants import (
|
|
53
|
+
CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
|
|
54
|
+
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
|
|
55
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
56
|
+
WEEKLY_WINDOW_MINUTES,
|
|
57
|
+
)
|
|
58
|
+
from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_list(path: Path) -> object:
|
|
62
|
+
try:
|
|
63
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
65
|
+
raise BrokerConfigurationError(f"cannot read account list {path}: {error}") from error
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _chain_entry(raw_entry: object, main_home: Path, chain_path: Path) -> Account:
|
|
69
|
+
if not isinstance(raw_entry, dict):
|
|
70
|
+
raise BrokerConfigurationError(f"invalid account list {chain_path}")
|
|
71
|
+
command = raw_entry.get("command")
|
|
72
|
+
credential = raw_entry.get("credentials_path")
|
|
73
|
+
extra_args = raw_entry.get("extra_args", [])
|
|
74
|
+
if not isinstance(command, str) or not command:
|
|
75
|
+
raise BrokerConfigurationError(f"invalid account list {chain_path}")
|
|
76
|
+
if credential is not None and (not isinstance(credential, str) or not credential):
|
|
77
|
+
raise BrokerConfigurationError(f"invalid account list {chain_path}")
|
|
78
|
+
if not isinstance(extra_args, list) or any(not isinstance(each_arg, str) for each_arg in extra_args):
|
|
79
|
+
raise BrokerConfigurationError(f"invalid account list {chain_path}")
|
|
80
|
+
home = Path(credential).expanduser().parent.resolve() if credential else main_home
|
|
81
|
+
return Account(Product.CLAUDE, command, home, command=command)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _chain_entries(chain_path: Path, main_home: Path) -> tuple[Account, ...]:
|
|
85
|
+
if not chain_path.exists():
|
|
86
|
+
return ()
|
|
87
|
+
document = _read_list(chain_path)
|
|
88
|
+
all_entries = document.get("chain") if isinstance(document, dict) else None
|
|
89
|
+
if not isinstance(all_entries, list) or not all_entries:
|
|
90
|
+
raise BrokerConfigurationError(f"invalid account list {chain_path}")
|
|
91
|
+
return tuple(_chain_entry(each_entry, main_home, chain_path) for each_entry in all_entries)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extra_homes(main_home: Path) -> tuple[Path, ...]:
|
|
95
|
+
extras_path = main_home / EXTRA_PROFILES_FILE_NAME
|
|
96
|
+
if not extras_path.exists():
|
|
97
|
+
return (default_profile_home(),)
|
|
98
|
+
all_names = _read_list(extras_path)
|
|
99
|
+
if not isinstance(all_names, list) or not all_names or any(not isinstance(each_name, str) for each_name in all_names):
|
|
100
|
+
raise BrokerConfigurationError(f"invalid account list {extras_path}")
|
|
101
|
+
if len({each_name.casefold() for each_name in all_names}) != len(all_names):
|
|
102
|
+
raise BrokerConfigurationError(f"duplicate account in {extras_path}")
|
|
103
|
+
try:
|
|
104
|
+
return tuple(default_profile_home(validate_profile_name(each_name)) for each_name in all_names)
|
|
105
|
+
except ValueError as error:
|
|
106
|
+
raise BrokerConfigurationError(f"invalid account list {extras_path}: {error}") from error
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _append_claude_account(
|
|
110
|
+
all_accounts: list[Account], all_seen: set[str], account: Account, main_home: Path
|
|
111
|
+
) -> None:
|
|
112
|
+
key = str(account.home).casefold()
|
|
113
|
+
if key == str(main_home).casefold():
|
|
114
|
+
all_accounts[0] = Account(Product.CLAUDE, "main", main_home, True, account.command)
|
|
115
|
+
elif key not in all_seen:
|
|
116
|
+
all_accounts.append(account)
|
|
117
|
+
all_seen.add(key)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_claude_accounts() -> tuple[Account, ...]:
|
|
121
|
+
"""Read the Claude account roster.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Main and additional accounts in configured order.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
BrokerConfigurationError: An account list is unreadable.
|
|
128
|
+
"""
|
|
129
|
+
main_home = (Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME).resolve()
|
|
130
|
+
all_accounts = [Account(Product.CLAUDE, "main", main_home, True, "claude")]
|
|
131
|
+
all_seen = {str(main_home).casefold()}
|
|
132
|
+
for each_entry in _chain_entries(main_home / "claude-chain.json", main_home):
|
|
133
|
+
_append_claude_account(all_accounts, all_seen, each_entry, main_home)
|
|
134
|
+
for each_home in _extra_homes(main_home):
|
|
135
|
+
home = each_home.resolve()
|
|
136
|
+
_append_claude_account(all_accounts, all_seen, Account(Product.CLAUDE, home.name, home, command=home.name), main_home)
|
|
137
|
+
return tuple(all_accounts)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_codex_accounts() -> tuple[Account, ...]:
|
|
141
|
+
"""Read the Codex account roster.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Configured accounts, or an empty tuple when none are configured.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
BrokerConfigurationError: The configured roster is unreadable.
|
|
148
|
+
"""
|
|
149
|
+
profiles_root = codex_account_choice.default_profiles_root().resolve()
|
|
150
|
+
roster_path = profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME
|
|
151
|
+
configured = bool(os.environ.get(CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE, "").strip())
|
|
152
|
+
if not configured and not roster_path.exists():
|
|
153
|
+
return ()
|
|
154
|
+
if not configured and _read_list(roster_path) == []:
|
|
155
|
+
return ()
|
|
156
|
+
try:
|
|
157
|
+
names = codex_account_choice.codex_account_names(profiles_root)
|
|
158
|
+
except (OSError, ValueError) as error:
|
|
159
|
+
raise BrokerConfigurationError(f"cannot read account list {roster_path}: {error}") from error
|
|
160
|
+
return tuple(Account(Product.CODEX, name, (profiles_root / name).resolve()) for name in names)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_claude_meters(account: Account) -> Meters | None:
|
|
164
|
+
"""Probe one Claude account.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
account: Account whose meter is read.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Its meters, or None when the probe fails.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME)
|
|
174
|
+
except (WeeklyUtilizationProbeError, OSError):
|
|
175
|
+
return None
|
|
176
|
+
return Meters(
|
|
177
|
+
FULL_PERCENT - usage.session_utilization if usage.session_utilization is not None else None,
|
|
178
|
+
usage.session_resets_at,
|
|
179
|
+
FULL_PERCENT - usage.weekly_utilization if usage.weekly_utilization is not None else None,
|
|
180
|
+
usage.weekly_resets_at,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def read_codex_account_meters(account: Account) -> Meters | None:
|
|
185
|
+
"""Probe one Codex account.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
account: Account whose meter is read.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Its meters, or None when the probe fails.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
codex_path = codex_account_meters.resolve_codex_path(None)
|
|
195
|
+
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
196
|
+
except (codex_account_meters.CodexMeterUnreadError, OSError):
|
|
197
|
+
return None
|
|
198
|
+
weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
|
|
199
|
+
short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
|
|
200
|
+
weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
|
|
201
|
+
short_window = max(short, key=lambda window: window.used_percent) if short else None
|
|
202
|
+
return Meters(
|
|
203
|
+
usage.short_window_percent_left,
|
|
204
|
+
short_window.resets_at if short_window else None,
|
|
205
|
+
FULL_PERCENT - weekly_window.used_percent if weekly_window else None,
|
|
206
|
+
weekly_window.resets_at if weekly_window else None,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
all_product_adapters = {
|
|
211
|
+
Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES, True),
|
|
212
|
+
Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures(), False),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def broker_state_path() -> Path:
|
|
217
|
+
return Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME / BROKER_STATE_DIRECTORY_NAME / BROKER_STATE_FILE_NAME
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _load_state(path: Path) -> dict[str, object]:
|
|
221
|
+
if not path.exists():
|
|
222
|
+
return {"meters": {}, "spent": {}, "affinity": {}}
|
|
223
|
+
try:
|
|
224
|
+
parsed_state = json.loads(path.read_text(encoding="utf-8"))
|
|
225
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
226
|
+
return {"meters": {}, "spent": {}, "affinity": {}}
|
|
227
|
+
if not isinstance(parsed_state, dict):
|
|
228
|
+
return {"meters": {}, "spent": {}, "affinity": {}}
|
|
229
|
+
return {key: parsed_state.get(key) if isinstance(parsed_state.get(key), dict) else {} for key in ("meters", "spent", "affinity")}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if sys.platform == "win32":
|
|
233
|
+
|
|
234
|
+
def _acquire_state_lock(lock_descriptor: int) -> None:
|
|
235
|
+
os.lseek(lock_descriptor, 0, os.SEEK_SET)
|
|
236
|
+
msvcrt.locking(lock_descriptor, msvcrt.LK_LOCK, 1)
|
|
237
|
+
|
|
238
|
+
def _release_state_lock(lock_descriptor: int) -> None:
|
|
239
|
+
os.lseek(lock_descriptor, 0, os.SEEK_SET)
|
|
240
|
+
msvcrt.locking(lock_descriptor, msvcrt.LK_UNLCK, 1)
|
|
241
|
+
|
|
242
|
+
else:
|
|
243
|
+
|
|
244
|
+
def _acquire_state_lock(lock_descriptor: int) -> None:
|
|
245
|
+
fcntl.flock(lock_descriptor, fcntl.LOCK_EX)
|
|
246
|
+
|
|
247
|
+
def _release_state_lock(lock_descriptor: int) -> None:
|
|
248
|
+
fcntl.flock(lock_descriptor, fcntl.LOCK_UN)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@contextmanager
|
|
252
|
+
def _state_lock(path: Path) -> Iterator[None]:
|
|
253
|
+
"""Hold an exclusive operating-system lock on the state file's sibling lock file."""
|
|
254
|
+
lock_descriptor = os.open(path.with_name(path.name + BROKER_STATE_LOCK_SUFFIX), os.O_CREAT | os.O_RDWR)
|
|
255
|
+
try:
|
|
256
|
+
_acquire_state_lock(lock_descriptor)
|
|
257
|
+
try:
|
|
258
|
+
yield
|
|
259
|
+
finally:
|
|
260
|
+
_release_state_lock(lock_descriptor)
|
|
261
|
+
finally:
|
|
262
|
+
os.close(lock_descriptor)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _read_at(entry: object) -> float:
|
|
266
|
+
if not isinstance(entry, dict):
|
|
267
|
+
return float("-inf")
|
|
268
|
+
read_at = entry.get("read_at")
|
|
269
|
+
return float(read_at) if isinstance(read_at, (int, float)) else float("-inf")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _merged_state(all_saved_state: dict[str, object], all_unsaved_state: dict[str, object]) -> dict[str, object]:
|
|
273
|
+
"""Combine two state documents so each writer keeps the other writer's entries.
|
|
274
|
+
|
|
275
|
+
A spent mark keeps its latest reset, a meter entry keeps its newest read,
|
|
276
|
+
and the unsaved session bindings take their keys.
|
|
277
|
+
"""
|
|
278
|
+
all_meters = dict(all_saved_state["meters"])
|
|
279
|
+
for each_key, each_entry in all_unsaved_state["meters"].items():
|
|
280
|
+
if _read_at(each_entry) >= _read_at(all_meters.get(each_key)):
|
|
281
|
+
all_meters[each_key] = each_entry
|
|
282
|
+
all_spent = dict(all_saved_state["spent"])
|
|
283
|
+
for each_key, each_reset in all_unsaved_state["spent"].items():
|
|
284
|
+
saved_reset = all_spent.get(each_key)
|
|
285
|
+
if not isinstance(saved_reset, (int, float)) or (isinstance(each_reset, (int, float)) and each_reset > saved_reset):
|
|
286
|
+
all_spent[each_key] = each_reset
|
|
287
|
+
return {"meters": all_meters, "spent": all_spent, "affinity": {**all_saved_state["affinity"], **all_unsaved_state["affinity"]}}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _save_state(path: Path, all_state: dict[str, object]) -> None:
|
|
291
|
+
"""Merge the caller's state into the saved file under a lock, then refresh the caller's copy."""
|
|
292
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
with _state_lock(path):
|
|
294
|
+
all_state.update(_merged_state(_load_state(path), all_state))
|
|
295
|
+
temporary_name: str | None = None
|
|
296
|
+
try:
|
|
297
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix="state-", suffix=BROKER_STATE_TEMP_SUFFIX, delete=False, newline="\n") as stream:
|
|
298
|
+
temporary_name = stream.name
|
|
299
|
+
json.dump(all_state, stream, indent=REPORT_INDENT_SPACES, sort_keys=True)
|
|
300
|
+
stream.write("\n")
|
|
301
|
+
os.replace(temporary_name, path)
|
|
302
|
+
finally:
|
|
303
|
+
if temporary_name is not None and os.path.exists(temporary_name):
|
|
304
|
+
os.unlink(temporary_name)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _state_key(account: Account) -> str:
|
|
308
|
+
return f"{account.product.value}:{account.name}:{account.home}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _meter_payload(meters: Meters | None) -> dict[str, object] | None:
|
|
312
|
+
if meters is None:
|
|
313
|
+
return None
|
|
314
|
+
return {
|
|
315
|
+
"session_percent_left": meters.session_percent_left,
|
|
316
|
+
"session_resets_at": utc_time_text(meters.session_resets_at),
|
|
317
|
+
"weekly_percent_left": meters.weekly_percent_left,
|
|
318
|
+
"weekly_resets_at": utc_time_text(meters.weekly_resets_at),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _meters_from_payload(raw_meters: object) -> Meters | None:
|
|
323
|
+
if not isinstance(raw_meters, dict):
|
|
324
|
+
return None
|
|
325
|
+
all_percentages = (raw_meters.get("session_percent_left"), raw_meters.get("weekly_percent_left"))
|
|
326
|
+
if any(each_percent is not None and not isinstance(each_percent, (int, float)) for each_percent in all_percentages):
|
|
327
|
+
return None
|
|
328
|
+
return Meters(all_percentages[0], parse_utc_time(raw_meters.get("session_resets_at")), all_percentages[1], parse_utc_time(raw_meters.get("weekly_resets_at")))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict[str, object], now: datetime) -> tuple[Reading, bool]:
|
|
332
|
+
key = _state_key(account)
|
|
333
|
+
cached = all_cache.get(key)
|
|
334
|
+
if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
|
|
335
|
+
return Reading(account, _meters_from_payload(cached.get("meters"))), False
|
|
336
|
+
try:
|
|
337
|
+
meters = adapter.read_meters(account)
|
|
338
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
|
|
339
|
+
meters = None
|
|
340
|
+
all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters)}
|
|
341
|
+
return Reading(account, meters), True
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def read_accounts(product: Product, adapter: ProductAdapter | None = None, *, all_state: dict[str, object] | None = None, now: datetime | None = None) -> tuple[Reading, ...]:
|
|
345
|
+
"""Read account meters with a 60-second cache.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
product: Product whose accounts are read.
|
|
349
|
+
adapter: Injected account and meter readers.
|
|
350
|
+
all_state: Mutable broker state document.
|
|
351
|
+
now: Clock instant used for cache age.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Account readings in roster order.
|
|
355
|
+
"""
|
|
356
|
+
active = adapter or all_product_adapters[product]
|
|
357
|
+
current = now or datetime.now(timezone.utc)
|
|
358
|
+
all_cache = all_state.get("meters", {}) if all_state is not None else {}
|
|
359
|
+
all_readings: list[Reading] = []
|
|
360
|
+
is_updated = False
|
|
361
|
+
for each_account in active.load_accounts():
|
|
362
|
+
reading, did_read = _read_one_account(each_account, active, all_cache, current)
|
|
363
|
+
all_readings.append(reading)
|
|
364
|
+
is_updated = is_updated or did_read
|
|
365
|
+
if is_updated and all_state is not None:
|
|
366
|
+
_save_state(broker_state_path(), all_state)
|
|
367
|
+
return tuple(all_readings)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _session_id_from_json(value: str) -> str | None:
|
|
371
|
+
try:
|
|
372
|
+
payload = json.loads(value)
|
|
373
|
+
except json.JSONDecodeError:
|
|
374
|
+
return None
|
|
375
|
+
session_id = payload.get("session_id") if isinstance(payload, dict) else None
|
|
376
|
+
return session_id if isinstance(session_id, str) and session_id else None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def extract_session_id_from_stdout(stdout: str) -> str | None:
|
|
380
|
+
"""Find a session id in JSON or newline-delimited JSON.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
stdout: Captured command output.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Session id, if present.
|
|
387
|
+
"""
|
|
388
|
+
return _session_id_from_json(stdout.strip()) or next(
|
|
389
|
+
(found for line in stdout.splitlines() if (found := _session_id_from_json(line.strip())) is not None),
|
|
390
|
+
None,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _resume_id(all_argv: Sequence[str]) -> str | None:
|
|
395
|
+
for each_index, each_argument in enumerate(all_argv):
|
|
396
|
+
if each_argument == "--resume" and each_index + 1 < len(all_argv):
|
|
397
|
+
candidate = all_argv[each_index + 1]
|
|
398
|
+
return candidate if candidate and not candidate.startswith("-") else None
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subprocess.CompletedProcess[str]:
|
|
403
|
+
encoding = str(options.get("encoding") or "utf-8")
|
|
404
|
+
errors = str(options.get("errors") or "replace")
|
|
405
|
+
stdin_bytes = options.get("input")
|
|
406
|
+
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
|
407
|
+
completion = subprocess.run(
|
|
408
|
+
list(all_argv),
|
|
409
|
+
input=stdin_bytes,
|
|
410
|
+
stdout=stdout_file,
|
|
411
|
+
stderr=stderr_file,
|
|
412
|
+
env=options.get("env"),
|
|
413
|
+
cwd=options.get("cwd"),
|
|
414
|
+
timeout=options.get("timeout"),
|
|
415
|
+
check=False,
|
|
416
|
+
)
|
|
417
|
+
stdout_file.seek(0)
|
|
418
|
+
stderr_file.seek(0)
|
|
419
|
+
stdout = stdout_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
|
|
420
|
+
stderr = stderr_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
|
|
421
|
+
return subprocess.CompletedProcess(list(all_argv), completion.returncode, stdout, stderr)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
subprocess_runner: SubprocessRunner = _run_captured_subprocess
|
|
425
|
+
_subprocess_runner_lock = threading.Lock()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@contextmanager
|
|
429
|
+
def override_subprocess_runner(runner: SubprocessRunner) -> Iterator[SubprocessRunner]:
|
|
430
|
+
"""Replace the captured subprocess runner within a context.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
runner: Replacement runner.
|
|
434
|
+
|
|
435
|
+
Yields:
|
|
436
|
+
Runner that was active before replacement.
|
|
437
|
+
"""
|
|
438
|
+
global subprocess_runner
|
|
439
|
+
with _subprocess_runner_lock:
|
|
440
|
+
previous = subprocess_runner
|
|
441
|
+
subprocess_runner = runner
|
|
442
|
+
try:
|
|
443
|
+
yield previous
|
|
444
|
+
finally:
|
|
445
|
+
subprocess_runner = previous
|