feat: vibed out some slop over here also

This commit is contained in:
h
2026-05-19 11:20:14 +02:00
commit bf6116dc8b
34 changed files with 6531 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
.venv
.idea
t
.coverage
.pytest_cache
.ruff_cache
.mypy_cache
+18
View File
@@ -0,0 +1,18 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13
hooks:
- id: ruff-check
types_or: [python, pyi]
args: [--fix]
- id: ruff-format
types_or: [python, pyi]
- repo: local
hooks:
- id: ty
name: ty check
entry: uvx ty check
language: python
types_or: [python, pyi]
pass_filenames: false
+106
View File
@@ -0,0 +1,106 @@
# claude-code-api
Python wrapper around the `claude` CLI for subscription-mode (no API key)
backends. Drives one long-running interactive `claude` per conversation via
a PTY and reads events from the JSONL session file; the public surface is
Anthropic-Messages-API shaped so a gateway in front of it is a one-liner
serializer away.
Not affiliated with Anthropic. You need a working subscription, the
`claude` CLI on PATH, and to have run `claude /login` once.
## Install
As a library inside another project:
```bash
uv add "claude-code-api @ git+https://git.kotikot.com/beaver/claude-code-api"
```
The runtime needs only `ptyprocess`.
## Use
```python
import asyncio
from claude_code_api import BackendOptions, ClaudeCodeBackend
async def main() -> None:
opts = BackendOptions(cwd="/path/to/project", dangerously_skip_permissions=True)
async with ClaudeCodeBackend(opts) as backend:
async for event in backend.complete(
[{"role": "user", "content": "say hi"}]
):
print(event)
asyncio.run(main())
```
Multi-turn works by construction — append the assistant reply + a fresh
user message to the same `messages` list and call `complete()` again. The
backend fingerprints `messages[:-1]`, finds the live PTY from the previous
turn, and reuses it (so the server-side prompt cache stays warm):
```python
history = [{"role": "user", "content": "remember Beaver"}]
async for ev in backend.complete(history): ...
history += [
{"role": "assistant", "content": [{"type": "text", "text": "OK"}]},
{"role": "user", "content": "what was the codeword?"},
]
async for ev in backend.complete(history): ...
```
## Public surface
Events (Anthropic-style, vendored to keep the dep tree empty):
`AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage`,
`TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`.
Errors: `BackendError` (root), `AuthError`, `ProcessError`,
`CLINotFoundError`, `RateLimitError`, `SessionError`, `MessageParseError`.
Backend: `ClaudeCodeBackend(opts).complete(messages)` is an async
generator of events. `BackendOptions` exposes model / system prompt /
allowed-tools / `mcp_servers` / permission mode / history injection mode.
Lower layers (`PtyClaudeProcess`, `JsonlWatcher`, `TurnManager`,
`normalize`) are re-exported for callers that want to assemble their own
session orchestration.
## How a turn works
1. The backend looks up a live session by `hash_history(messages[:-1])`.
If one matches, the new user message goes straight into its PTY.
2. If nothing matches and `messages[:-1]` is empty, a fresh `claude` is
spawned with a brand-new `--session-id`.
3. If `messages[:-1]` is non-empty (a continuation we don't have a live
PTY for — e.g. after restart), the backend writes a hand-crafted
JSONL transcript at `~/.claude/projects/<key>/<id>.jsonl` and spawns
`claude --resume <id>`. That is the `native_jsonl` injection mode;
the fallback is `concat_message`, which folds the prior history into
one large first prompt.
4. The PTY's stdout is drained continuously by a background thread; we
never read events from there. The JSONL file is tailed at 100ms
cadence and each new record is normalized into a typed `Event`.
5. The turn closes on the first `assistant` record with `stop_reason ∈
{end_turn, max_tokens, stop_sequence, refusal}`. A `ResultMessage`
is synthesized from its `usage` and yielded last.
## Examples
- `examples/basic_usage.py` — one turn, real `claude`.
- `examples/multi_turn.py` — two turns sharing one live PTY.
- `examples/mcp_tool.py` — wire up the bundled echo MCP server and let
the model call it.
## Tests
```bash
uv run pytest # unit tests (fast, no real claude)
RUN_CLAUDE_SMOKE=1 uv run pytest tests/test_pty.py tests/test_turn.py tests/test_backend.py
```
The smoke-marked tests spawn a real `claude` process and need a logged-in
subscription on the host.
+46
View File
@@ -0,0 +1,46 @@
"""Minimal end-to-end example: one turn against a real `claude` CLI.
Prerequisites:
1. `claude` is on PATH and the user is logged in (`claude /login` once).
2. Your subscription is healthy (no rate limit, no auth block).
Run:
python examples/basic_usage.py
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from claude_code_api import (
AssistantMessage,
BackendOptions,
ClaudeCodeBackend,
ResultMessage,
TextBlock,
)
CWD = Path(os.environ.get("CLAUDE_CODE_CWD", Path.cwd())).resolve()
async def main() -> None:
opts = BackendOptions(cwd=str(CWD), dangerously_skip_permissions=True)
async with ClaudeCodeBackend(opts) as backend:
async for event in backend.complete(
[{"role": "user", "content": "Reply with the single word: OK"}]
):
if isinstance(event, AssistantMessage):
for block in event.content:
if isinstance(block, TextBlock):
print(f"[assistant] {block.text.strip()}")
elif isinstance(event, ResultMessage):
print(
f"[result] stop_reason={event.stop_reason} "
f"duration_ms={event.duration_ms}"
)
if __name__ == "__main__":
asyncio.run(main())
+67
View File
@@ -0,0 +1,67 @@
"""Tool calling via a stdio MCP server.
`BackendOptions.mcp_servers` materializes into a temp `--mcp-config` JSON
file under the hood. The model decides when to invoke the tool, claude's
TUI runs the round-trip, and the events stream surfaces a `ToolUseBlock`
plus its `ToolResultBlock`.
This example wires the bundled echo server at `scripts/echo_mcp_server.py`
— same one the smoke test uses.
Run:
python examples/mcp_tool.py
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
from claude_code_api import (
AssistantMessage,
BackendOptions,
ClaudeCodeBackend,
ResultMessage,
TextBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
ECHO_SCRIPT = REPO_ROOT / "scripts" / "echo_mcp_server.py"
CWD = Path(os.environ.get("CLAUDE_CODE_CWD", Path.cwd())).resolve()
async def main() -> None:
opts = BackendOptions(
cwd=str(CWD),
dangerously_skip_permissions=True,
mcp_servers={
"echo": {"command": sys.executable, "args": [str(ECHO_SCRIPT)]},
},
)
async with ClaudeCodeBackend(opts) as backend:
prompt = (
"Call the `mcp__echo__echo` tool with text='ping' and then "
"tell me what it returned. Reply with one short sentence."
)
async for event in backend.complete([{"role": "user", "content": prompt}]):
if isinstance(event, AssistantMessage):
for block in event.content:
if isinstance(block, TextBlock) and block.text.strip():
print(f"[assistant] {block.text.strip()}")
elif isinstance(block, ToolUseBlock):
print(f"[tool_use] {block.name}({block.input})")
elif isinstance(event, UserMessage) and isinstance(event.content, list):
for block in event.content:
if isinstance(block, ToolResultBlock):
print(f"[tool_res] {block.content!r}")
elif isinstance(event, ResultMessage):
print(f"[result] stop_reason={event.stop_reason}")
if __name__ == "__main__":
asyncio.run(main())
+69
View File
@@ -0,0 +1,69 @@
"""Multi-turn: same backend, two turns, context persists across the pair.
The backend fingerprints `messages[:-1]` against its pool of live PTYs.
After turn 1 finishes, we re-send the conversation with the assistant's
reply appended and one new user message — fingerprint matches, the same
process is reused, and the server-side prompt cache keeps the context
without us paying a fresh-spawn tax.
Run:
python examples/multi_turn.py
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Any
from claude_code_api import (
AssistantMessage,
BackendOptions,
ClaudeCodeBackend,
ResultMessage,
TextBlock,
)
CWD = Path(os.environ.get("CLAUDE_CODE_CWD", Path.cwd())).resolve()
def assistant_text(events: list[Any]) -> str:
for ev in reversed(events):
if isinstance(ev, AssistantMessage):
return "".join(b.text for b in ev.content if isinstance(b, TextBlock)).strip()
return ""
async def run_turn(
backend: ClaudeCodeBackend, history: list[dict[str, Any]]
) -> str:
events: list[Any] = []
async for event in backend.complete(history):
events.append(event)
if isinstance(event, ResultMessage):
print(f" → stop_reason={event.stop_reason} duration_ms={event.duration_ms}")
return assistant_text(events)
async def main() -> None:
opts = BackendOptions(cwd=str(CWD), dangerously_skip_permissions=True)
async with ClaudeCodeBackend(opts) as backend:
history: list[dict[str, Any]] = [
{"role": "user", "content": "Remember the codeword: Beaver. Reply OK."}
]
print("[turn 1]")
reply1 = await run_turn(backend, history)
print(f" [assistant] {reply1}")
history += [
{"role": "assistant", "content": [{"type": "text", "text": reply1}]},
{"role": "user", "content": "What was the codeword? Reply with one word."},
]
print("[turn 2]")
reply2 = await run_turn(backend, history)
print(f" [assistant] {reply2}")
if __name__ == "__main__":
asyncio.run(main())
+36
View File
@@ -0,0 +1,36 @@
[project]
name = "claude-code-api"
version = "0.1.0"
description = "PTY-based wrapper around the `claude` CLI for subscription-mode backends."
requires-python = ">=3.11"
readme = "README.md"
authors = [
{ name = "h", email = "h@kotikot.com" }
]
dependencies = [
"ptyprocess>=0.7",
]
[build-system]
requires = ["uv_build>=0.11,<0.12"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = ["claude_code_api"]
[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=0.23",
"pre-commit>=4.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
"live: hits a real `claude` CLI — needs RUN_CLAUDE_SMOKE=1 and a logged-in subscription",
]
filterwarnings = [
"ignore:.*forkpty\\(\\) may lead to deadlocks.*:DeprecationWarning",
]
+10
View File
@@ -0,0 +1,10 @@
{
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.11",
"include": ["claude_code_api", "tests"],
"exclude": [".venv", "**/__pycache__", ".pytest_cache"],
"extraPaths": ["."],
"reportMissingImports": "error",
"reportMissingTypeStubs": "none"
}
+40
View File
@@ -0,0 +1,40 @@
target-version = "py311"
[lint]
select = ["ALL"]
ignore = [
"D203",
"D212",
"COM812",
"T201",
"D1",
"PLC0415",
"ANN401",
"PLR0913",
"PLR2004",
"C901",
"PLR0911",
"PLR0912",
"PLR0915",
"BLE001",
"ASYNC109",
"ASYNC110",
"TRY004",
]
unfixable = ["F401"]
[lint.per-file-ignores]
"tests/*" = ["ALL"]
"examples/*" = ["ALL"]
"scripts/*" = ["ALL"]
"*.lock" = ["ALL"]
[lint.pydocstyle]
convention = "google"
[lint.isort]
split-on-trailing-comma = false
[format]
docstring-code-format = true
skip-magic-trailing-comma = true
+178
View File
@@ -0,0 +1,178 @@
"""Minimal stdio MCP server with one tool: `echo`.
Used by the tool-calling smoke test to exercise the `--mcp-config` path
end-to-end with a real `claude` interactive process. We hand-roll the
JSON-RPC framing rather than pulling in the `mcp` Python SDK so the test
has the same zero-dep posture as the rest of the package.
Protocol surface (Model Context Protocol, stdio transport):
- `initialize` -> capabilities + serverInfo + protocolVersion
- `notifications/initialized` -> no response
- `tools/list` -> list of {name, description, inputSchema}
- `tools/call` -> content blocks for the named tool
- `ping` -> empty result
The `echo` tool returns its `text` argument verbatim as a single
`{"type":"text","text":...}` content block. That is enough for claude to
surface a `tool_use` -> `tool_result` pair in the JSONL session file.
Frame format on the wire is the stdio MCP convention: one JSON object per
line on stdin/stdout. We deliberately do NOT speak the alternative
`Content-Length`-framed flavor — claude's `--mcp-config` stdio transport
uses line framing for spawn-and-pipe servers.
Run standalone (for protocol sanity-checking) with:
printf '%s\\n%s\\n%s\\n' \\
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}' \\
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \\
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' \\
| python scripts/echo_mcp_server.py
"""
from __future__ import annotations
import json
import os
import sys
import traceback
from pathlib import Path
from typing import Any
_LOG_PATH = os.environ.get("ECHO_MCP_LOG")
def _log(message: str) -> None:
if not _LOG_PATH:
return
try:
with Path(_LOG_PATH).open("a", encoding="utf-8") as f:
f.write(message.rstrip("\n") + "\n")
except OSError:
pass
DEFAULT_PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "echo-server"
SERVER_VERSION = "0.0.1"
ECHO_TOOL = {
"name": "echo",
"description": "Return the supplied `text` argument verbatim.",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to echo back."},
},
"required": ["text"],
"additionalProperties": False,
},
}
def _write(message: dict[str, Any]) -> None:
"""Emit one JSON-RPC message as a single newline-terminated stdout line."""
sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _ok(req_id: Any, result: dict[str, Any]) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def _err(req_id: Any, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
def _handle_initialize(params: dict[str, Any]) -> dict[str, Any]:
requested = params.get("protocolVersion") if isinstance(params, dict) else None
version = requested if isinstance(requested, str) else DEFAULT_PROTOCOL_VERSION
return {
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
}
def _handle_tools_list() -> dict[str, Any]:
return {"tools": [ECHO_TOOL]}
def _handle_tools_call(params: dict[str, Any]) -> dict[str, Any]:
name = params.get("name") if isinstance(params, dict) else None
arguments = params.get("arguments") if isinstance(params, dict) else None
if name != "echo":
return {
"content": [{"type": "text", "text": f"unknown tool: {name!r}"}],
"isError": True,
}
text = ""
if isinstance(arguments, dict):
raw = arguments.get("text", "")
text = raw if isinstance(raw, str) else json.dumps(raw)
return {"content": [{"type": "text", "text": text}], "isError": False}
def _dispatch(message: dict[str, Any]) -> dict[str, Any] | None:
method = message.get("method")
req_id = message.get("id")
params = message.get("params") or {}
is_notification = "id" not in message
if method == "initialize":
return _ok(req_id, _handle_initialize(params))
if method == "notifications/initialized":
return None
if method == "tools/list":
return _ok(req_id, _handle_tools_list())
if method == "tools/call":
return _ok(req_id, _handle_tools_call(params))
if method == "ping":
return _ok(req_id, {})
if method == "prompts/list":
return _ok(req_id, {"prompts": []})
if method == "resources/list":
return _ok(req_id, {"resources": []})
if method == "resources/templates/list":
return _ok(req_id, {"resourceTemplates": []})
if is_notification:
return None
return _err(req_id, -32601, f"method not found: {method!r}")
def main() -> int:
_log(f"START pid={os.getpid()} python={sys.executable} cwd={Path.cwd()}")
try:
while True:
raw_line = sys.stdin.readline()
if not raw_line:
_log("EOF on stdin")
break
line = raw_line.strip()
if not line:
continue
_log(f"RX {line[:2000]}")
try:
message = json.loads(line)
except json.JSONDecodeError as exc:
_log(f"parse error: {exc}")
_write(_err(None, -32700, f"parse error: {exc}"))
continue
if not isinstance(message, dict):
_write(_err(None, -32600, "invalid request: not a JSON object"))
continue
response = _dispatch(message)
if response is not None:
_log(f"TX {json.dumps(response)[:2000]}")
_write(response)
except Exception as exc:
_log("CRASH: " + "".join(traceback.format_exception(exc)))
raise
_log("EXIT 0")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
"""Re-derive `claude_code_api.models` constants from a `claude` binary.
The `claude` CLI is a Mach-O (or ELF) executable that bundles its JS
runtime as plain strings. Model ids and the `/model` alias list show up
verbatim — no decryption needed. This script `strings`-greps a given
binary and prints a refreshed `src/claude_code_api/models.py`.
Usage:
uv run python scripts/extract_models.py # auto-locate
uv run python scripts/extract_models.py /path/to/claude # explicit
Auto-location order (macOS):
~/.local/share/claude/versions/<latest>
$(which claude) (follows symlinks)
"""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import sys
from pathlib import Path
_MODEL_ID_RE = re.compile(
rb'"(claude-(?:opus|sonnet|haiku|[0-9])[a-z0-9-]*-(?:opus|sonnet|haiku|[0-9])[a-z0-9-]*)"'
)
_ALIAS_LIST_RE = re.compile(rb'\["sonnet","opus","haiku".*?"opusplan"\]')
def find_default_binary() -> Path | None:
home = Path.home()
versions_dir = home / ".local" / "share" / "claude" / "versions"
if versions_dir.is_dir():
versions = sorted(
(p for p in versions_dir.iterdir() if p.is_file()),
key=lambda p: p.name,
)
if versions:
return versions[-1]
on_path = shutil.which("claude")
if on_path is not None:
return Path(on_path).resolve()
return None
def extract_strings(binary: Path) -> bytes:
result = subprocess.run(
["strings", str(binary)],
check=True,
capture_output=True,
)
return result.stdout
def extract_model_ids(blob: bytes) -> set[str]:
return {m.group(1).decode("ascii") for m in _MODEL_ID_RE.finditer(blob)}
def extract_aliases(blob: bytes) -> list[str]:
match = _ALIAS_LIST_RE.search(blob)
if match is None:
return []
raw = match.group(0).decode("ascii")
return re.findall(r'"([^"]+)"', raw)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("binary", nargs="?", help="Path to claude executable")
args = parser.parse_args()
binary = Path(args.binary) if args.binary else find_default_binary()
if binary is None or not binary.is_file():
print(f"could not locate claude binary: {binary}", file=sys.stderr)
return 2
blob = extract_strings(binary)
ids = sorted(extract_model_ids(blob))
aliases = extract_aliases(blob)
print(f"# binary: {binary}", file=sys.stderr)
print(f"# {len(ids)} model ids, {len(aliases)} aliases", file=sys.stderr)
print("aliases:", aliases)
print("models:")
for model_id in ids:
print(f" {model_id}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+79
View File
@@ -0,0 +1,79 @@
"""PTY-based wrapper around the `claude` CLI for subscription-mode backends.
`ClaudeCodeBackend` + `BackendOptions` is the surface a gateway
consumes. `TurnManager` and the typed events / errors are re-exported for
callers that want to assemble the lower layers directly (e.g. tests, custom
session orchestration).
"""
from claude_code_api.backend import (
BackendOptions,
ClaudeCodeBackend,
HistoryInjectionMode,
)
from claude_code_api.errors import (
AuthError,
BackendError,
CLINotFoundError,
MessageParseError,
ProcessError,
RateLimitError,
SessionError,
classify_pty_failure,
)
from claude_code_api.events import (
AssistantMessage,
ContentBlock,
Event,
ResultMessage,
SystemMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
from claude_code_api.models import (
ALIASES,
DISPLAY_NAMES,
MODELS_ALL,
MODELS_CURRENT,
MODELS_LEGACY,
is_valid_model,
)
from claude_code_api.normalizer import normalize
from claude_code_api.turn import TurnManager
__version__ = "0.1.0"
__all__ = [
"ALIASES",
"DISPLAY_NAMES",
"MODELS_ALL",
"MODELS_CURRENT",
"MODELS_LEGACY",
"AssistantMessage",
"AuthError",
"BackendError",
"BackendOptions",
"CLINotFoundError",
"ClaudeCodeBackend",
"ContentBlock",
"Event",
"HistoryInjectionMode",
"MessageParseError",
"ProcessError",
"RateLimitError",
"ResultMessage",
"SessionError",
"SystemMessage",
"TextBlock",
"ThinkingBlock",
"ToolResultBlock",
"ToolUseBlock",
"TurnManager",
"UserMessage",
"classify_pty_failure",
"is_valid_model",
"normalize",
]
+404
View File
@@ -0,0 +1,404 @@
"""The gateway-facing public API.
`ClaudeCodeBackend` is the only class the gateway needs to know
about. It owns:
- a pool of live `claude` sessions, keyed by a fingerprint of conversation
history, so a continuing turn reuses an existing PTY (and the
server-side prompt cache) instead of paying a fresh-spawn tax;
- the choice between `native_jsonl` (default) and `concat_message`
(fallback) for seeding a session with prior history that the gateway
sends in but no live session matches;
- the conversion from `BackendOptions` (high-level, takes a dict of MCP
servers) into `PtyProcessOptions` (low-level, takes argv-ready flags),
including materializing an `--mcp-config` file when `mcp_servers` is set.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import tempfile
import uuid
from collections.abc import AsyncIterator, Callable, Iterable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Self
from claude_code_api.errors import MessageParseError
from claude_code_api.events import (
AssistantMessage,
ContentBlock,
Event,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
)
from claude_code_api.injection import (
build_concat_prompt,
build_seed_jsonl,
hash_history,
)
from claude_code_api.paths import resolve_jsonl_path
from claude_code_api.pty import PtyClaudeProcess, PtyProcessOptions
from claude_code_api.turn import TurnManager
from claude_code_api.watcher import JsonlWatcher
HistoryInjectionMode = Literal["native_jsonl", "concat_message"]
ParseErrorCallback = Callable[[MessageParseError, dict[str, Any]], None]
_TERMINAL_STOP_REASONS: frozenset[str] = frozenset(
{"end_turn", "max_tokens", "stop_sequence", "refusal"}
)
@dataclass(frozen=True)
class BackendOptions:
"""High-level configuration for `ClaudeCodeBackend`.
Mirrors `PtyProcessOptions` shape but speaks the gateway's vocabulary:
`mcp_servers` is a `{name: config}` mapping (materialized into a temp
`--mcp-config` file under the hood) rather than a tuple of file paths.
"""
cwd: str | os.PathLike[str]
model: str | None = None
system_prompt: str | None = None
append_system_prompt: str | None = None
allowed_tools: tuple[str, ...] = ()
disallowed_tools: tuple[str, ...] = ()
mcp_servers: Mapping[str, Mapping[str, Any]] | None = None
permission_mode: str = "bypassPermissions"
dangerously_skip_permissions: bool = False
effort: str | None = None
add_dir: tuple[str, ...] = ()
settings: str | None = None
extra_args: tuple[str, ...] = ()
extra_env: Mapping[str, str] = field(default_factory=dict)
preserve_provider_env: bool = False
history_injection_mode: HistoryInjectionMode = "native_jsonl"
wait_for_turn_duration: bool = False
include_meta_user: bool = False
startup_delay: float = 1.0
file_wait_timeout: float = 30.0
turn_duration_timeout: float = 5.0
@dataclass
class _LiveSession:
"""One live PTY + watcher + turn manager. Created per conversation."""
pty: PtyClaudeProcess
watcher: JsonlWatcher
tm: TurnManager
@property
def session_id(self) -> str:
return self.pty.session_id
async def aclose(self) -> None:
await self.tm.aclose()
SessionFactory = Callable[
["ClaudeCodeBackend", str, bool, Path, int],
"asyncio.Future[_LiveSession] | _LiveSession",
]
class ClaudeCodeBackend:
"""Persistent multi-session wrapper around the subscription `claude` CLI.
Lifecycle:
async with ClaudeCodeBackend(opts) as backend:
async for event in backend.complete([{"role": "user", "content": "hi"}]):
...
Each call to `complete()` either reuses a live PTY (if the new
`messages[:-1]` matches one we already have running) or spawns a fresh
session, optionally seeding it with prior history. On success, the
session is stashed under a new fingerprint that incorporates this
turn, so the next request can find it.
"""
def __init__(
self,
options: BackendOptions,
*,
on_parse_error: ParseErrorCallback | None = None,
_session_factory: SessionFactory | None = None,
) -> None:
self._opts = options
self._on_parse_error = on_parse_error
self._sessions: dict[str, _LiveSession] = {}
self._mcp_config_path: Path | None = None
self._session_factory = _session_factory
self._closed = False
self._lock = asyncio.Lock()
@property
def options(self) -> BackendOptions:
return self._opts
@property
def live_session_count(self) -> int:
return len(self._sessions)
async def complete(self, messages: list[Mapping[str, Any]]) -> AsyncIterator[Event]:
"""Run one turn against the matching session (or spawn one).
`messages` is an Anthropic-Messages-API style list — alternating
user/assistant entries ending with a user entry. The backend uses
`messages[:-1]` to look up a live session by fingerprint; if none
matches it creates one (seeded with that history if non-empty).
Yields typed events as they arrive; the final event is the
synthesized `ResultMessage` from `TurnManager`.
"""
if self._closed:
msg = "ClaudeCodeBackend is closed"
raise RuntimeError(msg)
if not messages:
msg = "messages must not be empty"
raise ValueError(msg)
last = messages[-1]
if last.get("role") != "user":
msg = "last message must have role='user'"
raise ValueError(msg)
last_text = _user_text_payload(last.get("content"))
async with self._lock:
prior = list(messages[:-1])
fp_prior = hash_history(prior)
session: _LiveSession
if prior and fp_prior in self._sessions:
session = self._sessions.pop(fp_prior)
send_text = last_text
else:
session = await self._create_session(prior)
if prior and self._opts.history_injection_mode == "concat_message":
send_text = build_concat_prompt(prior, last_text)
else:
send_text = last_text
events: list[Event] = []
try:
async for event in session.tm.send_user_message(send_text):
events.append(event)
yield event
except BaseException:
with contextlib.suppress(Exception):
await session.aclose()
raise
synthetic_asst = _synthesize_assistant_dict(events)
new_history = [*list(messages), synthetic_asst]
self._sessions[hash_history(new_history)] = session
async def aclose(self) -> None:
"""Shut down all live sessions; remove the temp mcp-config file."""
self._closed = True
sessions = list(self._sessions.values())
self._sessions.clear()
for s in sessions:
with contextlib.suppress(Exception):
await s.aclose()
if self._mcp_config_path is not None:
with contextlib.suppress(OSError):
self._mcp_config_path.unlink()
self._mcp_config_path = None
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
await self.aclose()
async def _create_session(self, history: list[Mapping[str, Any]]) -> _LiveSession:
"""Spawn a fresh PTY + watcher + manager, optionally seeded.
`native_jsonl` (default): write a hand-crafted JSONL transcript at
`~/.claude/projects/<key>/<session_id>.jsonl`, then start claude
with `--resume <session_id>`. The watcher starts at the seed
file's end so it sees only fresh records.
`concat_message` (fallback): spawn fresh; the history is injected
into the first user prompt instead (handled by `complete()`).
"""
session_id = str(uuid.uuid4())
cwd = os.fspath(self._opts.cwd)
if history and self._opts.history_injection_mode == "native_jsonl":
jsonl_path = resolve_jsonl_path(cwd, session_id)
jsonl_path.parent.mkdir(parents=True, exist_ok=True)
seed = build_seed_jsonl(history, session_id=session_id, cwd=cwd)
jsonl_path.write_text(seed, encoding="utf-8")
start_offset = jsonl_path.stat().st_size
resume = True
else:
jsonl_path = resolve_jsonl_path(cwd, session_id)
start_offset = 0
resume = False
if self._session_factory is not None:
result = self._session_factory(
self, session_id, resume, jsonl_path, start_offset
)
if asyncio.iscoroutine(result):
return await result
return result # type: ignore[return-value]
return await self._spawn_real_session(
session_id=session_id,
resume=resume,
jsonl_path=jsonl_path,
start_offset=start_offset,
)
async def _spawn_real_session(
self, *, session_id: str, resume: bool, jsonl_path: Path, start_offset: int
) -> _LiveSession:
pty_opts = self._build_pty_options(session_id=session_id, resume=resume)
pty = PtyClaudeProcess(pty_opts)
watcher = JsonlWatcher(jsonl_path, start_offset=start_offset)
tm = TurnManager(
pty,
watcher,
wait_for_turn_duration=self._opts.wait_for_turn_duration,
include_meta_user=self._opts.include_meta_user,
file_wait_timeout=self._opts.file_wait_timeout,
turn_duration_timeout=self._opts.turn_duration_timeout,
startup_delay=self._opts.startup_delay,
on_parse_error=self._on_parse_error,
)
await tm.start()
return _LiveSession(pty=pty, watcher=watcher, tm=tm)
def _build_pty_options(self, *, session_id: str, resume: bool) -> PtyProcessOptions:
mcp_config = self._mcp_config_argument()
kwargs: dict[str, Any] = {
"cwd": self._opts.cwd,
"model": self._opts.model,
"system_prompt": self._opts.system_prompt,
"append_system_prompt": self._opts.append_system_prompt,
"allowed_tools": self._opts.allowed_tools,
"disallowed_tools": self._opts.disallowed_tools,
"mcp_config": mcp_config,
"add_dir": self._opts.add_dir,
"permission_mode": self._opts.permission_mode,
"dangerously_skip_permissions": self._opts.dangerously_skip_permissions,
"effort": self._opts.effort,
"settings": self._opts.settings,
"extra_args": self._opts.extra_args,
"preserve_provider_env": self._opts.preserve_provider_env,
"extra_env": self._opts.extra_env,
}
if resume:
kwargs["resume_session_id"] = session_id
else:
kwargs["session_id"] = session_id
return PtyProcessOptions(**kwargs)
def _mcp_config_argument(self) -> tuple[str, ...]:
"""Materialize `mcp_servers` into a `--mcp-config` file path tuple.
The temp file lives for the backend's lifetime — cleaned up in
`aclose()`. Written lazily so a backend that never spawns a
session leaves no debris.
"""
servers = self._opts.mcp_servers
if not servers:
return ()
if self._mcp_config_path is None:
fd, path = tempfile.mkstemp(prefix="claude-mcp-", suffix=".json")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"mcpServers": dict(servers)}, f)
except Exception:
with contextlib.suppress(OSError):
Path(path).unlink()
raise
self._mcp_config_path = Path(path)
return (str(self._mcp_config_path),)
def _user_text_payload(content: Any) -> str:
"""Extract the text we'll write to the PTY for the last user message.
A string `content` passes through as-is. A list of blocks is flattened
to its text content; tool_result blocks are not faithfully
reproducible through stdin and are skipped.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
chunks: list[str] = []
for block in content:
if isinstance(block, Mapping) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str):
chunks.append(text)
if not chunks:
msg = "last user message content must include at least one text block"
raise ValueError(msg)
return " ".join(chunks)
msg = f"last user message content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
def _synthesize_assistant_dict(events: Iterable[Event]) -> dict[str, Any]:
"""Render the terminal assistant message in Anthropic Messages format."""
terminal: AssistantMessage | None = None
for ev in reversed(list(events)):
if (
isinstance(ev, AssistantMessage)
and ev.stop_reason in _TERMINAL_STOP_REASONS
):
terminal = ev
break
if terminal is None:
return {"role": "assistant", "content": []}
return {
"role": "assistant",
"content": [_block_to_dict(b) for b in terminal.content],
}
def _block_to_dict(block: ContentBlock) -> dict[str, Any]:
if isinstance(block, TextBlock):
return {"type": "text", "text": block.text}
if isinstance(block, ToolUseBlock):
return {
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
}
if isinstance(block, ToolResultBlock):
return {
"type": "tool_result",
"tool_use_id": block.tool_use_id,
"content": block.content,
"is_error": block.is_error,
}
if isinstance(block, ThinkingBlock):
return {
"type": "thinking",
"thinking": block.thinking,
"signature": block.signature,
}
msg = f"unknown content block type: {type(block).__name__}"
raise TypeError(msg)
__all__ = [
"BackendOptions",
"ClaudeCodeBackend",
"HistoryInjectionMode",
"ParseErrorCallback",
]
+138
View File
@@ -0,0 +1,138 @@
"""Backend exception hierarchy.
Mirrors `claude_agent_sdk._errors` so a gateway that already catches its
shapes keeps working when it wires this backend in.
- `SessionError` is raised when the JSONL session file never appears within
`TurnManager.file_wait_timeout`.
- `ProcessError` carries `exit_code` and `stderr` (the rolling PTY output
buffer — claude's TUI writes its error chrome to the PTY stream, not to
stderr).
- `classify_pty_failure(captured)` inspects that buffer for known error
markers and returns the most specific subclass to raise (or `None` for
"no signal — caller picks the default").
"""
from __future__ import annotations
import re
from typing import Any
class BackendError(Exception):
"""Base for every error raised by this package."""
class MessageParseError(BackendError):
"""A JSONL record was malformed or missing fields required to type it."""
def __init__(self, message: str, data: Any = None) -> None:
self.data = data
super().__init__(message)
class ProcessError(BackendError):
"""The `claude` subprocess died unexpectedly or refused to start.
`exit_code` is `None` if we never observed an exit (e.g. the process is
still alive but unresponsive); `stderr` carries the rolling PTY output
buffer at the moment of failure (capped, oldest dropped) — claude prints
its error chrome there rather than to stderr.
"""
def __init__(
self, message: str, *, exit_code: int | None = None, stderr: str | None = None
) -> None:
self.exit_code = exit_code
self.stderr = stderr
if exit_code is not None:
message = f"{message} (exit code: {exit_code})"
if stderr:
tail = stderr[-2000:] if len(stderr) > 2000 else stderr
message = f"{message}\nPTY output (tail):\n{tail}"
super().__init__(message)
class CLINotFoundError(ProcessError):
"""The `claude` binary could not be located on PATH."""
def __init__(
self, message: str = "claude CLI not found", *, executable: str | None = None
) -> None:
if executable:
message = f"{message}: {executable}"
self.executable = executable
super().__init__(message)
class AuthError(BackendError):
"""OAuth / subscription auth is blocked — user must re-`claude /login`."""
def __init__(self, message: str = "claude subscription auth is blocked") -> None:
super().__init__(message)
class RateLimitError(BackendError):
"""Subscription rate limit hit."""
def __init__(self, message: str = "claude subscription rate limit reached") -> None:
super().__init__(message)
class SessionError(BackendError):
"""JSONL session file failed to materialize within the configured timeout.
Usually means claude is hung in startup (e.g. workspace-trust prompt
blocking), or its TUI never accepted our stdin write.
"""
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
_NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
def _compact(text: str) -> str:
return _NON_ALNUM_RE.sub("", _ANSI_RE.sub("", text).lower())
def classify_pty_failure(captured: bytes | str) -> type[BackendError] | None:
"""Inspect a PTY-output buffer for known TUI error-chrome markers.
Returns the most specific `BackendError` subclass to raise, or `None`
when no marker is matched (caller falls back to a generic
`ProcessError`/`SessionError`).
"""
text = (
captured.decode("utf-8", errors="replace")
if isinstance(captured, bytes)
else captured
)
low = _ANSI_RE.sub("", text).lower()
compact = _compact(text)
if (
"failed to authenticate" in low
or "api error: 403" in low
or "pleaserunlogin" in compact
or "pleaserun/login" in compact
):
return AuthError
if (
"you've hit your limit" in low
or "you have hit your limit" in low
or "hit your limit" in low
):
return RateLimitError
return None
__all__ = [
"AuthError",
"BackendError",
"CLINotFoundError",
"MessageParseError",
"ProcessError",
"RateLimitError",
"SessionError",
"classify_pty_failure",
]
+129
View File
@@ -0,0 +1,129 @@
"""Public event types emitted by the backend.
The shapes mirror the `claude-agent-sdk` Python types (`AssistantMessage`,
`UserMessage`, `SystemMessage`, `ResultMessage`, plus the content-block
dataclasses) so the gateway that consumes this backend can re-expose
everything over an Anthropic Messages API-compatible HTTP surface with a
one-liner serializer.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class TextBlock:
text: str
@dataclass
class ThinkingBlock:
thinking: str
signature: str
@dataclass
class ToolUseBlock:
id: str
name: str
input: dict[str, Any]
@dataclass
class ToolResultBlock:
tool_use_id: str
content: str | list[dict[str, Any]] | None = None
is_error: bool | None = None
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
@dataclass
class UserMessage:
"""User turn record from JSONL.
`content` is either a verbatim string (the prompt we sent) or a list of
content blocks (the only observed block-list shape is a list of
`ToolResultBlock`s, written by claude after a tool call completes).
"""
content: str | list[ContentBlock]
uuid: str | None = None
session_id: str | None = None
parent_uuid: str | None = None
@dataclass
class AssistantMessage:
"""Assistant turn record.
`stop_reason` can be ``None`` for intermediate streaming chunks claude
writes mid-turn; only terminal values (``end_turn`` / ``tool_use`` /
``max_tokens`` / ``stop_sequence`` / ``refusal``) close out a turn.
"""
content: list[ContentBlock]
model: str
stop_reason: str | None = None
usage: dict[str, Any] | None = None
message_id: str | None = None
session_id: str | None = None
uuid: str | None = None
parent_uuid: str | None = None
@dataclass
class SystemMessage:
"""Out-of-band signal record.
The only subtype the normalizer surfaces by default is ``turn_duration``,
which marks the end of a turn (after all post-turn hooks have flushed).
All other system subtypes (`stop_hook_summary`, `local_command`) are
filtered out — they are claude's internal bookkeeping, not consumer
signal. `data` carries the full raw record for callers that want it.
"""
subtype: str
data: dict[str, Any]
session_id: str | None = None
uuid: str | None = None
@dataclass
class ResultMessage:
"""Synthesized turn-completion summary.
Not emitted by the normalizer — JSONL has no native ``result`` record.
`TurnManager` fabricates one when a turn closes, aggregating usage from
the last assistant record and timing from the final `turn_duration`
system signal.
"""
subtype: str
duration_ms: int
num_turns: int
session_id: str
is_error: bool = False
stop_reason: str | None = None
usage: dict[str, Any] | None = None
uuid: str | None = None
Event = UserMessage | AssistantMessage | SystemMessage | ResultMessage
__all__ = [
"AssistantMessage",
"ContentBlock",
"Event",
"ResultMessage",
"SystemMessage",
"TextBlock",
"ThinkingBlock",
"ToolResultBlock",
"ToolUseBlock",
"UserMessage",
]
+253
View File
@@ -0,0 +1,253 @@
"""History fingerprinting + injection formats.
Three pure functions:
- `hash_history(messages)` — deterministic fingerprint of an Anthropic-style
message list. Two requests whose `messages[:-1]` hash to the same value
are considered the same conversation up to that turn, regardless of which
client sent them.
- `build_seed_jsonl(messages, session_id, cwd, ...)` — render a prior
history as a native `claude` JSONL transcript ready to be written under
`~/.claude/projects/<key>/<session_id>.jsonl` and resumed via
`claude --resume <session_id>`.
- `build_concat_prompt(history, last_user_text)` — render the same prior
history as a single big text prompt for the fallback `concat_message`
injection mode (no JSONL surgery; everything goes through stdin).
This module does NOT touch the filesystem.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import json
import uuid
from collections.abc import Iterable, Mapping
from typing import Any
_DEFAULT_CLAUDE_VERSION = "2.1.143"
_DEFAULT_MODEL = "claude-opus-4-7"
def hash_history(messages: Iterable[Mapping[str, Any]]) -> str:
"""Return a stable sha256 hex digest of a conversation prefix.
Only the content-bearing fields are considered: `role`, `content` (with
text/tool_use/tool_result blocks normalized to their semantic shape).
Ordering of dict keys inside blocks is normalized via canonical-JSON
(`sort_keys=True`) so two clients that serialize the same content
blocks in different key orders still collide.
"""
canonical = [_canonical_message(m) for m in messages]
payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _canonical_message(message: Mapping[str, Any]) -> dict[str, Any]:
role = message.get("role")
if role not in ("user", "assistant"):
msg = f"message must have role 'user' or 'assistant', got {role!r}"
raise ValueError(msg)
content = message.get("content")
return {"role": role, "content": _canonical_content(content)}
def _canonical_content(content: Any) -> Any:
if isinstance(content, str):
return content
if isinstance(content, list):
return [_canonical_block(b) for b in content]
msg = f"content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
def _canonical_block(block: Any) -> dict[str, Any]:
if not isinstance(block, Mapping):
msg = f"content block must be a mapping, got {type(block).__name__}"
raise ValueError(msg)
btype = block.get("type")
if btype == "text":
return {"type": "text", "text": block.get("text", "")}
if btype == "tool_use":
return {
"type": "tool_use",
"id": block.get("id", ""),
"name": block.get("name", ""),
"input": block.get("input", {}),
}
if btype == "tool_result":
return {
"type": "tool_result",
"tool_use_id": block.get("tool_use_id", ""),
"content": block.get("content"),
"is_error": block.get("is_error"),
}
return {"type": btype, **{k: v for k, v in block.items() if k != "type"}}
def build_seed_jsonl(
messages: Iterable[Mapping[str, Any]],
*,
session_id: str,
cwd: str,
claude_version: str = _DEFAULT_CLAUDE_VERSION,
model: str = _DEFAULT_MODEL,
now_iso: str | None = None,
) -> str:
"""Render a message list as a native claude JSONL transcript.
Output is a newline-terminated string of one JSON object per line. The
schema mirrors what claude itself writes (minus the snapshot records).
The caller writes the result to
`~/.claude/projects/<key>/<session_id>.jsonl` and spawns
`claude --resume <session_id>`. Claude appends its own
`file-history-snapshot` / `last-prompt` / `permission-mode` records on
resume — we don't need to.
Empty history is permitted; the returned string is empty in that case.
"""
if now_iso is None:
now_iso = _now_iso()
lines: list[str] = []
parent_uuid: str | None = None
common = {
"isSidechain": False,
"userType": "external",
"entrypoint": "cli",
"cwd": cwd,
"sessionId": session_id,
"version": claude_version,
"gitBranch": "",
}
for m in messages:
role = m.get("role")
if role == "user":
user_uuid = str(uuid.uuid4())
record = {
"parentUuid": parent_uuid,
"promptId": str(uuid.uuid4()),
"type": "user",
"message": {
"role": "user",
"content": _content_for_seed(m.get("content"), role="user"),
},
"isMeta": False,
"uuid": user_uuid,
"timestamp": now_iso,
**common,
}
parent_uuid = user_uuid
elif role == "assistant":
assistant_uuid = str(uuid.uuid4())
record = {
"parentUuid": parent_uuid,
"message": {
"model": model,
"id": f"msg_{uuid.uuid4().hex[:24]}",
"type": "message",
"role": "assistant",
"content": _content_for_seed(m.get("content"), role="assistant"),
"stop_reason": "end_turn",
"stop_sequence": None,
"stop_details": None,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"service_tier": "standard",
},
},
"requestId": f"req_{uuid.uuid4().hex[:24]}",
"type": "assistant",
"uuid": assistant_uuid,
"timestamp": now_iso,
**common,
}
parent_uuid = assistant_uuid
else:
msg = f"message role must be 'user' or 'assistant', got {role!r}"
raise ValueError(msg)
lines.append(json.dumps(record))
if not lines:
return ""
return "\n".join(lines) + "\n"
def _content_for_seed(content: Any, *, role: str) -> Any:
"""Normalize Anthropic message content to what claude expects in JSONL."""
if role == "user":
if isinstance(content, str):
return content
if isinstance(content, list):
return [dict(b) for b in content]
msg = f"user content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
if isinstance(content, str):
return [{"type": "text", "text": content}]
if isinstance(content, list):
return [dict(b) for b in content]
msg = f"assistant content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
_CONCAT_PREAMBLE = "Previous conversation context:"
_CONCAT_DIVIDER = "Continue from here. New user message:"
def build_concat_prompt(
history: Iterable[Mapping[str, Any]], last_user_text: str
) -> str:
"""Render prior history + the new user prompt as one stdin payload.
Fallback for when `native_jsonl` injection can't be used. Costs more
tokens per request and breaks the server-side prompt cache, but always
works because it goes through the same stdin path as a normal first
turn.
"""
parts: list[str] = []
history_list = list(history)
if history_list:
parts.append(_CONCAT_PREAMBLE)
parts.append("")
for m in history_list:
role = m.get("role")
if role not in ("user", "assistant"):
msg = f"message role must be 'user' or 'assistant', got {role!r}"
raise ValueError(msg)
label = "[User]" if role == "user" else "[Assistant]"
parts.append(f"{label}: {_flatten_text(m.get('content'))}")
parts.append("")
parts.append(f"{_CONCAT_DIVIDER} {last_user_text}")
else:
parts.append(last_user_text)
return "\n".join(parts)
def _flatten_text(content: Any) -> str:
"""Extract a single string from a content payload for concat mode."""
if isinstance(content, str):
return content
if isinstance(content, list):
chunks: list[str] = []
for b in content:
if isinstance(b, Mapping) and b.get("type") == "text":
text = b.get("text")
if isinstance(text, str):
chunks.append(text)
return " ".join(chunks)
return ""
def _now_iso() -> str:
"""RFC3339 with millisecond precision and a 'Z' suffix, matching claude."""
now = dt.datetime.now(dt.UTC)
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
__all__ = ["build_concat_prompt", "build_seed_jsonl", "hash_history"]
+129
View File
@@ -0,0 +1,129 @@
"""Hardcoded inventory of `claude` CLI models and aliases.
Sourced by inspecting the Mach-O binary at
`~/.local/share/claude/versions/<ver>` on macOS (or the equivalent Linux
location). The data lives in plain string literals — no decryption or
unpacking needed. See `scripts/extract_models.py` to refresh after a
`claude` update.
The CLI accepts either a *short alias* (e.g. ``sonnet``) or a *full model
id* (e.g. ``claude-sonnet-4-6``) for ``--model``. Aliases get resolved to
whatever Anthropic considers current; pin a full id when you need
stability across `claude` updates.
Last refreshed against `claude 2.1.143`.
"""
from __future__ import annotations
from typing import Final
CLAUDE_VERSION: Final = "2.1.143"
ALIASES: Final[tuple[str, ...]] = (
"default",
"sonnet",
"opus",
"haiku",
"best",
"sonnet[1m]",
"opus[1m]",
"opusplan",
)
"""Short aliases the CLI's `/model` command lists as ``Available:``.
`opusplan` routes /plan calls through opus and execution through sonnet.
`*[1m]` selects the 1M-context variant of the family (requires entitlement).
`best` resolves to whatever Anthropic currently advertises as flagship.
"""
MODELS_CURRENT: Final[tuple[str, ...]] = (
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-6-fast",
"claude-sonnet-4-6",
"claude-haiku-4-5",
)
"""Currently-recommended model ids — current minor release of each family."""
MODELS_LEGACY: Final[tuple[str, ...]] = (
"claude-opus-4-5",
"claude-opus-4-5-20251101",
"claude-opus-4-1",
"claude-opus-4-1-20250805",
"claude-opus-4-0",
"claude-opus-4",
"claude-opus-4-20250514",
"claude-4-opus-20250514",
"claude-sonnet-4-5",
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-0",
"claude-sonnet-4",
"claude-sonnet-4-20250514",
"claude-sonnet-3-7",
"claude-haiku-4",
"claude-haiku-3-5",
"claude-3-7-sonnet",
"claude-3-7-sonnet-latest",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku",
"claude-3-5-haiku-latest",
"claude-3-5-haiku-20241022",
"claude-3-opus",
"claude-3-opus-20240229",
"claude-3-sonnet",
"claude-3-sonnet-20240229",
"claude-3-haiku",
)
"""Older model ids the CLI still accepts but Anthropic no longer fronts."""
MODELS_ALL: Final[tuple[str, ...]] = MODELS_CURRENT + MODELS_LEGACY
"""Every model id the `claude 2.1.143` binary references."""
DISPLAY_NAMES: Final[dict[str, str]] = {
"claude-opus-4-7": "Opus 4.7",
"claude-opus-4-6": "Opus 4.6",
"claude-opus-4-6-fast": "Opus 4.6 (fast)",
"claude-opus-4-5": "Opus 4.5",
"claude-opus-4-1": "Opus 4.1",
"claude-opus-4": "Opus 4",
"claude-sonnet-4-6": "Sonnet 4.6",
"claude-sonnet-4-5": "Sonnet 4.5",
"claude-sonnet-4": "Sonnet 4",
"claude-haiku-4-5": "Haiku 4.5",
"claude-haiku-4": "Haiku 4",
"claude-3-7-sonnet": "Sonnet 3.7",
"claude-3-5-sonnet": "Sonnet 3.5",
"claude-3-5-haiku": "Haiku 3.5",
"claude-3-opus": "Opus 3",
"claude-3-sonnet": "Sonnet 3",
"claude-3-haiku": "Haiku 3",
}
"""Human-readable labels for the canonical model ids (no dated suffixes)."""
def is_valid_model(name: str) -> bool:
"""Return ``True`` if `name` is a known alias or a known model id.
A `False` return doesn't necessarily mean `claude` will reject the
value — Anthropic may have added a new model since this module was
refreshed. Use as a hint, not a gate.
"""
return name in ALIASES or name in MODELS_ALL
__all__ = [
"ALIASES",
"CLAUDE_VERSION",
"DISPLAY_NAMES",
"MODELS_ALL",
"MODELS_CURRENT",
"MODELS_LEGACY",
"is_valid_model",
]
+190
View File
@@ -0,0 +1,190 @@
"""JSONL record -> typed `Event`.
A pure function. Stateless, side-effect-free, and unaware of the file the
record came from. Higher layers call `normalize(record)` on every line:
- a typed `Event` instance — surface it to the consumer;
- ``None`` — filtered out (forward-compat unknown type, bookkeeping record,
meta caveat) — drop silently;
- `MessageParseError` — the record claimed to be of a type we handle but
was missing fields we cannot fabricate.
Filter policy: only the records that carry consumer-visible signal are
mapped (`user`, `assistant`, and the `system.subtype=turn_duration`
heartbeat). Everything else is bookkeeping and gets dropped.
"""
from __future__ import annotations
from typing import Any
from claude_code_api.errors import MessageParseError
from claude_code_api.events import (
AssistantMessage,
ContentBlock,
Event,
SystemMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
_BOOKKEEPING_TYPES: frozenset[str] = frozenset(
{
"attachment",
"file-history-snapshot",
"last-prompt",
"ai-title",
"permission-mode",
"queue-operation",
}
)
_BOOKKEEPING_SYSTEM_SUBTYPES: frozenset[str] = frozenset(
{"local_command", "stop_hook_summary"}
)
def normalize(
record: dict[str, Any], *, include_meta_user: bool = False
) -> Event | None:
"""Map one JSONL record to a typed event, or ``None`` if filtered.
Args:
record: parsed JSON object from a JSONL line.
include_meta_user: when ``True``, user records with ``isMeta=True``
(local-command caveats claude injects) are emitted instead of
dropped. Off by default — those records are not part of the
real conversation history.
Raises:
MessageParseError: the record's `type` is one we handle but it is
missing fields needed to construct the event.
"""
if not isinstance(record, dict):
msg = f"record must be a dict, got {type(record).__name__}"
raise MessageParseError(msg, record)
record_type = record.get("type")
if record_type is None:
msg = "record missing 'type' field"
raise MessageParseError(msg, record)
if record_type in _BOOKKEEPING_TYPES:
return None
if record_type == "user":
return _parse_user(record, include_meta=include_meta_user)
if record_type == "assistant":
return _parse_assistant(record)
if record_type == "system":
return _parse_system(record)
return None
def _parse_user(record: dict[str, Any], *, include_meta: bool) -> UserMessage | None:
if record.get("isMeta") and not include_meta:
return None
try:
raw_content = record["message"]["content"]
except KeyError as exc:
msg = f"user record missing required field: {exc}"
raise MessageParseError(msg, record) from exc
content: str | list[ContentBlock]
if isinstance(raw_content, str):
content = raw_content
elif isinstance(raw_content, list):
content = [_parse_block(block, record) for block in raw_content]
else:
got = type(raw_content).__name__
msg = f"user record content must be str or list, got {got}"
raise MessageParseError(msg, record)
return UserMessage(
content=content,
uuid=record.get("uuid"),
session_id=record.get("sessionId"),
parent_uuid=record.get("parentUuid"),
)
def _parse_assistant(record: dict[str, Any]) -> AssistantMessage:
try:
message = record["message"]
raw_content = message["content"]
model = message["model"]
except KeyError as exc:
msg = f"assistant record missing required field: {exc}"
raise MessageParseError(msg, record) from exc
if not isinstance(raw_content, list):
msg = f"assistant content must be a list, got {type(raw_content).__name__}"
raise MessageParseError(msg, record)
content = [_parse_block(block, record) for block in raw_content]
return AssistantMessage(
content=content,
model=model,
stop_reason=message.get("stop_reason"),
usage=message.get("usage"),
message_id=message.get("id"),
session_id=record.get("sessionId"),
uuid=record.get("uuid"),
parent_uuid=record.get("parentUuid"),
)
def _parse_system(record: dict[str, Any]) -> SystemMessage | None:
subtype = record.get("subtype")
if subtype is None:
msg = "system record missing 'subtype'"
raise MessageParseError(msg, record)
if subtype in _BOOKKEEPING_SYSTEM_SUBTYPES:
return None
return SystemMessage(
subtype=subtype,
data=record,
session_id=record.get("sessionId"),
uuid=record.get("uuid"),
)
def _parse_block(block: Any, record: dict[str, Any]) -> ContentBlock:
if not isinstance(block, dict):
msg = f"content block must be a dict, got {type(block).__name__}"
raise MessageParseError(msg, record)
block_type = block.get("type")
try:
if block_type == "text":
return TextBlock(text=block["text"])
if block_type == "thinking":
return ThinkingBlock(
thinking=block["thinking"], signature=block["signature"]
)
if block_type == "tool_use":
return ToolUseBlock(
id=block["id"], name=block["name"], input=block["input"]
)
if block_type == "tool_result":
return ToolResultBlock(
tool_use_id=block["tool_use_id"],
content=block.get("content"),
is_error=block.get("is_error"),
)
except KeyError as exc:
msg = f"{block_type} block missing required field: {exc}"
raise MessageParseError(msg, record) from exc
msg = f"unknown content block type: {block_type!r}"
raise MessageParseError(msg, record)
__all__ = ["normalize"]
+113
View File
@@ -0,0 +1,113 @@
"""Helpers for locating `claude` JSONL session files.
`claude` stores per-session transcripts at
`~/.claude/projects/<project_key>/<session_id>.jsonl`, where `project_key` is
the absolute cwd with every non-alphanumeric character (other than `-`)
replaced by `-`. So `/Users/h/.t3/worktrees/foo` becomes
`-Users-h--t3-worktrees-foo`.
The encoding is intentionally lossy (existing `-` is preserved) but matches
what claude writes for every cwd inspected. None of these helpers touch the
filesystem except `find_jsonl_by_session_id`.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
_KEY_SAFE_RE = re.compile(r"[^A-Za-z0-9-]")
_PROJECTS_DIRNAME = "projects"
_CLAUDE_HOME_DIRNAME = ".claude"
def claude_home(home: str | os.PathLike[str] | None = None) -> Path:
"""Return the `~/.claude` directory; honors `$HOME` via `Path.home()`."""
base = Path(home) if home is not None else Path.home()
return base / _CLAUDE_HOME_DIRNAME
def projects_root(home: str | os.PathLike[str] | None = None) -> Path:
"""Return `~/.claude/projects`."""
return claude_home(home) / _PROJECTS_DIRNAME
def encode_project_key(cwd: str | os.PathLike[str]) -> str:
"""Encode an absolute cwd into the directory name claude uses on disk.
Rules:
- `cwd` must be absolute; relative paths raise `ValueError`.
- The leading `/` becomes `-`, so the key always starts with `-`.
- Every char outside `[A-Za-z0-9-]` becomes `-`. Adjacent specials
produce adjacent dashes — matches what claude writes.
Symlinks are not resolved; claude stores the literal invocation path.
"""
raw = os.fspath(cwd)
if not raw:
msg = "cwd must not be empty"
raise ValueError(msg)
if not Path(raw).is_absolute():
msg = f"cwd must be absolute, got {raw!r}"
raise ValueError(msg)
return _KEY_SAFE_RE.sub("-", raw)
def session_dir(
cwd: str | os.PathLike[str], *, home: str | os.PathLike[str] | None = None
) -> Path:
"""Return the directory that holds JSONL session files for `cwd`."""
return projects_root(home) / encode_project_key(cwd)
def resolve_jsonl_path(
cwd: str | os.PathLike[str],
session_id: str,
*,
home: str | os.PathLike[str] | None = None,
) -> Path:
"""Return the canonical JSONL path for `(cwd, session_id)`.
Does not check existence — both higher layers (watcher reads, injection
writes) want to be able to compute this path before the file exists.
"""
if not session_id:
msg = "session_id must not be empty"
raise ValueError(msg)
return session_dir(cwd, home=home) / f"{session_id}.jsonl"
def find_jsonl_by_session_id(
session_id: str, *, home: str | os.PathLike[str] | None = None
) -> Path | None:
"""Search `~/.claude/projects/**/<session_id>.jsonl`.
Useful as a sanity check when the cwd-derived key seems wrong. Returns
the first match, or `None` if no session file with that id exists.
"""
if not session_id:
msg = "session_id must not be empty"
raise ValueError(msg)
root = projects_root(home)
if not root.is_dir():
return None
for path in root.glob(f"*/{session_id}.jsonl"):
return path
return None
__all__: Iterable[str] = (
"claude_home",
"encode_project_key",
"find_jsonl_by_session_id",
"projects_root",
"resolve_jsonl_path",
"session_dir",
)
+389
View File
@@ -0,0 +1,389 @@
"""PTY-driven `claude` subprocess.
`PtyClaudeProcess` owns the lifecycle of one long-running interactive `claude`
under a pseudo-TTY. Higher layers consume events from the JSONL session file,
not from this process's PTY output — but the PTY must still be drained
continuously so the child does not block on a full kernel buffer. A background
drain thread handles that; raw output is exposed only for smoke tests and an
optional callback for debug consumers.
This module knows nothing about turns, JSONL, or event normalization.
"""
from __future__ import annotations
import asyncio
import contextlib
import errno
import os
import select
import signal
import threading
import uuid
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from typing import Self
from ptyprocess import PtyProcess
from claude_code_api.errors import CLINotFoundError
PtyOutputCallback = Callable[[bytes], None]
_PROVIDER_ENV_VARS: tuple[str, ...] = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
)
_VALID_PERMISSION_MODES: frozenset[str] = frozenset(
{"acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"}
)
_DEFAULT_DRAIN_CHUNK = 65536
_DEFAULT_OUTPUT_BUFFER_CAP = 1_000_000
@dataclass(frozen=True)
class PtyProcessOptions:
"""Configuration for a single PTY-spawned `claude` interactive process.
`cwd` is required because it determines the JSONL project key used by
higher layers. `session_id` is auto-generated as a UUID4 if omitted.
"""
cwd: str | os.PathLike[str]
session_id: str | None = None
resume_session_id: str | None = None
model: str | None = None
system_prompt: str | None = None
append_system_prompt: str | None = None
allowed_tools: tuple[str, ...] = ()
disallowed_tools: tuple[str, ...] = ()
mcp_config: tuple[str, ...] = ()
add_dir: tuple[str, ...] = ()
permission_mode: str = "bypassPermissions"
dangerously_skip_permissions: bool = False
effort: str | None = None
settings: str | None = None
executable: str = "claude"
extra_args: tuple[str, ...] = ()
term: str = "xterm-256color"
dimensions: tuple[int, int] = (24, 80)
preserve_provider_env: bool = False
extra_env: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if (
not self.dangerously_skip_permissions
and self.permission_mode not in _VALID_PERMISSION_MODES
):
msg = (
f"invalid permission_mode={self.permission_mode!r}; "
f"expected one of {sorted(_VALID_PERMISSION_MODES)}"
)
raise ValueError(msg)
rows, cols = self.dimensions
if rows <= 0 or cols <= 0:
msg = f"dimensions must be positive, got {self.dimensions!r}"
raise ValueError(msg)
if self.resume_session_id is not None and self.session_id is not None:
msg = "set either session_id or resume_session_id, not both"
raise ValueError(msg)
def build_argv(opts: PtyProcessOptions, session_id: str) -> list[str]:
"""Materialize CLI argv for `claude` interactive mode.
Subscription-mode TUI must NOT pass `--print`, `--output-format`, or
`--input-format` — they either force headless mode or are silently
ignored by interactive claude.
When `opts.resume_session_id` is set, emit `--resume <id>` instead of
`--session-id <id>` — claude rejects the two flags together unless
`--fork-session` is also passed, which would branch the session into a
new JSONL.
"""
if opts.resume_session_id is not None:
argv: list[str] = [opts.executable, "--resume", opts.resume_session_id]
else:
argv = [opts.executable, "--session-id", session_id]
if opts.dangerously_skip_permissions:
argv.append("--dangerously-skip-permissions")
else:
argv += ["--permission-mode", opts.permission_mode]
if opts.model:
argv += ["--model", opts.model]
if opts.system_prompt is not None:
argv += ["--system-prompt", opts.system_prompt]
if opts.append_system_prompt is not None:
argv += ["--append-system-prompt", opts.append_system_prompt]
if opts.allowed_tools:
argv += ["--allowedTools", ",".join(opts.allowed_tools)]
if opts.disallowed_tools:
argv += ["--disallowedTools", ",".join(opts.disallowed_tools)]
for cfg in opts.mcp_config:
argv += ["--mcp-config", cfg]
if opts.add_dir:
argv += ["--add-dir", *opts.add_dir]
if opts.effort:
argv += ["--effort", opts.effort]
if opts.settings:
argv += ["--settings", opts.settings]
argv.extend(opts.extra_args)
return argv
def build_env(
opts: PtyProcessOptions, base: Mapping[str, str] | None = None
) -> dict[str, str]:
"""Build the env for the subprocess.
Starts from `base` (defaults to `os.environ`), optionally strips the
three Anthropic provider env vars so the CLI uses OAuth/subscription
auth, and sets `TERM` / `NO_COLOR` for a predictable TUI surface.
"""
env = dict(base if base is not None else os.environ)
if not opts.preserve_provider_env:
for name in _PROVIDER_ENV_VARS:
env.pop(name, None)
env["TERM"] = opts.term
env["NO_COLOR"] = "1"
env.update(opts.extra_env)
return env
class PtyClaudeProcess:
"""A live `claude` interactive process under a PTY.
Public lifecycle:
proc = PtyClaudeProcess(opts)
await proc.start()
await proc.write("hi")
await proc.terminate() # SIGTERM, then SIGKILL after grace
A background daemon thread drains PTY output continuously to prevent
the child blocking on a full pty buffer. Captured output is available
via `captured_output()` (capped at ~1MB) and optionally streamed to
`on_pty_output`. Higher layers should ignore PTY output and read
JSONL.
"""
def __init__(
self,
options: PtyProcessOptions,
*,
on_pty_output: PtyOutputCallback | None = None,
output_buffer_cap: int = _DEFAULT_OUTPUT_BUFFER_CAP,
) -> None:
self._opts = options
self._on_output = on_pty_output
self._output_buffer_cap = output_buffer_cap
if options.resume_session_id is not None:
self._session_id = options.resume_session_id
else:
self._session_id = options.session_id or str(uuid.uuid4())
self._argv: list[str] = build_argv(options, self._session_id)
self._env: dict[str, str] = build_env(options)
self._pty: PtyProcess | None = None
self._drain_thread: threading.Thread | None = None
self._drain_stop = threading.Event()
self._output_lock = threading.Lock()
self._output_buffer = bytearray()
@property
def session_id(self) -> str:
return self._session_id
@property
def argv(self) -> list[str]:
return list(self._argv)
@property
def env(self) -> dict[str, str]:
return dict(self._env)
@property
def cwd(self) -> str:
return os.fspath(self._opts.cwd)
@property
def pid(self) -> int | None:
return self._pty.pid if self._pty is not None else None
def is_alive(self) -> bool:
return self._pty is not None and self._pty.isalive()
def captured_output(self) -> bytes:
"""Snapshot of the rolling PTY output buffer (capped, oldest dropped)."""
with self._output_lock:
return bytes(self._output_buffer)
async def start(self) -> None:
"""Spawn the child synchronously on the main thread.
ptyprocess uses `pty.openpty()` + `os.forkpty()`; forking from a
worker thread on macOS can leave the child in an unstable state.
The fork itself is microsecond-scale.
"""
if self._pty is not None:
msg = "PtyClaudeProcess.start() called twice"
raise RuntimeError(msg)
try:
self._pty = PtyProcess.spawn(
self._argv,
cwd=self.cwd,
env=self._env,
echo=False,
dimensions=self._opts.dimensions,
)
except FileNotFoundError as exc:
raise CLINotFoundError(executable=self._opts.executable) from exc
self._drain_stop.clear()
self._drain_thread = threading.Thread(
target=self._drain_loop,
name=f"pty-drain-{self._session_id[:8]}",
daemon=True,
)
self._drain_thread.start()
def _drain_loop(self) -> None:
pty = self._pty
if pty is None:
return
fd = pty.fileno()
while not self._drain_stop.is_set():
try:
ready, _, _ = select.select([fd], [], [], 0.1)
except (OSError, ValueError):
break
if not ready:
continue
try:
data = os.read(fd, _DEFAULT_DRAIN_CHUNK)
except OSError as exc:
if exc.errno in (errno.EIO, errno.EBADF):
break
if self._drain_stop.wait(0.05):
break
continue
if not data:
break
cb = self._on_output
if cb is not None:
with contextlib.suppress(Exception):
cb(data)
with self._output_lock:
self._output_buffer.extend(data)
overflow = len(self._output_buffer) - self._output_buffer_cap
if overflow > 0:
del self._output_buffer[:overflow]
async def write(self, data: str | bytes, *, newline: bool = True) -> int:
r"""Write bytes to the child's stdin.
Strings are UTF-8 encoded. When `newline=True` (the default) the
payload is wrapped in xterm bracketed-paste markers (`ESC [ 200 ~`
... `ESC [ 201 ~`) and followed by a carriage return — that is the
Enter-key keycode interactive `claude` expects.
Without the bracketed-paste framing, the TUI heuristically treats
bursts longer than ~63 bytes as a paste and *buffers* them in the
input box without submitting; the trailing `\r` is then absorbed
as a newline inside the box rather than acting as Submit.
Bracketed paste makes the framing explicit for any length payload.
Callers that need raw byte streaming (e.g. arrow keys, individual
keypresses) pass `newline=False` and write the framing themselves.
"""
if self._pty is None:
msg = "PtyClaudeProcess not started"
raise RuntimeError(msg)
payload = data.encode("utf-8") if isinstance(data, str) else bytes(data)
if newline:
if payload.endswith(b"\r"):
payload = payload[:-1]
payload = b"\x1b[200~" + payload + b"\x1b[201~\r"
pty = self._pty
return await asyncio.to_thread(pty.write, payload)
async def send_control(self, char: str) -> None:
"""Send a control character (e.g. 'c' for Ctrl-C, 'd' for Ctrl-D)."""
if self._pty is None:
msg = "PtyClaudeProcess not started"
raise RuntimeError(msg)
pty = self._pty
await asyncio.to_thread(pty.sendcontrol, char)
async def wait(self) -> int | None:
"""Block until the child exits; return its exit status."""
if self._pty is None:
return None
pty = self._pty
return await asyncio.to_thread(pty.wait)
async def terminate(self, *, grace: float = 5.0) -> int | None:
"""SIGTERM → wait up to `grace` seconds → SIGKILL ladder."""
if self._pty is None:
return None
pty = self._pty
if pty.isalive():
with contextlib.suppress(OSError):
pty.kill(signal.SIGTERM)
deadline = asyncio.get_running_loop().time() + grace
while pty.isalive() and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.05)
if pty.isalive():
with contextlib.suppress(OSError):
pty.kill(signal.SIGKILL)
return await self._reap()
async def kill(self) -> int | None:
"""Immediate SIGKILL with no grace period."""
if self._pty is None:
return None
pty = self._pty
if pty.isalive():
with contextlib.suppress(OSError):
pty.kill(signal.SIGKILL)
return await self._reap()
async def _reap(self) -> int | None:
pty = self._pty
if pty is None:
return None
exit_status = await asyncio.to_thread(pty.wait)
self._drain_stop.set()
thread = self._drain_thread
if thread is not None and thread.is_alive():
await asyncio.to_thread(thread.join, 1.0)
with contextlib.suppress(OSError):
pty.close(force=True)
return exit_status
async def aclose(self) -> int | None:
"""Idempotent shutdown — terminate if alive, otherwise reap."""
if self._pty is None:
return None
if self._pty.isalive():
return await self.terminate()
return await self._reap()
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
await self.aclose()
__all__: Iterable[str] = (
"PtyClaudeProcess",
"PtyOutputCallback",
"PtyProcessOptions",
"build_argv",
"build_env",
)
View File
+320
View File
@@ -0,0 +1,320 @@
"""Per-turn orchestration.
`TurnManager` glues the lower layers into the per-turn loop: send a user
prompt into the PTY, tail the JSONL until the model finishes, hand back
typed events along the way, and synthesize a `ResultMessage` at the turn
boundary.
Turn-end detection:
- An `assistant` record with `stop_reason ∈ {end_turn, max_tokens,
stop_sequence, refusal}` closes the turn. By default we return as soon
as this terminal record is yielded.
- `stop_reason="tool_use"` does **not** close the turn. In TUI mode claude
orchestrates the tool-use loop itself; we keep streaming and wait for
the next terminal `assistant`.
- Setting `wait_for_turn_duration=True` adds one extra wait: after the
terminal assistant we keep iterating until a
`system.subtype=turn_duration` heartbeat arrives.
JSONL has no native `result` record, so we synthesize one from the
terminal assistant's `usage` plus the `durationMs` carried by the
`turn_duration` heartbeat (when available).
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import AsyncIterator, Callable, Iterable
from typing import TYPE_CHECKING, Any, Self
from claude_code_api.errors import (
BackendError,
MessageParseError,
ProcessError,
SessionError,
classify_pty_failure,
)
from claude_code_api.events import AssistantMessage, Event, ResultMessage, SystemMessage
from claude_code_api.normalizer import normalize
from claude_code_api.watcher import JsonlRecord, JsonlWatcher
if TYPE_CHECKING:
from claude_code_api.pty import PtyClaudeProcess
_TERMINAL_STOP_REASONS: frozenset[str] = frozenset(
{"end_turn", "max_tokens", "stop_sequence", "refusal"}
)
_DEFAULT_FILE_WAIT_TIMEOUT = 30.0
_DEFAULT_TURN_DURATION_TIMEOUT = 5.0
_DEFAULT_STARTUP_DELAY = 1.0
ParseErrorCallback = Callable[[MessageParseError, JsonlRecord], None]
class TurnManager:
"""Drive one turn at a time over a long-lived PTY + JSONL pair.
The manager does NOT own the lifetime of the watcher's start offset —
`JsonlWatcher` tracks that internally, so re-using the same watcher
across turns naturally picks up where the previous turn left off. The
manager DOES own the PTY lifecycle in the common case (`async with` /
`aclose()`); callers that want to share a PTY across managers can
construct with `owns_pty=False`.
Usage:
pty = PtyClaudeProcess(opts)
path = resolve_jsonl_path(pty.cwd, pty.session_id)
watcher = JsonlWatcher(path)
async with TurnManager(pty, watcher) as tm:
async for event in tm.send_user_message("say hi"):
print(event)
"""
def __init__(
self,
pty: PtyClaudeProcess,
watcher: JsonlWatcher,
*,
wait_for_turn_duration: bool = False,
include_meta_user: bool = False,
file_wait_timeout: float | None = _DEFAULT_FILE_WAIT_TIMEOUT,
turn_duration_timeout: float | None = _DEFAULT_TURN_DURATION_TIMEOUT,
startup_delay: float = _DEFAULT_STARTUP_DELAY,
on_parse_error: ParseErrorCallback | None = None,
owns_pty: bool = True,
) -> None:
if file_wait_timeout is not None and file_wait_timeout < 0:
msg = f"file_wait_timeout must be non-negative, got {file_wait_timeout!r}"
raise ValueError(msg)
if turn_duration_timeout is not None and turn_duration_timeout < 0:
msg = f"turn_duration_timeout must be non-negative, got {turn_duration_timeout!r}" # noqa: E501
raise ValueError(msg)
if startup_delay < 0:
msg = f"startup_delay must be non-negative, got {startup_delay!r}"
raise ValueError(msg)
self._pty = pty
self._watcher = watcher
self._wait_for_turn_duration = wait_for_turn_duration
self._include_meta_user = include_meta_user
self._file_wait_timeout = file_wait_timeout
self._turn_duration_timeout = turn_duration_timeout
self._startup_delay = startup_delay
self._on_parse_error = on_parse_error
self._owns_pty = owns_pty
self._started = False
self._turn_count = 0
self._turn_in_progress = False
@property
def pty(self) -> PtyClaudeProcess:
return self._pty
@property
def watcher(self) -> JsonlWatcher:
return self._watcher
@property
def turn_count(self) -> int:
"""Number of turns completed (or in progress) so far."""
return self._turn_count
async def start(self) -> None:
"""Spawn the PTY and let claude's TUI settle. Idempotent."""
if self._started:
return
await self._pty.start()
if self._startup_delay > 0:
await asyncio.sleep(self._startup_delay)
self._started = True
async def send_user_message(self, text: str) -> AsyncIterator[Event]:
"""Send `text` as a user prompt and stream events until turn-end.
Async generator: yields every typed event normalized from new JSONL
records (user/assistant/system) and, as its final value, a
synthesized `ResultMessage`.
"""
if not self._started:
msg = "TurnManager.send_user_message() called before start()"
raise RuntimeError(msg)
if self._turn_in_progress:
msg = "send_user_message() called while a turn is in progress"
raise RuntimeError(msg)
self._turn_in_progress = True
self._turn_count += 1
try:
try:
await self._pty.write(text)
except OSError as exc:
raise self._classify_pty_failure(
fallback_message="claude process not accepting input"
) from exc
if not self._watcher.path.exists():
try:
await self._watcher.wait_for_file(timeout=self._file_wait_timeout)
except TimeoutError as exc:
raise self._classify_pty_failure(
fallback_cls=SessionError,
fallback_message=(
f"JSONL file did not appear within "
f"{self._file_wait_timeout}s: {self._watcher.path}"
),
) from exc
terminal_assistant: AssistantMessage | None = None
terminal_seen_at: float | None = None
poll = self._watcher.poll_interval
loop = asyncio.get_running_loop()
while True:
records = await self._watcher.read_once()
if not records:
if (
terminal_assistant is not None
and terminal_seen_at is not None
and self._turn_duration_timeout is not None
and loop.time() - terminal_seen_at > self._turn_duration_timeout
):
yield self._synthesize_result(terminal_assistant, None)
return
if terminal_assistant is None and not self._pty_is_alive():
raise self._classify_pty_failure(
fallback_message=(
"claude process exited before a terminal "
"assistant message was emitted"
)
)
await asyncio.sleep(poll)
continue
for rec in records:
try:
event = normalize(
rec, include_meta_user=self._include_meta_user
)
except MessageParseError as exc:
if self._on_parse_error is not None:
with contextlib.suppress(Exception):
self._on_parse_error(exc, rec)
continue
if event is None:
continue
yield event
if isinstance(event, AssistantMessage):
if event.stop_reason in _TERMINAL_STOP_REASONS:
terminal_assistant = event
terminal_seen_at = loop.time()
if not self._wait_for_turn_duration:
yield self._synthesize_result(terminal_assistant, None)
return
elif (
isinstance(event, SystemMessage)
and event.subtype == "turn_duration"
and terminal_assistant is not None
):
duration_ms = _extract_duration_ms(event.data)
yield self._synthesize_result(terminal_assistant, duration_ms)
return
finally:
self._turn_in_progress = False
def _pty_is_alive(self) -> bool:
"""Best-effort liveness check. Test fakes may lack `is_alive()`."""
is_alive = getattr(self._pty, "is_alive", None)
if is_alive is None:
return True
try:
return bool(is_alive())
except Exception:
return True
def _captured_pty_output(self) -> bytes:
"""Best-effort snapshot of the PTY drain buffer."""
captured = getattr(self._pty, "captured_output", None)
if captured is None:
return b""
try:
value = captured()
except Exception:
return b""
if isinstance(value, bytearray):
return bytes(value)
return value if isinstance(value, bytes) else b""
def _classify_pty_failure(
self,
*,
fallback_cls: type[BackendError] = ProcessError,
fallback_message: str = "claude process failed",
) -> BackendError:
"""Build the typed exception that fits the current PTY state."""
captured = self._captured_pty_output()
cls = classify_pty_failure(captured) or fallback_cls
if issubclass(cls, ProcessError):
exit_code = getattr(self._pty, "_pty", None)
real_exit: int | None = None
inner = getattr(exit_code, "exitstatus", None)
if isinstance(inner, int):
real_exit = inner
stderr_text = (
captured.decode("utf-8", errors="replace") if captured else None
)
return cls(fallback_message, exit_code=real_exit, stderr=stderr_text)
return cls(fallback_message)
def _synthesize_result(
self, assistant: AssistantMessage, duration_ms: int | None
) -> ResultMessage:
return ResultMessage(
subtype="success",
duration_ms=duration_ms if duration_ms is not None else 0,
num_turns=self._turn_count,
session_id=self._pty.session_id,
is_error=False,
stop_reason=assistant.stop_reason,
usage=assistant.usage,
)
async def aclose(self) -> None:
"""Shut down — terminate the PTY if we own it."""
if self._owns_pty:
await self._pty.aclose()
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
await self.aclose()
def _extract_duration_ms(data: dict[str, Any]) -> int | None:
"""Pull a turn-duration value out of a `system.subtype=turn_duration` record."""
for key in ("durationMs", "duration_ms"):
value = data.get(key)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
nested = data.get("data")
if isinstance(nested, dict):
for key in ("durationMs", "duration_ms"):
value = nested.get(key)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
return None
__all__: Iterable[str] = ("ParseErrorCallback", "TurnManager")
+179
View File
@@ -0,0 +1,179 @@
"""Polling tail of a `claude` JSONL session file.
`JsonlWatcher` watches a single `<session_id>.jsonl` and yields each appended
record as a parsed `dict`. It is intentionally dumb about semantics — turning
records into Anthropic events lives in `normalizer.py`, orchestrating turns
lives in `turn.py`.
Guarantees:
- the file may not exist yet when `tail()` starts — the watcher waits;
- bytes are read incrementally from a tracked offset, so reopening on every
poll is cheap (no full re-scan);
- a record split across two polls is held in an internal byte buffer until
the trailing newline arrives;
- a malformed JSON line is delegated to an optional callback and otherwise
dropped — one bad record must not stall the stream.
"""
from __future__ import annotations
import asyncio
import contextlib
import errno
import json
from collections.abc import AsyncIterator, Callable, Iterable
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import os
JsonlRecord = dict[str, Any]
ParseErrorCallback = Callable[[bytes, json.JSONDecodeError], None]
_DEFAULT_POLL_INTERVAL = 0.1
_DEFAULT_READ_CHUNK = 65536
class JsonlWatcher:
"""Tail one JSONL file. One watcher per session.
Usage:
watcher = JsonlWatcher(path)
async for record in watcher.tail():
handle(record)
`tail()` is an unbounded async iterator — it never returns on its own.
Stop it by cancelling the consuming task (or by breaking out of the
`async for`, which propagates `GeneratorExit` and lets the watcher
clean up its internal buffer).
"""
def __init__(
self,
path: str | os.PathLike[str],
*,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
start_offset: int = 0,
on_parse_error: ParseErrorCallback | None = None,
read_chunk: int = _DEFAULT_READ_CHUNK,
) -> None:
if poll_interval <= 0:
msg = f"poll_interval must be positive, got {poll_interval!r}"
raise ValueError(msg)
if start_offset < 0:
msg = f"start_offset must be non-negative, got {start_offset!r}"
raise ValueError(msg)
if read_chunk <= 0:
msg = f"read_chunk must be positive, got {read_chunk!r}"
raise ValueError(msg)
self._path = Path(path)
self._poll_interval = poll_interval
self._on_parse_error = on_parse_error
self._read_chunk = read_chunk
self._offset = start_offset
self._line_buffer = bytearray()
@property
def path(self) -> Path:
return self._path
@property
def offset(self) -> int:
"""Byte offset at which the next read will resume."""
return self._offset
@property
def poll_interval(self) -> float:
return self._poll_interval
async def wait_for_file(self, *, timeout: float | None = None) -> None:
"""Block until `path` exists.
Polls at the configured interval. Raises `TimeoutError` if the file
does not appear before `timeout` seconds elapse. `timeout=None`
means wait forever (callers should rely on task cancellation).
"""
if timeout is not None and timeout < 0:
msg = f"timeout must be non-negative, got {timeout!r}"
raise ValueError(msg)
loop = asyncio.get_running_loop()
deadline = None if timeout is None else loop.time() + timeout
while not self._path.exists():
if deadline is not None and loop.time() >= deadline:
msg = f"JSONL file did not appear within {timeout}s: {self._path}"
raise TimeoutError(msg)
await asyncio.sleep(self._poll_interval)
async def read_once(self) -> list[JsonlRecord]:
"""Single non-blocking pass: drain all currently available records.
Returns `[]` when the file does not exist or no complete record has
been appended since the last call. Updates internal offset and the
partial-line buffer either way.
"""
return await asyncio.to_thread(self._read_available)
async def tail(self) -> AsyncIterator[JsonlRecord]:
"""Yield records as they appear. Runs until cancelled."""
while True:
if not self._path.exists():
await asyncio.sleep(self._poll_interval)
continue
records = await asyncio.to_thread(self._read_available)
if not records:
await asyncio.sleep(self._poll_interval)
continue
for rec in records:
yield rec
def _read_available(self) -> list[JsonlRecord]:
try:
stat = self._path.stat()
except FileNotFoundError:
return []
size = stat.st_size
if size < self._offset:
self._offset = 0
self._line_buffer.clear()
if size == self._offset:
return []
records: list[JsonlRecord] = []
try:
with self._path.open("rb") as f:
f.seek(self._offset)
while True:
chunk = f.read(self._read_chunk)
if not chunk:
break
self._offset += len(chunk)
self._line_buffer.extend(chunk)
self._drain_buffer_into(records)
except OSError as exc:
if exc.errno == errno.ENOENT:
return records
raise
return records
def _drain_buffer_into(self, records: list[JsonlRecord]) -> None:
while True:
nl = self._line_buffer.find(b"\n")
if nl == -1:
return
line = bytes(self._line_buffer[:nl])
del self._line_buffer[: nl + 1]
if not line.strip():
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as exc:
cb = self._on_parse_error
if cb is not None:
with contextlib.suppress(Exception):
cb(line, exc)
__all__: Iterable[str] = ("JsonlRecord", "JsonlWatcher", "ParseErrorCallback")
View File
+852
View File
@@ -0,0 +1,852 @@
"""Unit + smoke tests for Layer 5 (`ClaudeCodeBackend`).
Unit tests inject a `FakePty`-backed session factory so we can drive the
dispatch logic end-to-end — fingerprint lookup, fresh spawn vs continuation,
native_jsonl seeding vs concat_message preamble, post-turn fingerprint
stash — without launching `claude`. The smoke test at the bottom spawns
the real binary behind `RUN_CLAUDE_SMOKE=1`.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
from pathlib import Path
from typing import Any
import pytest
from claude_code_api import (
AssistantMessage,
BackendOptions,
ClaudeCodeBackend,
ResultMessage,
SessionError,
TextBlock,
UserMessage,
)
from claude_code_api.backend import _LiveSession
from claude_code_api.injection import hash_history
from claude_code_api.paths import resolve_jsonl_path
from claude_code_api.watcher import JsonlWatcher
from claude_code_api.turn import TurnManager
# --- fakes -----------------------------------------------------------------
class FakePty:
"""Records writes and flushes a scripted JSONL batch on each `write()`.
Reused shape from `test_turn_manager.py` so the contract stays familiar.
Each backend `complete()` call ultimately drives one `write()` on the
underlying PTY, which consumes the next entry in `scripts`. Tests pre-load
the script list with one batch per expected turn.
"""
def __init__(
self,
jsonl_path: Path,
*,
session_id: str,
scripts: list[list[dict[str, Any]]],
) -> None:
self.cwd = str(jsonl_path.parent)
self.session_id = session_id
self._jsonl = jsonl_path
self._scripts = scripts
self._write_count = 0
self.writes: list[str] = []
self.started = False
self.closed = False
async def start(self) -> None:
self.started = True
async def write(self, text: str, *, newline: bool = True) -> int:
self.writes.append(text)
if self._write_count < len(self._scripts):
self._jsonl.parent.mkdir(parents=True, exist_ok=True)
with self._jsonl.open("a", encoding="utf-8") as f:
for rec in self._scripts[self._write_count]:
f.write(json.dumps(rec) + "\n")
self._write_count += 1
return len(text)
async def aclose(self) -> None:
self.closed = True
def _user_rec(text: str, session_id: str) -> dict[str, Any]:
return {
"type": "user",
"uuid": f"u-{text[:8]}",
"sessionId": session_id,
"parentUuid": None,
"message": {"role": "user", "content": text},
}
def _assistant_rec(
text: str,
session_id: str,
*,
stop_reason: str = "end_turn",
) -> dict[str, Any]:
return {
"type": "assistant",
"uuid": f"a-{text[:8]}",
"sessionId": session_id,
"parentUuid": None,
"message": {
"id": "msg_x",
"role": "assistant",
"model": "claude-test",
"content": [{"type": "text", "text": text}],
"stop_reason": stop_reason,
"usage": {"input_tokens": 1, "output_tokens": 1},
},
}
class FakeFactoryHarness:
"""Builds the `_session_factory` callable the backend wants, while
also tracking every session spawned so tests can inspect them.
Each call to the factory pops the next FakePty script batch off the
queue and wires a real `TurnManager` + `JsonlWatcher` around it — that
way we exercise the same code path real sessions use, only the bottom
layer is faked.
"""
def __init__(self, scripts_per_session: list[list[list[dict[str, Any]]]]) -> None:
self._scripts = list(scripts_per_session)
self.spawned: list[FakePty] = []
self.seed_files: list[tuple[Path, bytes]] = []
def __call__(
self,
backend: ClaudeCodeBackend,
session_id: str,
resume: bool,
jsonl_path: Path,
start_offset: int,
) -> Any:
# Reconstruct the test-visible script for THIS session.
if not self._scripts:
raise AssertionError("FakeFactoryHarness ran out of scripts")
scripts = self._scripts.pop(0)
if resume and jsonl_path.exists():
self.seed_files.append((jsonl_path, jsonl_path.read_bytes()))
fake = FakePty(jsonl_path, session_id=session_id, scripts=scripts)
self.spawned.append(fake)
watcher = JsonlWatcher(jsonl_path, poll_interval=0.01, start_offset=start_offset)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=2.0,
)
async def _start() -> _LiveSession:
await tm.start()
return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type]
return _start()
# --- option / validation tests --------------------------------------------
@pytest.mark.asyncio
async def test_complete_rejects_empty_messages(tmp_path: Path) -> None:
backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path)))
with pytest.raises(ValueError, match="empty"):
async for _ in backend.complete([]):
pass
await backend.aclose()
@pytest.mark.asyncio
async def test_complete_rejects_non_user_last_message(tmp_path: Path) -> None:
backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path)))
with pytest.raises(ValueError, match="user"):
async for _ in backend.complete([{"role": "assistant", "content": "hi"}]):
pass
await backend.aclose()
@pytest.mark.asyncio
async def test_complete_after_aclose_raises(tmp_path: Path) -> None:
backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path)))
await backend.aclose()
with pytest.raises(RuntimeError, match="closed"):
async for _ in backend.complete([{"role": "user", "content": "hi"}]):
pass
# --- single-turn fresh session -------------------------------------------
@pytest.mark.asyncio
async def test_complete_fresh_session_yields_events(tmp_path: Path) -> None:
"""One message → spawn a fresh session, run one turn, get events back.
Because there's no prior history, no seed JSONL gets written. The fake
PTY's `write()` appends a scripted `(user, assistant)` pair to the JSONL
on disk; the real watcher tails it and the real TurnManager closes the
turn on the terminal assistant.
"""
# We need to know the session_id ahead of time? No — let the factory
# pull it from the backend's invocation. The scripts in scripts_per_session
# carry sessionId fields but those are decorative for our purposes —
# the watcher / normalizer don't filter on them.
scripts_per_session = [
# session 0:
[
# turn 0 batch (written on first write())
[
_user_rec("hi", "S0"),
_assistant_rec("hello there", "S0"),
],
],
]
harness = FakeFactoryHarness(scripts_per_session)
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path)),
_session_factory=harness,
)
events: list[Any] = []
async for event in backend.complete([{"role": "user", "content": "hi"}]):
events.append(event)
await backend.aclose()
assert len(harness.spawned) == 1
assert harness.spawned[0].writes == ["hi"]
assert any(isinstance(e, UserMessage) for e in events)
assert any(isinstance(e, AssistantMessage) for e in events)
assert isinstance(events[-1], ResultMessage)
assert events[-1].stop_reason == "end_turn"
# No seed was written — first turn has empty prior history.
assert harness.seed_files == []
# --- multi-turn fingerprint reuse ----------------------------------------
@pytest.mark.asyncio
async def test_continuation_reuses_live_session(tmp_path: Path) -> None:
"""Second `complete()` whose `messages[:-1]` matches the post-turn
fingerprint of the first call must hit the live session — no new PTY,
no seed file.
"""
scripts_per_session = [
# session 0 handles BOTH turns (two write() calls).
[
[_user_rec("hi", "S0"), _assistant_rec("hello there", "S0")],
[_user_rec("again", "S0"), _assistant_rec("hi again", "S0")],
],
]
harness = FakeFactoryHarness(scripts_per_session)
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path)),
_session_factory=harness,
)
events1: list[Any] = []
async for e in backend.complete([{"role": "user", "content": "hi"}]):
events1.append(e)
# Build the continuation: client echoes back our synthesized assistant
# in canonical Anthropic shape (list of blocks).
continuation = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": [{"type": "text", "text": "hello there"}]},
{"role": "user", "content": "again"},
]
events2: list[Any] = []
async for e in backend.complete(continuation):
events2.append(e)
await backend.aclose()
# Only ONE session was spawned across both turns.
assert len(harness.spawned) == 1
assert harness.spawned[0].writes == ["hi", "again"]
# Second turn's events are clean (turn_count bookkeeping):
assert isinstance(events2[-1], ResultMessage)
assert events2[-1].num_turns == 2
@pytest.mark.asyncio
async def test_unmatched_history_spawns_new_session_via_native_jsonl(
tmp_path: Path,
) -> None:
"""When prior history doesn't match any live session, the backend
seeds a JSONL with that history and spawns a fresh `--resume` session
(native_jsonl default mode).
"""
scripts_per_session = [
# one session for one turn — the only write() is the new user message
[
[_user_rec("how are you?", "S0"), _assistant_rec("good", "S0")],
],
]
harness = FakeFactoryHarness(scripts_per_session)
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path)),
_session_factory=harness,
)
# Three messages, no live session in the pool — must seed.
messages = [
{"role": "user", "content": "remember beaver"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "how are you?"},
]
events: list[Any] = []
async for e in backend.complete(messages):
events.append(e)
await backend.aclose()
assert len(harness.spawned) == 1
# Only the LAST user message is sent into the PTY — history went via seed.
assert harness.spawned[0].writes == ["how are you?"]
# A seed file was captured by the harness.
assert len(harness.seed_files) == 1
_seed_path, seed_bytes = harness.seed_files[0]
seed_lines = [
json.loads(line) for line in seed_bytes.decode("utf-8").strip().splitlines()
]
# Two seeded records (one user + one assistant) for the prior turn.
assert [r["type"] for r in seed_lines] == ["user", "assistant"]
assert seed_lines[0]["message"]["content"] == "remember beaver"
assert seed_lines[1]["message"]["content"] == [{"type": "text", "text": "ok"}]
assert isinstance(events[-1], ResultMessage)
@pytest.mark.asyncio
async def test_unmatched_history_uses_concat_message_when_configured(
tmp_path: Path,
) -> None:
"""In `concat_message` mode the backend does NOT write a seed JSONL —
it concatenates the prior history into the first stdin payload."""
scripts_per_session = [
[
[_user_rec("how are you?", "S0"), _assistant_rec("good", "S0")],
],
]
harness = FakeFactoryHarness(scripts_per_session)
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path), history_injection_mode="concat_message"),
_session_factory=harness,
)
messages = [
{"role": "user", "content": "remember beaver"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "how are you?"},
]
async for _ in backend.complete(messages):
pass
await backend.aclose()
assert harness.seed_files == [] # no native injection in concat mode
assert len(harness.spawned) == 1
sent = harness.spawned[0].writes[0]
# The first payload is the concat preamble + the new user prompt.
assert "Previous conversation context:" in sent
assert "[User]: remember beaver" in sent
assert "[Assistant]: ok" in sent
assert "Continue from here. New user message: how are you?" in sent
# --- failure handling ----------------------------------------------------
@pytest.mark.asyncio
async def test_complete_failure_does_not_stash_broken_session(tmp_path: Path) -> None:
"""If the turn iteration raises, the session must be closed and NOT
re-stored under any fingerprint.
"""
class BrokenFactory:
def __init__(self) -> None:
self.spawned: list[FakePty] = []
def __call__(
self,
backend: ClaudeCodeBackend,
session_id: str,
resume: bool,
jsonl_path: Path,
start_offset: int,
) -> Any:
fake = FakePty(jsonl_path, session_id=session_id, scripts=[])
self.spawned.append(fake)
watcher = JsonlWatcher(jsonl_path, poll_interval=0.01)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=0.05, # fires fast — no JSONL ever appears
)
async def _start() -> _LiveSession:
await tm.start()
return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type]
return _start()
factory = BrokenFactory()
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path)),
_session_factory=factory,
)
with pytest.raises(SessionError):
async for _ in backend.complete([{"role": "user", "content": "hi"}]):
pass
assert backend.live_session_count == 0
assert factory.spawned[0].closed is True
await backend.aclose()
# --- cancellation (Stage 9) ----------------------------------------------
class _HangingFactory:
"""Factory whose sessions never produce records — perfect for cancel tests.
`write()` creates the JSONL (so `wait_for_file()` returns immediately) but
leaves it empty, so `TurnManager.send_user_message` enters its poll loop
and stays there until something cancels it from outside.
"""
def __init__(self) -> None:
self.spawned: list[FakePty] = []
def __call__(
self,
backend: ClaudeCodeBackend,
session_id: str,
resume: bool,
jsonl_path: Path,
start_offset: int,
) -> Any:
fake = FakePty(jsonl_path, session_id=session_id, scripts=[[]])
self.spawned.append(fake)
watcher = JsonlWatcher(jsonl_path, poll_interval=0.01)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=2.0,
)
async def _start() -> _LiveSession:
await tm.start()
return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type]
return _start()
@pytest.mark.asyncio
async def test_cancel_mid_turn_closes_session_and_leaves_pool_empty(
tmp_path: Path,
) -> None:
"""task.cancel() on a consumer iterating `complete()` must:
- propagate CancelledError to the consumer,
- tear down the live session (PTY closed via TurnManager.aclose),
- leave the live-session pool empty (broken session is never re-stashed).
"""
factory = _HangingFactory()
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path)),
_session_factory=factory,
)
started = asyncio.Event()
async def consumer() -> None:
async for _ in backend.complete([{"role": "user", "content": "hi"}]):
started.set()
started.set() # also signal if iteration ends naturally (shouldn't here)
task = asyncio.create_task(consumer())
# Let the turn enter its poll loop. The poll interval is 10ms; 200ms is
# plenty for the FakePty.write() + first read_once() to land.
await asyncio.sleep(0.2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert backend.live_session_count == 0
assert len(factory.spawned) == 1
assert factory.spawned[0].closed is True
await backend.aclose()
@pytest.mark.asyncio
async def test_cancel_releases_lock_so_next_complete_works(tmp_path: Path) -> None:
"""After a cancelled turn, the backend's internal lock must be released
so a subsequent `complete()` can run. We follow up with a normal call
against a healthy session and assert it completes end-to-end.
"""
class HangThenRespondFactory:
"""First spawn hangs (cancel target); second spawn completes a turn."""
def __init__(self) -> None:
self._spawn_index = 0
self.spawned: list[FakePty] = []
def __call__(
self,
backend: ClaudeCodeBackend,
session_id: str,
resume: bool,
jsonl_path: Path,
start_offset: int,
) -> Any:
idx = self._spawn_index
self._spawn_index += 1
if idx == 0:
scripts: list[list[dict[str, Any]]] = [[]] # hangs
else:
scripts = [
[
_user_rec("hi", "S1"),
_assistant_rec("hello", "S1"),
]
]
fake = FakePty(jsonl_path, session_id=session_id, scripts=scripts)
self.spawned.append(fake)
watcher = JsonlWatcher(jsonl_path, poll_interval=0.01)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=2.0,
)
async def _start() -> _LiveSession:
await tm.start()
return _LiveSession(pty=fake, watcher=watcher, tm=tm) # type: ignore[arg-type]
return _start()
factory = HangThenRespondFactory()
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path)),
_session_factory=factory,
)
# First call: cancel mid-stream.
async def consumer() -> None:
async for _ in backend.complete([{"role": "user", "content": "hi"}]):
pass
task = asyncio.create_task(consumer())
await asyncio.sleep(0.2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# Second call: must proceed without deadlocking on the lock.
events: list[Any] = []
async for e in backend.complete([{"role": "user", "content": "hi"}]):
events.append(e)
assert len(factory.spawned) == 2
assert factory.spawned[0].closed is True # cancelled session is dead
assert isinstance(events[-1], ResultMessage)
assert events[-1].num_turns == 1 # fresh session, fresh counter
await backend.aclose()
# --- mcp_servers materialization -----------------------------------------
def test_mcp_config_argument_writes_temp_file_lazily(tmp_path: Path) -> None:
"""`mcp_servers` lifts to a temp `--mcp-config` JSON written on first
access; the file is removed in `aclose()`."""
backend = ClaudeCodeBackend(
BackendOptions(
cwd=str(tmp_path),
mcp_servers={"echo": {"command": "/bin/echo", "args": []}},
)
)
paths = backend._mcp_config_argument() # type: ignore[attr-defined]
assert len(paths) == 1
p = Path(paths[0])
assert p.exists()
body = json.loads(p.read_text())
assert body == {"mcpServers": {"echo": {"command": "/bin/echo", "args": []}}}
# Calling again returns the same path; no second file.
paths2 = backend._mcp_config_argument() # type: ignore[attr-defined]
assert paths2 == paths
# aclose() removes the file.
asyncio.run(backend.aclose())
assert not p.exists()
def test_no_mcp_config_returns_empty_tuple(tmp_path: Path) -> None:
backend = ClaudeCodeBackend(BackendOptions(cwd=str(tmp_path)))
assert backend._mcp_config_argument() == () # type: ignore[attr-defined]
# --- post-turn fingerprint key shape -------------------------------------
def test_post_turn_fingerprint_matches_canonical_continuation(tmp_path: Path) -> None:
"""Regression: the backend stashes the live session under
hash_history(messages + [synthesized_assistant]) where the synthesized
assistant uses the `[{"type": "text", "text": ...}]` block shape.
A gateway that echoes that same shape back on the next request must
look up to the same fingerprint. Pin both sides of that contract here.
"""
# Synthesized assistant after one turn yielding "hello there":
synthesized = {
"role": "assistant",
"content": [{"type": "text", "text": "hello there"}],
}
messages_sent = [{"role": "user", "content": "hi"}]
fp_stash = hash_history([*messages_sent, synthesized])
next_request_prior = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": [{"type": "text", "text": "hello there"}]},
]
fp_lookup = hash_history(next_request_prior)
assert fp_stash == fp_lookup
# --- smoke test (real claude) --------------------------------------------
_SMOKE_ENV = "RUN_CLAUDE_SMOKE"
@pytest.mark.skipif(
os.environ.get(_SMOKE_ENV) != "1",
reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test",
)
@pytest.mark.asyncio
async def test_smoke_backend_round_trip(tmp_path: Path) -> None:
"""End-to-end against real claude through the public API.
Single `complete()` call with no prior history → fresh session →
yields events. Asserts the same shape contracts the gateway will
rely on: at least one terminal assistant message and a final
`ResultMessage` whose session_id matches the live PTY.
"""
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path), dangerously_skip_permissions=True),
)
events: list[Any] = []
try:
async for event in backend.complete([{"role": "user", "content": "say hi"}]):
events.append(event)
finally:
await backend.aclose()
terminal = next(
(
e
for e in events
if isinstance(e, AssistantMessage)
and e.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"}
),
None,
)
assert terminal is not None
assert any(isinstance(b, TextBlock) for b in terminal.content)
assert isinstance(events[-1], ResultMessage)
assert events[-1].stop_reason == terminal.stop_reason
@pytest.mark.skipif(
os.environ.get(_SMOKE_ENV) != "1",
reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test",
)
@pytest.mark.asyncio
async def test_smoke_backend_native_jsonl_injection(tmp_path: Path) -> None:
"""Real claude, real injection: send a 3-message history (no live
session yet), the backend writes a seed JSONL and resumes — the
assistant reply must reference the seeded context.
"""
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path), dangerously_skip_permissions=True),
)
messages = [
{"role": "user", "content": "My name is Beaver. Please remember it."},
{"role": "assistant", "content": "Got it — your name is Beaver."},
{"role": "user", "content": "What is my name? Answer with just the name, one word."},
]
events: list[Any] = []
try:
async for event in backend.complete(messages):
events.append(event)
finally:
await backend.aclose()
# The seeded JSONL should be visible on disk under the session path.
# (We can't easily get the session_id back here, but the test of
# correctness is in the reply.)
terminal = next(
(
e
for e in events
if isinstance(e, AssistantMessage) and e.stop_reason == "end_turn"
),
None,
)
assert terminal is not None
text = " ".join(b.text for b in terminal.content if isinstance(b, TextBlock))
assert "beaver" in text.lower(), f"injection failed to plant context; got {text!r}"
# Sanity: the file the backend resumed against exists and contains our seed.
session_id = events[-1].session_id # type: ignore[union-attr]
assert isinstance(session_id, str)
jsonl_path = resolve_jsonl_path(str(tmp_path), session_id)
assert jsonl_path.exists()
# The seeded user record's content text is in the file.
assert "My name is Beaver" in jsonl_path.read_text()
@pytest.mark.skipif(
os.environ.get(_SMOKE_ENV) != "1",
reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test",
)
@pytest.mark.asyncio
async def test_smoke_cancellation_kills_pty_no_zombie(tmp_path: Path) -> None:
"""Smoke 4 (Stage 9): cancel a real long-running turn, assert the PTY
dies cleanly with no zombie left behind.
Strategy:
- prompt claude with something verbose so the turn stays in flight
long enough for us to cancel mid-stream;
- wrap the spawn through `_session_factory` so we can capture the
live `PtyClaudeProcess` while it's still in flight (the backend
does NOT keep in-flight sessions in `_sessions`);
- cancel the consumer task as soon as we've seen at least one event
(proving the turn really started — otherwise we'd be cancelling a
not-yet-spawned session);
- after the cancel propagates, assert: PTY is dead (no `kill -0`),
pool is empty, and a second `complete()` on the same backend still
works (lock was released).
"""
import signal as _signal
captured: list[Any] = [] # collected _LiveSession objects
backend_box: dict[str, ClaudeCodeBackend] = {}
def capturing_factory(
backend: ClaudeCodeBackend,
session_id: str,
resume: bool,
jsonl_path: Path,
start_offset: int,
) -> Any:
async def _real() -> Any:
session = await backend._spawn_real_session( # type: ignore[attr-defined]
session_id=session_id,
resume=resume,
jsonl_path=jsonl_path,
start_offset=start_offset,
)
captured.append(session)
return session
return _real()
backend = ClaudeCodeBackend(
BackendOptions(cwd=str(tmp_path), dangerously_skip_permissions=True),
_session_factory=capturing_factory,
)
backend_box["b"] = backend
saw_event = asyncio.Event()
events: list[Any] = []
long_prompt = (
"Please count slowly from 1 to 500, one number per line, in plain text. "
"Do not stop until you reach 500."
)
async def consumer() -> None:
async for event in backend.complete([{"role": "user", "content": long_prompt}]):
events.append(event)
saw_event.set()
task = asyncio.create_task(consumer())
try:
# Wait until we have at least one event so we know the turn is in
# flight on a live PTY. 30s is comfortably above the typical
# spawn + first-record latency (~3-5s for cold claude startup).
await asyncio.wait_for(saw_event.wait(), timeout=30.0)
assert len(captured) == 1, (
f"expected exactly one captured session at cancel time; got {len(captured)}"
)
live = captured[0]
pid = live.pty.pid
assert pid is not None and pid > 0
assert live.pty.is_alive() is True
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# SIGTERM ladder runs inside session.aclose() during cleanup, so by
# the time `await task` returns the PTY has been reaped.
assert live.pty.is_alive() is False, "PTY still alive after cancel cleanup"
assert backend.live_session_count == 0, (
"cancelled session must not be re-stashed in the live pool"
)
# Belt-and-suspenders: confirm the OS no longer has the pid.
# `os.kill(pid, 0)` raises ProcessLookupError when the process is gone;
# any other state (zombie not yet reaped, still alive) raises something
# else or returns successfully. We accept both ProcessLookupError and
# the kernel reporting the pid is gone.
try:
os.kill(pid, 0)
# If we got here, the pid is still claimable. With pty.close(force=True)
# in _reap that shouldn't happen, but on macOS the reap might race
# very briefly — give it one more beat.
await asyncio.sleep(0.2)
with pytest.raises(ProcessLookupError):
os.kill(pid, 0)
except ProcessLookupError:
pass # expected: process is gone
# Lock released — a fresh call must still work end-to-end.
followup_events: list[Any] = []
async for ev in backend.complete([{"role": "user", "content": "say hi"}]):
followup_events.append(ev)
assert isinstance(followup_events[-1], ResultMessage), (
"follow-up turn failed; backend may have leaked state after cancel"
)
finally:
# Defensive: if anything above failed, make sure we don't leave a
# zombie claude around for the next test run.
if not task.done():
task.cancel()
with contextlib.suppress(BaseException):
await task
for s in captured:
if s.pty.is_alive():
with contextlib.suppress(BaseException):
s.pty._pty.kill(_signal.SIGKILL) # type: ignore[union-attr]
await backend.aclose()
+125
View File
@@ -0,0 +1,125 @@
"""Unit tests for the Stage 10 error hierarchy + PTY-output classifier."""
from __future__ import annotations
import pytest
from claude_code_api import (
AuthError,
BackendError,
CLINotFoundError,
MessageParseError,
ProcessError,
RateLimitError,
SessionError,
classify_pty_failure,
)
def test_hierarchy_roots_under_backend_error() -> None:
# Every backend-emitted exception must descend from BackendError so a
# gateway can install a single catch-all handler.
for cls in (
AuthError,
MessageParseError,
ProcessError,
RateLimitError,
SessionError,
):
assert issubclass(cls, BackendError)
assert issubclass(CLINotFoundError, ProcessError)
def test_process_error_carries_exit_code_and_stderr_in_message() -> None:
exc = ProcessError("boom", exit_code=7, stderr="line1\nline2")
assert exc.exit_code == 7
assert exc.stderr == "line1\nline2"
rendered = str(exc)
assert "boom" in rendered
assert "exit code: 7" in rendered
assert "line1" in rendered # included in the tail
def test_process_error_tail_caps_huge_stderr() -> None:
# A 5KB blob should not embed wholesale in the message.
blob = "x" * 5000
exc = ProcessError("oops", stderr=blob)
rendered = str(exc)
# Tail is capped to 2000 chars in the formatter.
assert rendered.count("x") <= 2000 + 10 # +slack for any literal 'x' in prefix
def test_cli_not_found_appends_executable() -> None:
exc = CLINotFoundError(executable="/usr/local/bin/claude")
assert "/usr/local/bin/claude" in str(exc)
assert exc.executable == "/usr/local/bin/claude"
# Default constructor is also valid.
bare = CLINotFoundError()
assert "not found" in str(bare).lower()
def test_classify_pty_failure_returns_none_when_no_marker() -> None:
assert classify_pty_failure(b"the model is thinking...") is None
assert classify_pty_failure("") is None
def test_classify_auth_markers() -> None:
assert classify_pty_failure(b"Failed to authenticate (status 401)") is AuthError
assert classify_pty_failure(b"API Error: 403 Forbidden") is AuthError
# claude-p's compact match handles "Please run /login" even when ANSI
# / spinner punctuation splits the words.
assert classify_pty_failure(b"Please run /login to continue.") is AuthError
assert (
classify_pty_failure(b"\x1b[31mPlease\x1b[0m run /login")
is AuthError
)
def test_classify_rate_limit_markers() -> None:
assert classify_pty_failure(b"You've hit your limit. Try again later.") is RateLimitError
assert classify_pty_failure(b"You have hit your limit.") is RateLimitError
# Bare form (TUI sometimes wraps the noun out).
assert classify_pty_failure(b"hit your limit") is RateLimitError
def test_classify_strips_ansi_before_matching() -> None:
# Common SGR sequences should not block the marker.
coloured = b"\x1b[1;31mYou've hit your limit\x1b[0m"
assert classify_pty_failure(coloured) is RateLimitError
def test_classify_accepts_str_or_bytes() -> None:
assert classify_pty_failure("Failed to authenticate") is AuthError
assert classify_pty_failure(b"Failed to authenticate") is AuthError
def test_auth_and_rate_limit_default_messages() -> None:
# Default messages are descriptive enough to surface to a gateway.
assert "auth" in str(AuthError()).lower()
assert "rate" in str(RateLimitError()).lower() or "limit" in str(RateLimitError()).lower()
def test_session_error_is_plain_backend_error() -> None:
# No special fields — just a typed marker.
exc = SessionError("never appeared")
assert isinstance(exc, BackendError)
assert "never appeared" in str(exc)
def test_message_parse_error_carries_data() -> None:
payload = {"oops": True}
exc = MessageParseError("bad shape", data=payload)
assert exc.data is payload
def test_session_error_is_not_a_timeout_error() -> None:
# We deliberately broke the TimeoutError lineage: gateways that used to
# catch TimeoutError must migrate to SessionError. Pin that.
assert not issubclass(SessionError, TimeoutError)
def test_raise_chain_smoke() -> None:
with pytest.raises(AuthError):
raise AuthError()
with pytest.raises(BackendError):
raise RateLimitError()
+193
View File
@@ -0,0 +1,193 @@
"""Unit tests for `history_injection` helpers.
Pure functions, no claude / no filesystem. The seed-JSONL shape is regression
tested against the same minimal contract that `probe_jsonl_injection.py`
proved out empirically (see FINDINGS § *Native JSONL injection works on
--resume*).
"""
from __future__ import annotations
import json
import pytest
from claude_code_api.injection import (
build_concat_prompt,
build_seed_jsonl,
hash_history,
)
# --- hash_history ---------------------------------------------------------
def test_hash_history_empty_is_stable() -> None:
assert hash_history([]) == hash_history([])
def test_hash_history_distinguishes_content() -> None:
a = [{"role": "user", "content": "hi"}]
b = [{"role": "user", "content": "bye"}]
assert hash_history(a) != hash_history(b)
def test_hash_history_ignores_block_key_order() -> None:
"""Two clients that serialize the same block in different key orders
must collide. Canonical-JSON serialization handles this."""
a = [
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "t1", "name": "echo", "input": {"x": 1}}],
}
]
b = [
{
"role": "assistant",
"content": [{"input": {"x": 1}, "name": "echo", "id": "t1", "type": "tool_use"}],
}
]
assert hash_history(a) == hash_history(b)
def test_hash_history_rejects_unknown_role() -> None:
with pytest.raises(ValueError, match="role"):
hash_history([{"role": "system", "content": "x"}])
def test_hash_history_text_blocks_collide_with_string_form() -> None:
"""A bare string `content` and the equivalent single text block hash to
DIFFERENT values. They represent the same semantic content but appear
on the wire differently the gateway must pick one form per role and
stay consistent. We don't try to paper over that here."""
a = [{"role": "user", "content": "hello"}]
b = [{"role": "user", "content": [{"type": "text", "text": "hello"}]}]
assert hash_history(a) != hash_history(b)
# --- build_seed_jsonl -----------------------------------------------------
def test_build_seed_jsonl_empty_is_empty_string() -> None:
assert build_seed_jsonl([], session_id="s", cwd="/tmp") == ""
def test_build_seed_jsonl_two_records_for_one_turn() -> None:
seed = build_seed_jsonl(
[
{"role": "user", "content": "My name is Beaver."},
{"role": "assistant", "content": "Got it."},
],
session_id="sid-1",
cwd="/work",
)
lines = [json.loads(line) for line in seed.strip().splitlines()]
assert len(lines) == 2
user_rec, asst_rec = lines
assert user_rec["type"] == "user"
assert user_rec["sessionId"] == "sid-1"
assert user_rec["cwd"] == "/work"
assert user_rec["parentUuid"] is None
assert user_rec["message"] == {"role": "user", "content": "My name is Beaver."}
assert user_rec["isMeta"] is False
assert "uuid" in user_rec and "timestamp" in user_rec
assert asst_rec["type"] == "assistant"
assert asst_rec["parentUuid"] == user_rec["uuid"]
assert asst_rec["message"]["role"] == "assistant"
assert asst_rec["message"]["content"] == [{"type": "text", "text": "Got it."}]
assert asst_rec["message"]["stop_reason"] == "end_turn"
assert asst_rec["sessionId"] == "sid-1"
def test_build_seed_jsonl_chains_parent_uuids_across_turns() -> None:
"""The parentUuid graph must form a linear chain across turns — that's
how claude reconstructs conversation order on resume."""
seed = build_seed_jsonl(
[
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "a2"},
],
session_id="s",
cwd="/tmp",
)
recs = [json.loads(line) for line in seed.strip().splitlines()]
assert len(recs) == 4
assert recs[0]["parentUuid"] is None
assert recs[1]["parentUuid"] == recs[0]["uuid"]
assert recs[2]["parentUuid"] == recs[1]["uuid"]
assert recs[3]["parentUuid"] == recs[2]["uuid"]
def test_build_seed_jsonl_passes_list_content_through_for_user() -> None:
"""A user record with a tool_result block (the only list-form user
content claude itself writes) must round-trip verbatim."""
seed = build_seed_jsonl(
[
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "42"},
],
}
],
session_id="s",
cwd="/tmp",
)
rec = json.loads(seed.strip())
assert rec["message"]["content"] == [
{"type": "tool_result", "tool_use_id": "t1", "content": "42"},
]
def test_build_seed_jsonl_rejects_unknown_role() -> None:
with pytest.raises(ValueError, match="role"):
build_seed_jsonl([{"role": "system", "content": "x"}], session_id="s", cwd="/tmp")
# --- build_concat_prompt --------------------------------------------------
def test_build_concat_prompt_empty_history_returns_just_last_user() -> None:
assert build_concat_prompt([], "hello") == "hello"
def test_build_concat_prompt_renders_alternating_history() -> None:
out = build_concat_prompt(
[
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "a2"},
],
"u3",
)
assert "Previous conversation context:" in out
assert "[User]: u1" in out
assert "[Assistant]: a1" in out
assert "[User]: u2" in out
assert "[Assistant]: a2" in out
assert "Continue from here. New user message: u3" in out
# The new prompt must come after the history, not interleaved.
assert out.index("[Assistant]: a2") < out.index("Continue from here")
def test_build_concat_prompt_flattens_text_blocks_and_skips_tools() -> None:
"""Content-as-list with text blocks gets flattened; tool blocks are
skipped (they don't round-trip through stdin in any useful form)."""
out = build_concat_prompt(
[
{
"role": "assistant",
"content": [
{"type": "text", "text": "hello"},
{"type": "tool_use", "id": "t1", "name": "x", "input": {}},
{"type": "text", "text": "world"},
],
},
],
"ping",
)
assert "[Assistant]: hello world" in out
+421
View File
@@ -0,0 +1,421 @@
"""Unit tests for Layer 3 (`event_normalizer.normalize`).
All fixtures are hand-built dicts shaped like real records observed under
``~/.claude/projects/``; no `claude` is invoked. The normalizer is a pure
function so every test is a one-shot ``normalize(record) -> Event | None``
assertion.
"""
from __future__ import annotations
from typing import Any
import pytest
from claude_code_api import (
AssistantMessage,
MessageParseError,
SystemMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
normalize,
)
# --- envelope metadata shared by every record observed in the wild ---------
_ENVELOPE: dict[str, Any] = {
"parentUuid": "parent-uuid",
"isSidechain": False,
"uuid": "rec-uuid",
"timestamp": "2026-05-16T20:17:27.664Z",
"userType": "external",
"entrypoint": "cli",
"cwd": "/some/cwd",
"sessionId": "sess-uuid",
"version": "2.1.143",
"gitBranch": "HEAD",
}
def _envelope(extra: dict[str, Any]) -> dict[str, Any]:
"""Compose a record with the standard envelope plus the type-specific bits."""
return {**_ENVELOPE, **extra}
# --- user records ----------------------------------------------------------
def test_user_string_content() -> None:
rec = _envelope(
{
"type": "user",
"message": {"role": "user", "content": "hello there"},
}
)
event = normalize(rec)
assert isinstance(event, UserMessage)
assert event.content == "hello there"
assert event.uuid == "rec-uuid"
assert event.session_id == "sess-uuid"
assert event.parent_uuid == "parent-uuid"
def test_user_tool_result_content() -> None:
rec = _envelope(
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": "stdout body",
"is_error": False,
}
],
},
}
)
event = normalize(rec)
assert isinstance(event, UserMessage)
assert isinstance(event.content, list)
assert event.content == [
ToolResultBlock(
tool_use_id="toolu_01",
content="stdout body",
is_error=False,
)
]
def test_user_meta_filtered_by_default() -> None:
rec = _envelope(
{
"type": "user",
"isMeta": True,
"message": {"role": "user", "content": "<local-command-caveat>...</...>"},
}
)
assert normalize(rec) is None
def test_user_meta_emitted_when_opt_in() -> None:
rec = _envelope(
{
"type": "user",
"isMeta": True,
"message": {"role": "user", "content": "x"},
}
)
event = normalize(rec, include_meta_user=True)
assert isinstance(event, UserMessage)
assert event.content == "x"
def test_user_missing_message_raises() -> None:
rec = _envelope({"type": "user"})
with pytest.raises(MessageParseError, match="user record missing"):
normalize(rec)
def test_user_content_wrong_type_raises() -> None:
rec = _envelope({"type": "user", "message": {"content": 42}})
with pytest.raises(MessageParseError, match="content must be str or list"):
normalize(rec)
# --- assistant records -----------------------------------------------------
def test_assistant_text_only() -> None:
rec = _envelope(
{
"type": "assistant",
"message": {
"model": "claude-opus-4-7",
"id": "msg_01",
"role": "assistant",
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 2},
},
}
)
event = normalize(rec)
assert isinstance(event, AssistantMessage)
assert event.content == [TextBlock(text="hi")]
assert event.model == "claude-opus-4-7"
assert event.message_id == "msg_01"
assert event.stop_reason == "end_turn"
assert event.usage == {"input_tokens": 1, "output_tokens": 2}
def test_assistant_all_block_types() -> None:
rec = _envelope(
{
"type": "assistant",
"message": {
"model": "claude-opus-4-7",
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "...", "signature": "sig"},
{"type": "text", "text": "calling tool"},
{
"type": "tool_use",
"id": "toolu_01",
"name": "Bash",
"input": {"command": "ls"},
},
],
"stop_reason": "tool_use",
},
}
)
event = normalize(rec)
assert isinstance(event, AssistantMessage)
assert event.content == [
ThinkingBlock(thinking="...", signature="sig"),
TextBlock(text="calling tool"),
ToolUseBlock(id="toolu_01", name="Bash", input={"command": "ls"}),
]
assert event.stop_reason == "tool_use"
def test_assistant_streaming_chunk_has_null_stop_reason() -> None:
# claude writes partial assistant records mid-turn with stop_reason=null;
# the normalizer surfaces the None so TurnManager can tell partial from
# terminal.
rec = _envelope(
{
"type": "assistant",
"message": {
"model": "claude-opus-4-7",
"role": "assistant",
"content": [{"type": "text", "text": "partial"}],
"stop_reason": None,
},
}
)
event = normalize(rec)
assert isinstance(event, AssistantMessage)
assert event.stop_reason is None
def test_assistant_missing_model_raises() -> None:
rec = _envelope(
{
"type": "assistant",
"message": {"role": "assistant", "content": []},
}
)
with pytest.raises(MessageParseError, match="assistant record missing"):
normalize(rec)
def test_assistant_content_not_list_raises() -> None:
rec = _envelope(
{
"type": "assistant",
"message": {
"model": "claude-opus-4-7",
"role": "assistant",
"content": "not a list",
},
}
)
with pytest.raises(MessageParseError, match="content must be a list"):
normalize(rec)
def test_assistant_unknown_block_type_raises() -> None:
rec = _envelope(
{
"type": "assistant",
"message": {
"model": "claude-opus-4-7",
"role": "assistant",
"content": [{"type": "image", "data": "..."}],
},
}
)
with pytest.raises(MessageParseError, match="unknown content block type"):
normalize(rec)
def test_assistant_tool_use_missing_id_raises() -> None:
rec = _envelope(
{
"type": "assistant",
"message": {
"model": "claude-opus-4-7",
"role": "assistant",
"content": [{"type": "tool_use", "name": "X", "input": {}}],
},
}
)
with pytest.raises(MessageParseError, match="tool_use block missing"):
normalize(rec)
# --- system records --------------------------------------------------------
def test_system_turn_duration_surfaced() -> None:
rec = _envelope(
{
"type": "system",
"subtype": "turn_duration",
"durationMs": 1234,
"messageCount": 5,
"isMeta": False,
}
)
event = normalize(rec)
assert isinstance(event, SystemMessage)
assert event.subtype == "turn_duration"
assert event.session_id == "sess-uuid"
# `data` mirrors the full raw record so callers can pull `durationMs`
# without re-parsing.
assert event.data["durationMs"] == 1234
assert event.data["messageCount"] == 5
def test_system_stop_hook_summary_filtered() -> None:
rec = _envelope(
{
"type": "system",
"subtype": "stop_hook_summary",
"hookCount": 0,
"hookInfos": [],
}
)
assert normalize(rec) is None
def test_system_local_command_filtered() -> None:
rec = _envelope(
{
"type": "system",
"subtype": "local_command",
"content": "<local-command-stdout></local-command-stdout>",
}
)
assert normalize(rec) is None
def test_system_missing_subtype_raises() -> None:
rec = _envelope({"type": "system"})
with pytest.raises(MessageParseError, match="system record missing 'subtype'"):
normalize(rec)
# --- filtered top-level types ---------------------------------------------
@pytest.mark.parametrize(
"record_type",
[
"attachment",
"file-history-snapshot",
"last-prompt",
"ai-title",
"permission-mode",
"queue-operation",
],
)
def test_bookkeeping_types_filtered(record_type: str) -> None:
rec = _envelope({"type": record_type})
assert normalize(rec) is None
def test_unknown_type_silently_dropped() -> None:
# forward-compat: a brand-new top-level record type from a future claude
# version is dropped, not raised.
rec = _envelope({"type": "some-new-record-type"})
assert normalize(rec) is None
# --- error path ------------------------------------------------------------
def test_non_dict_record_raises() -> None:
with pytest.raises(MessageParseError, match="must be a dict"):
normalize("not a dict") # type: ignore[arg-type]
def test_record_missing_type_raises() -> None:
rec = _envelope({})
with pytest.raises(MessageParseError, match="record missing 'type'"):
normalize(rec)
# --- regression fixtures from real session ---------------------------------
def test_real_user_string_record() -> None:
"""Copy-paste of an actual user prompt record from a 2.1.143 session."""
rec = {
"parentUuid": None,
"isSidechain": False,
"promptId": "364db1ee-f587-4096-bc6c-0dc4323512dc",
"type": "user",
"message": {"role": "user", "content": "What is my name?"},
"uuid": "97968a26-6466-4410-84db-2077e65573e1",
"timestamp": "2026-05-16T20:17:27.664Z",
"userType": "external",
"entrypoint": "cli",
"cwd": "/Users/h/projects/playgrounds/claude-code-sdk",
"sessionId": "4df01eee-6026-4782-bdba-d67ab47a3e5b",
"version": "2.1.143",
"gitBranch": "HEAD",
}
event = normalize(rec)
assert isinstance(event, UserMessage)
assert event.content == "What is my name?"
assert event.parent_uuid is None
def test_real_assistant_tool_use_record() -> None:
"""Copy-paste of a real ``stop_reason=tool_use`` assistant record."""
rec = {
"parentUuid": "97968a26-6466-4410-84db-2077e65573e1",
"isSidechain": False,
"message": {
"model": "claude-opus-4-7",
"id": "msg_019Sy3eBbN24Y6YwgxuMvN7g",
"type": "message",
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "...", "signature": "sig"},
{
"type": "tool_use",
"id": "toolu_01XCXcKt7TaDbAKscRPpvumi",
"name": "Bash",
"input": {"command": "ls"},
},
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 6,
"cache_creation_input_tokens": 11211,
"cache_read_input_tokens": 17654,
"output_tokens": 172,
},
},
"requestId": "req_011Cb6s6f7fhCRgo2yhNZY9G",
"type": "assistant",
"uuid": "14e394aa-9faa-4448-8a6c-1365bf2acb8a",
"sessionId": "4df01eee-6026-4782-bdba-d67ab47a3e5b",
}
event = normalize(rec)
assert isinstance(event, AssistantMessage)
assert event.stop_reason == "tool_use"
assert event.usage is not None
assert event.usage["cache_read_input_tokens"] == 17654
assert len(event.content) == 2
assert isinstance(event.content[1], ToolUseBlock)
assert event.content[1].name == "Bash"
+101
View File
@@ -0,0 +1,101 @@
"""Unit tests for `jsonl_paths` — pure string transforms + light fs lookup."""
from __future__ import annotations
import pytest
from claude_code_api.paths import (
claude_home,
encode_project_key,
find_jsonl_by_session_id,
projects_root,
resolve_jsonl_path,
session_dir,
)
# ----- encode_project_key -----------------------------------------------------
@pytest.mark.parametrize(
("cwd", "expected"),
[
# Observed in this repo: bare alnum + slashes + literal dashes.
(
"/Users/h/projects/playgrounds/claude-code-sdk",
"-Users-h-projects-playgrounds-claude-code-sdk",
),
# Observed: dot-prefixed dir produces doubled dash, dash-containing
# path segments survive unchanged.
(
"/Users/h/.t3/worktrees/cars-system/t3code-9d8591ad",
"-Users-h--t3-worktrees-cars-system-t3code-9d8591ad",
),
# Trailing slash collapses to a trailing dash — claude would not
# normally see this, but the encoder is deterministic.
("/Users/h/", "-Users-h-"),
# Root.
("/", "-"),
# Spaces, parentheses, other punct all become dashes.
("/tmp/My Project (v2)", "-tmp-My-Project--v2-"),
],
)
def test_encode_known_paths(cwd: str, expected: str) -> None:
assert encode_project_key(cwd) == expected
def test_encode_rejects_relative() -> None:
with pytest.raises(ValueError, match="absolute"):
encode_project_key("relative/path")
def test_encode_rejects_empty() -> None:
with pytest.raises(ValueError, match="empty"):
encode_project_key("")
# ----- resolve_jsonl_path / session_dir --------------------------------------
def test_resolve_jsonl_path_under_fake_home(tmp_path):
sid = "deadbeef-0000-4000-8000-000000000001"
p = resolve_jsonl_path("/foo/bar", sid, home=tmp_path)
assert p == tmp_path / ".claude" / "projects" / "-foo-bar" / f"{sid}.jsonl"
def test_session_dir_matches_resolve_parent(tmp_path):
sid = "deadbeef-0000-4000-8000-000000000002"
assert resolve_jsonl_path("/a/b", sid, home=tmp_path).parent == session_dir(
"/a/b", home=tmp_path
)
def test_resolve_rejects_empty_session_id(tmp_path):
with pytest.raises(ValueError, match="session_id"):
resolve_jsonl_path("/foo", "", home=tmp_path)
def test_claude_home_and_projects_root_honor_override(tmp_path):
assert claude_home(tmp_path) == tmp_path / ".claude"
assert projects_root(tmp_path) == tmp_path / ".claude" / "projects"
# ----- find_jsonl_by_session_id ---------------------------------------------
def test_find_returns_none_when_root_missing(tmp_path):
# No `.claude/projects` under tmp_path.
assert find_jsonl_by_session_id("nope", home=tmp_path) is None
def test_find_locates_existing_session(tmp_path):
sid = "abcdef00-1111-4000-8000-000000000000"
p = resolve_jsonl_path("/some/cwd", sid, home=tmp_path)
p.parent.mkdir(parents=True)
p.write_text("{}\n")
found = find_jsonl_by_session_id(sid, home=tmp_path)
assert found == p
def test_find_rejects_empty_session_id(tmp_path):
with pytest.raises(ValueError, match="session_id"):
find_jsonl_by_session_id("", home=tmp_path)
+261
View File
@@ -0,0 +1,261 @@
"""Unit + smoke tests for Layer 1 (`PtyClaudeProcess`).
Unit tests exercise pure argv/env construction and don't require `claude`.
The smoke test spawns the real binary and is opt-in via env var because it
hits the user's OAuth state and the wider system.
"""
from __future__ import annotations
import asyncio
import os
import pytest
from claude_code_api import CLINotFoundError
from claude_code_api.pty import (
PtyClaudeProcess,
PtyProcessOptions,
build_argv,
build_env,
)
# --- argv construction ----------------------------------------------------
def test_build_argv_minimal_uses_session_id_and_permission_mode() -> None:
opts = PtyProcessOptions(cwd="/tmp")
argv = build_argv(opts, session_id="abc-123")
assert argv[0] == "claude"
# --session-id must come early so it can be observed in `ps` output even
# if later flags are mistyped/dropped.
assert argv[1:3] == ["--session-id", "abc-123"]
assert "--permission-mode" in argv
pm_index = argv.index("--permission-mode")
assert argv[pm_index + 1] == "bypassPermissions"
# Must never contain headless-only flags.
for forbidden in ("--print", "-p", "--output-format", "--input-format"):
assert forbidden not in argv
def test_build_argv_dangerously_skip_permissions_excludes_permission_mode() -> None:
opts = PtyProcessOptions(cwd="/tmp", dangerously_skip_permissions=True)
argv = build_argv(opts, session_id="s")
assert "--dangerously-skip-permissions" in argv
assert "--permission-mode" not in argv
def test_build_argv_includes_optional_flags_when_set() -> None:
opts = PtyProcessOptions(
cwd="/tmp",
model="claude-opus-4-7",
system_prompt="be brief",
append_system_prompt="also be kind",
allowed_tools=("Read", "Glob"),
disallowed_tools=("Bash",),
mcp_config=("/tmp/a.json", "/tmp/b.json"),
add_dir=("/srv/x", "/srv/y"),
effort="high",
settings="/tmp/settings.json",
extra_args=("--brief",),
)
argv = build_argv(opts, session_id="s")
# Each flag should pair with its value.
def _pairs(flag: str) -> list[str]:
return [argv[i + 1] for i, v in enumerate(argv) if v == flag and i + 1 < len(argv)]
assert _pairs("--model") == ["claude-opus-4-7"]
assert _pairs("--system-prompt") == ["be brief"]
assert _pairs("--append-system-prompt") == ["also be kind"]
# CSV form per claude CLI conventions.
assert _pairs("--allowedTools") == ["Read,Glob"]
assert _pairs("--disallowedTools") == ["Bash"]
assert _pairs("--mcp-config") == ["/tmp/a.json", "/tmp/b.json"]
assert _pairs("--effort") == ["high"]
assert _pairs("--settings") == ["/tmp/settings.json"]
# --add-dir is variadic in claude CLI: one flag, multiple values.
add_dir_at = argv.index("--add-dir")
assert argv[add_dir_at + 1 : add_dir_at + 3] == ["/srv/x", "/srv/y"]
# extra_args are passthrough at the end.
assert argv[-1] == "--brief"
def test_build_argv_omits_unset_optionals() -> None:
opts = PtyProcessOptions(cwd="/tmp")
argv = build_argv(opts, session_id="s")
for flag in (
"--model",
"--system-prompt",
"--append-system-prompt",
"--allowedTools",
"--disallowedTools",
"--mcp-config",
"--add-dir",
"--effort",
"--settings",
):
assert flag not in argv
def test_build_argv_resume_session_id_replaces_session_id_flag() -> None:
"""Resume mode swaps `--session-id <fresh>` for `--resume <existing>`.
claude rejects the two flags together unless `--fork-session` is also
passed (which would branch the session into a new JSONL). Higher layers
pick resume mode when they've seeded a JSONL by hand and need claude to
pick it up rather than create a new one.
"""
opts = PtyProcessOptions(cwd="/tmp", resume_session_id="resume-uuid")
argv = build_argv(opts, session_id="ignored-fresh-uuid")
assert argv[1:3] == ["--resume", "resume-uuid"]
assert "--session-id" not in argv
def test_options_reject_session_id_with_resume_session_id() -> None:
with pytest.raises(ValueError, match="session_id"):
PtyProcessOptions(cwd="/tmp", session_id="a", resume_session_id="b")
def test_pty_process_reports_resume_session_id_as_session_id() -> None:
"""When constructed in resume mode, the process advertises the resumed
session id (the id of the JSONL on disk) not a fresh uuid. Higher
layers rely on `pty.session_id` to compute the JSONL path."""
proc = PtyClaudeProcess(PtyProcessOptions(cwd="/tmp", resume_session_id="seeded-123"))
assert proc.session_id == "seeded-123"
assert "--resume" in proc.argv
assert "--session-id" not in proc.argv
def test_options_reject_invalid_permission_mode() -> None:
with pytest.raises(ValueError, match="permission_mode"):
PtyProcessOptions(cwd="/tmp", permission_mode="banana")
def test_options_reject_nonpositive_dimensions() -> None:
with pytest.raises(ValueError, match="dimensions"):
PtyProcessOptions(cwd="/tmp", dimensions=(0, 80))
# --- env construction -----------------------------------------------------
def test_build_env_strips_provider_vars_by_default() -> None:
base = {
"PATH": "/usr/bin",
"HOME": "/home/x",
"ANTHROPIC_API_KEY": "sk-xxx",
"ANTHROPIC_AUTH_TOKEN": "tok",
"ANTHROPIC_BASE_URL": "https://x.example",
}
env = build_env(PtyProcessOptions(cwd="/tmp"), base=base)
for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"):
assert name not in env
assert env["PATH"] == "/usr/bin"
assert env["HOME"] == "/home/x"
assert env["TERM"] == "xterm-256color"
assert env["NO_COLOR"] == "1"
def test_build_env_preserve_provider_env_keeps_keys() -> None:
base = {"ANTHROPIC_API_KEY": "sk-xxx", "PATH": "/usr/bin"}
opts = PtyProcessOptions(cwd="/tmp", preserve_provider_env=True)
env = build_env(opts, base=base)
assert env["ANTHROPIC_API_KEY"] == "sk-xxx"
def test_build_env_extra_env_overrides_base() -> None:
base = {"PATH": "/usr/bin", "TERM": "dumb"}
opts = PtyProcessOptions(cwd="/tmp", extra_env={"FOO": "bar", "TERM": "vt100"})
env = build_env(opts, base=base)
assert env["FOO"] == "bar"
# Explicit override should win over the default TERM we set in build_env.
assert env["TERM"] == "vt100"
# --- construction-only PtyClaudeProcess sanity ----------------------------
def test_session_id_is_autogenerated_when_omitted() -> None:
proc = PtyClaudeProcess(PtyProcessOptions(cwd="/tmp"))
# UUID4 is 36 chars including dashes.
assert len(proc.session_id) == 36
assert proc.is_alive() is False
assert proc.pid is None
def test_session_id_is_passed_through_when_provided() -> None:
proc = PtyClaudeProcess(PtyProcessOptions(cwd="/tmp", session_id="custom-id"))
assert proc.session_id == "custom-id"
assert "--session-id" in proc.argv
assert proc.argv[proc.argv.index("--session-id") + 1] == "custom-id"
# --- error mapping (Stage 10) ---------------------------------------------
@pytest.mark.asyncio
async def test_start_raises_cli_not_found_when_executable_missing(tmp_path) -> None:
"""`PtyClaudeProcess.start()` lifts ptyprocess's `FileNotFoundError`
(which fires from the pre-fork `which()` lookup) into our typed
`CLINotFoundError` so callers don't need to know about the underlying
library."""
opts = PtyProcessOptions(
cwd=str(tmp_path),
executable="claude-binary-that-does-not-exist-xyz",
dangerously_skip_permissions=True,
)
proc = PtyClaudeProcess(opts)
with pytest.raises(CLINotFoundError) as info:
await proc.start()
assert "claude-binary-that-does-not-exist-xyz" in str(info.value)
assert info.value.executable == "claude-binary-that-does-not-exist-xyz"
# --- smoke test (real claude) ---------------------------------------------
_SMOKE_ENV = "RUN_CLAUDE_SMOKE"
@pytest.mark.skipif(
os.environ.get(_SMOKE_ENV) != "1",
reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test",
)
@pytest.mark.asyncio
async def test_smoke_start_write_terminate(tmp_path) -> None:
"""End-to-end Layer 1 check against the installed `claude` binary.
Spawns claude under a PTY, confirms it's alive, sends a no-op message
(which we don't expect a turn to complete in this test), then terminates
cleanly via SIGTERM. We only assert lifecycle invariants here JSONL
parsing and turn semantics live in later layers.
"""
opts = PtyProcessOptions(
cwd=str(tmp_path),
dangerously_skip_permissions=True,
)
proc = PtyClaudeProcess(opts)
await proc.start()
pid = proc.pid
try:
assert pid is not None and pid > 0
# Give claude a moment to paint the TUI before we ask it to die.
# If it can't even stay alive for a beat, something is fundamentally
# wrong with the spawn (auth blocked, missing HOME, etc.).
await asyncio.sleep(0.5)
captured = proc.captured_output()
assert proc.is_alive(), (
f"claude exited within 0.5s of spawn; captured {len(captured)} bytes:\n"
f"{captured[:1000]!r}"
)
await proc.write("hello")
finally:
exit_status = await proc.terminate(grace=5.0)
assert proc.is_alive() is False
# Either an exit code or a signal — anything other than `None` is fine.
assert exit_status is not None, (
f"terminate() returned None for pid={pid}; output:\n{proc.captured_output()[:1000]!r}"
)
+934
View File
@@ -0,0 +1,934 @@
"""Unit + smoke tests for Layer 4 (`TurnManager`).
Unit tests use a `FakePty` that, on `write()`, dumps a scripted list of JSONL
records into a real temp file. A real `JsonlWatcher` tails that file so the
manager's read/normalize/turn-end loop is exercised end-to-end without
launching `claude`. The smoke test at the bottom spawns the real binary
behind `RUN_CLAUDE_SMOKE=1` and also serves as the empirical probe for
Open Q #2 (PTY echo / buffering).
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any
import pytest
from claude_code_api import (
AssistantMessage,
AuthError,
ProcessError,
RateLimitError,
ResultMessage,
SessionError,
SystemMessage,
TextBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
from claude_code_api.paths import resolve_jsonl_path
from claude_code_api.watcher import JsonlWatcher
from claude_code_api.pty import PtyClaudeProcess, PtyProcessOptions
from claude_code_api.turn import TurnManager
# --- fakes -----------------------------------------------------------------
class FakePty:
"""Stand-in for `PtyClaudeProcess` that flushes a scripted JSONL batch on write.
The script is a list of records that get appended to `jsonl_path` (one
JSON object per line) as soon as the manager calls `write()`. This lets
a single synchronous setup drive the full turn loop no async
coordination, no real `claude`. Multi-write scripts are supported: the
Nth `write()` flushes the Nth element of `scripts`.
Stage 10 additions: `alive` and `output` knobs let tests simulate
sub-process death and error chrome captured from the PTY drain buffer,
which `TurnManager` consults when classifying failures.
"""
def __init__(
self,
tmp_path: Path,
*,
session_id: str = "fake-session-0001",
scripts: list[list[dict[str, Any]]] | None = None,
alive: bool = True,
output: bytes = b"",
) -> None:
self.cwd = str(tmp_path)
self.session_id = session_id
self._jsonl = tmp_path / f"{session_id}.jsonl"
self._scripts = scripts if scripts is not None else []
self._write_count = 0
self.writes: list[str] = []
self.started = False
self.closed = False
self._alive = alive
self._output = output
async def start(self) -> None:
self.started = True
async def write(self, text: str, *, newline: bool = True) -> int:
self.writes.append(text)
if self._write_count < len(self._scripts):
records = self._scripts[self._write_count]
with self._jsonl.open("a", encoding="utf-8") as f:
for rec in records:
f.write(json.dumps(rec) + "\n")
self._write_count += 1
return len(text)
async def aclose(self) -> None:
self.closed = True
# --- Stage 10 surface ----------------------------------------------
def is_alive(self) -> bool:
return self._alive
def captured_output(self) -> bytes:
return self._output
def set_alive(self, alive: bool) -> None:
self._alive = alive
def set_output(self, output: bytes) -> None:
self._output = output
def _user_rec(text: str) -> dict[str, Any]:
return {
"type": "user",
"uuid": f"u-{text[:8]}",
"sessionId": "fake-session-0001",
"parentUuid": None,
"message": {"role": "user", "content": text},
}
def _assistant_rec(
text: str,
*,
stop_reason: str | None = "end_turn",
usage: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"type": "assistant",
"uuid": f"a-{text[:8]}",
"sessionId": "fake-session-0001",
"parentUuid": None,
"message": {
"id": "msg_x",
"role": "assistant",
"model": "claude-test",
"content": [{"type": "text", "text": text}],
"stop_reason": stop_reason,
"usage": usage or {"input_tokens": 1, "output_tokens": 1},
},
}
def _tool_use_assistant_rec(name: str, tool_id: str) -> dict[str, Any]:
return {
"type": "assistant",
"uuid": f"a-tu-{tool_id}",
"sessionId": "fake-session-0001",
"parentUuid": None,
"message": {
"id": "msg_y",
"role": "assistant",
"model": "claude-test",
"content": [{"type": "tool_use", "id": tool_id, "name": name, "input": {}}],
"stop_reason": "tool_use",
"usage": {"input_tokens": 1, "output_tokens": 1},
},
}
def _tool_result_user_rec(tool_id: str, content: str) -> dict[str, Any]:
return {
"type": "user",
"uuid": f"u-tr-{tool_id}",
"sessionId": "fake-session-0001",
"parentUuid": None,
"message": {
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": tool_id, "content": content}],
},
}
def _turn_duration_rec(duration_ms: int = 1234) -> dict[str, Any]:
return {
"type": "system",
"subtype": "turn_duration",
"uuid": "sys-td",
"sessionId": "fake-session-0001",
"durationMs": duration_ms,
}
def _make_manager(
fake: FakePty,
*,
wait_for_turn_duration: bool = False,
startup_delay: float = 0.0,
turn_duration_timeout: float | None = 1.0,
on_parse_error: Any = None,
) -> TurnManager:
"""Build a TurnManager wired to a real JsonlWatcher on the fake's path."""
watcher = JsonlWatcher(
Path(fake.cwd) / f"{fake.session_id}.jsonl",
poll_interval=0.01,
)
return TurnManager(
fake, # type: ignore[arg-type]
watcher,
wait_for_turn_duration=wait_for_turn_duration,
startup_delay=startup_delay,
turn_duration_timeout=turn_duration_timeout,
on_parse_error=on_parse_error,
)
# --- construction validation ----------------------------------------------
def test_init_rejects_negative_file_wait_timeout(tmp_path: Path) -> None:
fake = FakePty(tmp_path)
watcher = JsonlWatcher(tmp_path / "x.jsonl")
with pytest.raises(ValueError, match="file_wait_timeout"):
TurnManager(fake, watcher, file_wait_timeout=-1) # type: ignore[arg-type]
def test_init_rejects_negative_startup_delay(tmp_path: Path) -> None:
fake = FakePty(tmp_path)
watcher = JsonlWatcher(tmp_path / "x.jsonl")
with pytest.raises(ValueError, match="startup_delay"):
TurnManager(fake, watcher, startup_delay=-0.5) # type: ignore[arg-type]
def test_init_rejects_negative_turn_duration_timeout(tmp_path: Path) -> None:
fake = FakePty(tmp_path)
watcher = JsonlWatcher(tmp_path / "x.jsonl")
with pytest.raises(ValueError, match="turn_duration_timeout"):
TurnManager(fake, watcher, turn_duration_timeout=-1) # type: ignore[arg-type]
# --- lifecycle guards -----------------------------------------------------
@pytest.mark.asyncio
async def test_send_before_start_raises(tmp_path: Path) -> None:
fake = FakePty(tmp_path)
tm = _make_manager(fake)
with pytest.raises(RuntimeError, match="before start"):
async for _ in tm.send_user_message("hi"):
pass
@pytest.mark.asyncio
async def test_start_is_idempotent(tmp_path: Path) -> None:
fake = FakePty(tmp_path)
tm = _make_manager(fake)
await tm.start()
await tm.start()
# FakePty.start() flips `started` either way; we just need no exception
# and a stable state machine.
assert fake.started is True
# --- happy path: one turn, terminal end_turn -------------------------------
@pytest.mark.asyncio
async def test_basic_turn_yields_user_assistant_then_result(tmp_path: Path) -> None:
fake = FakePty(
tmp_path,
scripts=[
[
_user_rec("say hi"),
_assistant_rec("hi!", stop_reason="end_turn"),
# turn_duration is in the script but with
# wait_for_turn_duration=False it gets queued behind our
# early return — we don't yield it.
_turn_duration_rec(),
]
],
)
tm = _make_manager(fake)
await tm.start()
events: list[Any] = []
async for event in tm.send_user_message("say hi"):
events.append(event)
await tm.aclose()
assert fake.writes == ["say hi"]
assert isinstance(events[0], UserMessage)
assert isinstance(events[1], AssistantMessage)
assert events[1].stop_reason == "end_turn"
assert isinstance(events[1].content[0], TextBlock)
assert isinstance(events[-1], ResultMessage)
assert events[-1].stop_reason == "end_turn"
assert events[-1].num_turns == 1
assert events[-1].session_id == fake.session_id
# No turn_duration → duration_ms falls back to 0 in the synthesized result.
assert events[-1].duration_ms == 0
@pytest.mark.asyncio
async def test_wait_for_turn_duration_carries_duration_ms(tmp_path: Path) -> None:
fake = FakePty(
tmp_path,
scripts=[
[
_user_rec("ping"),
_assistant_rec("pong", stop_reason="end_turn"),
_turn_duration_rec(duration_ms=4242),
]
],
)
tm = _make_manager(fake, wait_for_turn_duration=True)
await tm.start()
events = [e async for e in tm.send_user_message("ping")]
await tm.aclose()
# We also want the system event itself to be visible in the stream.
assert any(isinstance(e, SystemMessage) and e.subtype == "turn_duration" for e in events)
result = events[-1]
assert isinstance(result, ResultMessage)
assert result.duration_ms == 4242
# --- tool loop continues until next terminal -----------------------------
@pytest.mark.asyncio
async def test_tool_use_stop_reason_does_not_close_turn(tmp_path: Path) -> None:
fake = FakePty(
tmp_path,
scripts=[
[
_user_rec("compute"),
_tool_use_assistant_rec("Bash", "tool_1"),
_tool_result_user_rec("tool_1", "42"),
_assistant_rec("the answer is 42", stop_reason="end_turn"),
]
],
)
tm = _make_manager(fake)
await tm.start()
events = [e async for e in tm.send_user_message("compute")]
await tm.aclose()
assistants = [e for e in events if isinstance(e, AssistantMessage)]
# Both assistant records made it through — the tool_use one did not
# short-circuit the loop.
assert len(assistants) == 2
assert assistants[0].stop_reason == "tool_use"
assert assistants[1].stop_reason == "end_turn"
assert isinstance(events[-1], ResultMessage)
assert events[-1].stop_reason == "end_turn"
# --- error & misuse paths -------------------------------------------------
@pytest.mark.asyncio
async def test_parse_error_callback_keeps_stream_alive(tmp_path: Path) -> None:
# A bogus record (missing `message`) sits between two valid ones. The
# callback should fire once and the stream should still terminate cleanly.
bad = {"type": "assistant", "uuid": "x", "sessionId": "fake-session-0001"}
fake = FakePty(
tmp_path,
scripts=[
[
_user_rec("hi"),
bad,
_assistant_rec("ok", stop_reason="end_turn"),
]
],
)
errors: list[tuple[Exception, dict[str, Any]]] = []
tm = _make_manager(fake, on_parse_error=lambda exc, rec: errors.append((exc, rec)))
await tm.start()
events = [e async for e in tm.send_user_message("hi")]
await tm.aclose()
assert len(errors) == 1
assert errors[0][1] is bad or errors[0][1] == bad
assert isinstance(events[-1], ResultMessage)
@pytest.mark.asyncio
async def test_double_send_raises_while_turn_in_progress(tmp_path: Path) -> None:
# Manager that will NEVER see a terminal assistant (no scripted records).
# Drive one __anext__ on the first generator so it enters the polling loop,
# then attempt a second concurrent send.
fake = FakePty(tmp_path, scripts=[[]])
# Touch the file so the file-wait doesn't block forever.
(tmp_path / f"{fake.session_id}.jsonl").touch()
tm = _make_manager(fake)
await tm.start()
gen1 = tm.send_user_message("first")
# Spin up the generator: schedule one read pass.
task = asyncio.create_task(gen1.__anext__())
await asyncio.sleep(0.05) # let _iter_turn flip turn_in_progress
with pytest.raises(RuntimeError, match="turn is in progress"):
async for _ in tm.send_user_message("second"):
pass
task.cancel()
with pytest.raises((asyncio.CancelledError, StopAsyncIteration)):
await task
await tm.aclose()
@pytest.mark.asyncio
async def test_aclose_terminates_owned_pty(tmp_path: Path) -> None:
fake = FakePty(tmp_path)
tm = _make_manager(fake)
await tm.start()
await tm.aclose()
assert fake.closed is True
@pytest.mark.asyncio
async def test_aclose_skips_pty_when_not_owned(tmp_path: Path) -> None:
fake = FakePty(tmp_path)
watcher = JsonlWatcher(tmp_path / f"{fake.session_id}.jsonl", poll_interval=0.01)
tm = TurnManager(fake, watcher, owns_pty=False, startup_delay=0.0) # type: ignore[arg-type]
await tm.start()
await tm.aclose()
assert fake.closed is False
# --- Stage 10: error mapping ---------------------------------------------
@pytest.mark.asyncio
async def test_session_error_raised_when_jsonl_never_appears(tmp_path: Path) -> None:
"""No script → FakePty.write() doesn't create the JSONL → the
file-wait timeout fires TurnManager raises SessionError (not the
raw asyncio.TimeoutError)."""
fake = FakePty(tmp_path, scripts=[]) # write() is a no-op for JSONL
watcher = JsonlWatcher(
tmp_path / f"{fake.session_id}.jsonl",
poll_interval=0.01,
)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=0.05, # fire fast
)
await tm.start()
with pytest.raises(SessionError):
async for _ in tm.send_user_message("hi"):
pass
await tm.aclose()
@pytest.mark.asyncio
async def test_auth_marker_in_pty_output_raises_auth_error(tmp_path: Path) -> None:
"""When the JSONL never appears AND captured PTY output carries an
auth-block marker, the classifier promotes the failure to AuthError
(instead of the generic SessionError)."""
fake = FakePty(
tmp_path,
scripts=[],
output=b"Failed to authenticate. Please run /login.\r\n",
)
watcher = JsonlWatcher(
tmp_path / f"{fake.session_id}.jsonl",
poll_interval=0.01,
)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=0.05,
)
await tm.start()
with pytest.raises(AuthError):
async for _ in tm.send_user_message("hi"):
pass
await tm.aclose()
@pytest.mark.asyncio
async def test_rate_limit_marker_promotes_session_error_to_rate_limit(
tmp_path: Path,
) -> None:
"""Same path as the auth case but with a rate-limit marker."""
fake = FakePty(
tmp_path,
scripts=[],
output=b"\x1b[31mYou've hit your limit\x1b[0m. Try again at 9pm.",
)
watcher = JsonlWatcher(
tmp_path / f"{fake.session_id}.jsonl",
poll_interval=0.01,
)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=0.05,
)
await tm.start()
with pytest.raises(RateLimitError):
async for _ in tm.send_user_message("hi"):
pass
await tm.aclose()
@pytest.mark.asyncio
async def test_process_death_mid_poll_raises_process_error(tmp_path: Path) -> None:
"""The JSONL appears (so we leave the wait-for-file phase) but no
terminal assistant ever arrives AND the PTY reports dead. Detection
fires from inside the poll loop, with the captured output included in
the exception so a gateway can log what claude wrote before exiting.
"""
fake = FakePty(
tmp_path,
scripts=[[_user_rec("hi")]], # only the user record — no assistant
output=b"some claude chrome before death\r\n",
)
watcher = JsonlWatcher(
tmp_path / f"{fake.session_id}.jsonl",
poll_interval=0.01,
)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=2.0,
)
await tm.start()
async def consumer() -> list[Any]:
events: list[Any] = []
async for ev in tm.send_user_message("hi"):
events.append(ev)
# Once we've seen the user record, declare the PTY dead so the
# next polling pass enters the failure branch.
if isinstance(ev, UserMessage):
fake.set_alive(False)
return events
with pytest.raises(ProcessError) as info:
await consumer()
assert "exited before a terminal" in str(info.value)
assert info.value.stderr is not None
assert "claude chrome before death" in info.value.stderr
await tm.aclose()
@pytest.mark.asyncio
async def test_process_death_with_rate_limit_marker_raises_rate_limit(
tmp_path: Path,
) -> None:
"""Process-death classifier defers to the PTY marker: if the buffer
carries a rate-limit notice, raise the typed marker, not the generic
ProcessError."""
fake = FakePty(
tmp_path,
scripts=[[_user_rec("hi")]],
output=b"You've hit your limit. Cooling off.",
)
watcher = JsonlWatcher(
tmp_path / f"{fake.session_id}.jsonl",
poll_interval=0.01,
)
tm = TurnManager(
fake, # type: ignore[arg-type]
watcher,
startup_delay=0.0,
file_wait_timeout=2.0,
)
await tm.start()
async def consumer() -> None:
async for ev in tm.send_user_message("hi"):
if isinstance(ev, UserMessage):
fake.set_alive(False)
with pytest.raises(RateLimitError):
await consumer()
await tm.aclose()
# --- multi-turn (Stage 6) -------------------------------------------------
@pytest.mark.asyncio
async def test_two_consecutive_turns_each_yield_only_fresh_records(tmp_path: Path) -> None:
"""Stage 6 core: a second `send_user_message()` on the same manager sees
only the records appended after the first turn ended.
The watcher is reused across turns and tracks the byte offset internally
(see PROGRESS.md decision log: "TurnManager does NOT own
JsonlWatcher.offset"). This test pins that contract.
"""
fake = FakePty(
tmp_path,
scripts=[
[
_user_rec("Q1"),
_assistant_rec("A1", stop_reason="end_turn"),
],
[
_user_rec("Q2"),
_assistant_rec("A2", stop_reason="end_turn"),
],
],
)
tm = _make_manager(fake)
await tm.start()
turn1 = [e async for e in tm.send_user_message("Q1")]
turn2 = [e async for e in tm.send_user_message("Q2")]
await tm.aclose()
assert fake.writes == ["Q1", "Q2"]
# Turn 1: user("Q1"), assistant("A1"), result
assert [type(e).__name__ for e in turn1] == [
"UserMessage",
"AssistantMessage",
"ResultMessage",
]
assert turn1[0].content == "Q1"
assert isinstance(turn1[1], AssistantMessage)
assert isinstance(turn1[1].content[0], TextBlock)
assert turn1[1].content[0].text == "A1"
assert isinstance(turn1[-1], ResultMessage)
assert turn1[-1].num_turns == 1
# Turn 2 must NOT leak any of turn 1's records back to the caller.
assert [type(e).__name__ for e in turn2] == [
"UserMessage",
"AssistantMessage",
"ResultMessage",
]
assert turn2[0].content == "Q2"
assert isinstance(turn2[1], AssistantMessage)
assert isinstance(turn2[1].content[0], TextBlock)
assert turn2[1].content[0].text == "A2"
# Turn-count bookkeeping increments across turns; session_id is stable.
assert isinstance(turn2[-1], ResultMessage)
assert turn2[-1].num_turns == 2
assert turn2[-1].session_id == turn1[-1].session_id == fake.session_id
assert tm.turn_count == 2
@pytest.mark.asyncio
async def test_multi_turn_with_wait_for_turn_duration_carries_each_duration(
tmp_path: Path,
) -> None:
"""When `wait_for_turn_duration=True`, each turn's synthesized result
carries its own duration. The watcher offset advances past the
intervening turn_duration heartbeat so turn 2 starts clean.
"""
fake = FakePty(
tmp_path,
scripts=[
[
_user_rec("ping1"),
_assistant_rec("pong1", stop_reason="end_turn"),
_turn_duration_rec(duration_ms=111),
],
[
_user_rec("ping2"),
_assistant_rec("pong2", stop_reason="end_turn"),
_turn_duration_rec(duration_ms=222),
],
],
)
tm = _make_manager(fake, wait_for_turn_duration=True)
await tm.start()
turn1 = [e async for e in tm.send_user_message("ping1")]
turn2 = [e async for e in tm.send_user_message("ping2")]
await tm.aclose()
assert isinstance(turn1[-1], ResultMessage)
assert turn1[-1].duration_ms == 111
assert turn1[-1].num_turns == 1
assert isinstance(turn2[-1], ResultMessage)
assert turn2[-1].duration_ms == 222
assert turn2[-1].num_turns == 2
# --- smoke test (real claude) ---------------------------------------------
_SMOKE_ENV = "RUN_CLAUDE_SMOKE"
@pytest.mark.skipif(
os.environ.get(_SMOKE_ENV) != "1",
reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test",
)
@pytest.mark.asyncio
async def test_smoke_send_hi(tmp_path: Path) -> None:
"""Smoke 1: end-to-end one-turn against real claude.
Confirms: PTY spawn, JSONL discovery, watcher tail, normalizer mapping,
turn-end detection, and ResultMessage synthesis all line up. Also
doubles as the empirical probe for Open Q #2 — if claude doesn't pick up
our prompt after `pty.write("say hi\\r")`, the JSONL never grows and the
file-wait timeout fires; that failure mode tells us the carriage-return
+ 1s startup delay is not enough and we need a different submit
mechanism.
"""
opts = PtyProcessOptions(
cwd=str(tmp_path),
dangerously_skip_permissions=True,
)
pty = PtyClaudeProcess(opts)
jsonl_path = resolve_jsonl_path(pty.cwd, pty.session_id)
watcher = JsonlWatcher(jsonl_path)
tm = TurnManager(pty, watcher)
try:
await tm.start()
events: list[Any] = []
async for event in tm.send_user_message("say hi"):
events.append(event)
finally:
await tm.aclose()
assistants = [e for e in events if isinstance(e, AssistantMessage)]
assert assistants, (
f"no AssistantMessage in stream; got {[type(e).__name__ for e in events]}"
)
terminal = next(
(
a
for a in assistants
if a.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"}
),
None,
)
assert terminal is not None, (
f"no terminal stop_reason; got {[a.stop_reason for a in assistants]}"
)
assert any(isinstance(b, TextBlock) for b in terminal.content)
assert isinstance(events[-1], ResultMessage)
assert events[-1].stop_reason == terminal.stop_reason
assert events[-1].session_id == pty.session_id
@pytest.mark.skipif(
os.environ.get(_SMOKE_ENV) != "1",
reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test",
)
@pytest.mark.asyncio
async def test_smoke_multi_turn_context_persists(tmp_path: Path) -> None:
"""Smoke 2 (Stage 6): two turns on one TurnManager, the second must see
the first's context.
Turn 1 plants a memorable token via the user message; turn 2 asks for it
back. If the same `--session-id` PTY truly accumulates context (as the
JSONL design implies), the second assistant text contains the token. If
instead each turn ran isolated, the second reply would not know it.
The token is a low-entropy proper noun ("Beaver" same one we used in
the JSONL injection probe) chosen to be unlikely-but-not-impossible to
appear spontaneously, so a false positive remains very unlikely while
keeping the prompt natural.
"""
opts = PtyProcessOptions(
cwd=str(tmp_path),
dangerously_skip_permissions=True,
)
pty = PtyClaudeProcess(opts)
jsonl_path = resolve_jsonl_path(pty.cwd, pty.session_id)
watcher = JsonlWatcher(jsonl_path)
tm = TurnManager(pty, watcher)
turn1_events: list[Any] = []
turn2_events: list[Any] = []
try:
await tm.start()
async for event in tm.send_user_message(
"Please remember: my name is Beaver. Reply with just 'ok'."
):
turn1_events.append(event)
async for event in tm.send_user_message(
"What is my name? Answer with the single word only."
):
turn2_events.append(event)
finally:
await tm.aclose()
# Both turns yielded a synthesized result; num_turns increments.
assert isinstance(turn1_events[-1], ResultMessage)
assert isinstance(turn2_events[-1], ResultMessage)
assert turn1_events[-1].num_turns == 1
assert turn2_events[-1].num_turns == 2
assert turn1_events[-1].session_id == turn2_events[-1].session_id == pty.session_id
assert tm.turn_count == 2
# Second turn's terminal assistant must reference the planted token.
turn2_assistants = [e for e in turn2_events if isinstance(e, AssistantMessage)]
terminal2 = next(
(
a
for a in turn2_assistants
if a.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"}
),
None,
)
assert terminal2 is not None, (
f"no terminal stop_reason in turn 2; got {[a.stop_reason for a in turn2_assistants]}"
)
text2 = " ".join(b.text for b in terminal2.content if isinstance(b, TextBlock))
assert "beaver" in text2.lower(), (
f"turn 2 did not inherit context from turn 1; reply was: {text2!r}"
)
# --- Stage 7: tool calls via external MCP server -------------------------
_REPO_ROOT = Path(__file__).resolve().parent.parent
_ECHO_MCP_SCRIPT = _REPO_ROOT / "scripts" / "echo_mcp_server.py"
@pytest.mark.skipif(
os.environ.get(_SMOKE_ENV) != "1",
reason=f"set {_SMOKE_ENV}=1 to run the real-`claude` smoke test",
)
@pytest.mark.asyncio
async def test_smoke_tool_call_via_mcp(tmp_path: Path) -> None:
"""Smoke 3 (Stage 7): real claude routes a tool call through an external
stdio MCP server, and the resulting `tool_use` + `tool_result` records
surface as typed events.
Setup:
- `scripts/echo_mcp_server.py` is a zero-dep stdio MCP server with one
tool, `echo`, that returns its `text` argument verbatim.
- We point claude at it via a temp `--mcp-config` JSON file (one
server named "echo"). `--strict-mcp-config` keeps the user's
ambient `.mcp.json` from leaking in and changing the tool surface.
Assertions:
- At least one `AssistantMessage.content` carries a `ToolUseBlock`
whose name references the echo tool (claude exposes external MCP
tools as `mcp__<server>__<tool>`, here `mcp__echo__echo`).
- The follow-up `UserMessage` carries a `ToolResultBlock` whose
content includes the marker token we asked the tool to echo
the only place that token can come from is the MCP server, so
seeing it round-tripped proves the full path worked.
- A terminal assistant closes the turn and the synthesized
`ResultMessage` reflects its stop_reason.
"""
assert _ECHO_MCP_SCRIPT.exists(), f"missing echo MCP server at {_ECHO_MCP_SCRIPT}"
marker = "banana42xyz" # low-collision sentinel; must appear in tool_result
mcp_config_path = tmp_path / "mcp_config.json"
mcp_config_path.write_text(
json.dumps(
{
"mcpServers": {
"echo": {
"command": sys.executable,
"args": [str(_ECHO_MCP_SCRIPT)],
},
},
}
)
)
opts = PtyProcessOptions(
cwd=str(tmp_path),
dangerously_skip_permissions=True,
mcp_config=(str(mcp_config_path),),
)
pty = PtyClaudeProcess(opts)
jsonl_path = resolve_jsonl_path(pty.cwd, pty.session_id)
watcher = JsonlWatcher(jsonl_path)
# External MCP servers spawn during claude's startup, so the input box
# mounts a bit later than for a bare session. The 60s file-wait still
# leaves headroom even on a slow first MCP handshake.
tm = TurnManager(pty, watcher, file_wait_timeout=60.0)
prompt = f"Call mcp__echo__echo with text={marker!r}, then reply 'done'."
events: list[Any] = []
try:
await tm.start()
async for event in tm.send_user_message(prompt):
events.append(event)
finally:
await tm.aclose()
# --- assertions ---
tool_uses: list[ToolUseBlock] = []
for ev in events:
if isinstance(ev, AssistantMessage):
tool_uses.extend(b for b in ev.content if isinstance(b, ToolUseBlock))
assert tool_uses, (
"no ToolUseBlock in any assistant message; got "
f"{[type(e).__name__ for e in events]}"
)
echo_uses = [t for t in tool_uses if "echo" in t.name.lower()]
assert echo_uses, (
f"no tool_use referenced the echo tool; saw names {[t.name for t in tool_uses]}"
)
# The marker text only exists on the MCP server side, so finding it in a
# tool_result block proves the round-trip actually completed.
tool_results: list[ToolResultBlock] = []
for ev in events:
if isinstance(ev, UserMessage) and isinstance(ev.content, list):
tool_results.extend(b for b in ev.content if isinstance(b, ToolResultBlock))
assert tool_results, "no ToolResultBlock in any user message after the tool call"
def _result_text(block: ToolResultBlock) -> str:
if isinstance(block.content, str):
return block.content
if isinstance(block.content, list):
chunks: list[str] = []
for part in block.content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
chunks.append(part["text"])
return " ".join(chunks)
return ""
assert any(marker in _result_text(b) for b in tool_results), (
f"marker {marker!r} did not appear in any tool_result; got "
f"{[_result_text(b) for b in tool_results]}"
)
terminal_assistant = next(
(
ev
for ev in events
if isinstance(ev, AssistantMessage)
and ev.stop_reason in {"end_turn", "max_tokens", "stop_sequence", "refusal"}
),
None,
)
assert terminal_assistant is not None, (
"no terminal assistant after tool round-trip; got stop_reasons "
f"{[e.stop_reason for e in events if isinstance(e, AssistantMessage)]}"
)
assert isinstance(events[-1], ResultMessage)
assert events[-1].stop_reason == terminal_assistant.stop_reason
+364
View File
@@ -0,0 +1,364 @@
"""Unit tests for Layer 2 (`JsonlWatcher`).
All tests use temp files; no `claude` involved. The watcher is exercised both
in its single-pass mode (`read_once`) and in its long-running mode (`tail`).
For `tail`, a producer task appends to the file while a consumer pulls from
the async iterator; both run under one event loop with a short poll interval
so tests stay quick.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from claude_code_api.watcher import JsonlWatcher
def _write_records(path: Path, records: list[dict]) -> None:
"""Append JSONL records as a single text blob (with trailing newline)."""
blob = "".join(json.dumps(r) + "\n" for r in records)
with path.open("a", encoding="utf-8") as f:
f.write(blob)
# --- construction validation ------------------------------------------------
def test_init_rejects_nonpositive_poll_interval(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="poll_interval"):
JsonlWatcher(tmp_path / "x.jsonl", poll_interval=0)
with pytest.raises(ValueError, match="poll_interval"):
JsonlWatcher(tmp_path / "x.jsonl", poll_interval=-1)
def test_init_rejects_negative_start_offset(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="start_offset"):
JsonlWatcher(tmp_path / "x.jsonl", start_offset=-1)
def test_init_rejects_nonpositive_read_chunk(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="read_chunk"):
JsonlWatcher(tmp_path / "x.jsonl", read_chunk=0)
def test_path_is_exposed(tmp_path: Path) -> None:
p = tmp_path / "x.jsonl"
w = JsonlWatcher(p)
assert w.path == p
assert w.offset == 0
# --- read_once: synchronous behavior ---------------------------------------
@pytest.mark.asyncio
async def test_read_once_returns_empty_when_file_missing(tmp_path: Path) -> None:
w = JsonlWatcher(tmp_path / "missing.jsonl")
assert await w.read_once() == []
# Offset must not advance when there's nothing to read.
assert w.offset == 0
@pytest.mark.asyncio
async def test_read_once_returns_all_existing_records(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
records = [
{"type": "user", "i": 0},
{"type": "assistant", "i": 1},
{"type": "system", "i": 2},
]
_write_records(p, records)
w = JsonlWatcher(p)
got = await w.read_once()
assert got == records
# Offset should now be at EOF.
assert w.offset == p.stat().st_size
@pytest.mark.asyncio
async def test_read_once_is_incremental(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
_write_records(p, [{"i": 0}])
w = JsonlWatcher(p)
assert await w.read_once() == [{"i": 0}]
# Second pass with no new bytes: empty.
assert await w.read_once() == []
# Append more — only the new ones come out.
_write_records(p, [{"i": 1}, {"i": 2}])
assert await w.read_once() == [{"i": 1}, {"i": 2}]
@pytest.mark.asyncio
async def test_read_once_buffers_partial_line(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
# Write a complete record + a partial record (no trailing newline).
rec1 = {"complete": True}
partial = '{"complete":'
with p.open("w", encoding="utf-8") as f:
f.write(json.dumps(rec1) + "\n")
f.write(partial) # no newline
w = JsonlWatcher(p)
assert await w.read_once() == [rec1]
# Offset has consumed the partial bytes too — they're stashed internally.
assert w.offset == p.stat().st_size
# Now finish the partial line.
with p.open("a", encoding="utf-8") as f:
f.write(" false}\n")
assert await w.read_once() == [{"complete": False}]
@pytest.mark.asyncio
async def test_read_once_skips_blank_lines(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
# Mix in some blank lines — the watcher should ignore them rather than
# treat them as parse errors.
with p.open("w", encoding="utf-8") as f:
f.write("\n")
f.write(json.dumps({"i": 0}) + "\n")
f.write(" \n")
f.write(json.dumps({"i": 1}) + "\n")
f.write("\n")
w = JsonlWatcher(p)
assert await w.read_once() == [{"i": 0}, {"i": 1}]
@pytest.mark.asyncio
async def test_read_once_invokes_parse_error_callback(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
with p.open("w", encoding="utf-8") as f:
f.write(json.dumps({"i": 0}) + "\n")
f.write("this is not json\n")
f.write(json.dumps({"i": 2}) + "\n")
errors: list[tuple[bytes, Exception]] = []
w = JsonlWatcher(p, on_parse_error=lambda line, exc: errors.append((line, exc)))
got = await w.read_once()
# Bad line skipped; valid ones returned.
assert got == [{"i": 0}, {"i": 2}]
assert len(errors) == 1
bad_line, exc = errors[0]
assert bad_line == b"this is not json"
assert isinstance(exc, json.JSONDecodeError)
@pytest.mark.asyncio
async def test_read_once_drops_malformed_silently_without_callback(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
with p.open("w", encoding="utf-8") as f:
f.write("garbage\n")
f.write(json.dumps({"i": 1}) + "\n")
w = JsonlWatcher(p) # no callback
assert await w.read_once() == [{"i": 1}]
@pytest.mark.asyncio
async def test_read_once_handles_chunk_boundary(tmp_path: Path) -> None:
"""A record larger than `read_chunk` must still come out whole."""
p = tmp_path / "s.jsonl"
big = {"payload": "x" * 8000, "i": 0}
small = {"i": 1}
_write_records(p, [big, small])
w = JsonlWatcher(p, read_chunk=128) # force many chunks per record
assert await w.read_once() == [big, small]
@pytest.mark.asyncio
async def test_start_offset_skips_initial_content(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
_write_records(p, [{"i": 0}, {"i": 1}])
initial_size = p.stat().st_size
# Start a watcher pointed at EOF — it should see only future appends.
w = JsonlWatcher(p, start_offset=initial_size)
assert await w.read_once() == []
_write_records(p, [{"i": 2}])
assert await w.read_once() == [{"i": 2}]
@pytest.mark.asyncio
async def test_read_once_resets_on_truncation(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
_write_records(p, [{"i": 0}, {"i": 1}])
w = JsonlWatcher(p)
assert await w.read_once() == [{"i": 0}, {"i": 1}]
# Truncate (or rotate) — write a brand-new shorter file.
p.write_text(json.dumps({"reset": True}) + "\n", encoding="utf-8")
assert await w.read_once() == [{"reset": True}]
assert w.offset == p.stat().st_size
# --- wait_for_file ----------------------------------------------------------
@pytest.mark.asyncio
async def test_wait_for_file_returns_immediately_if_exists(tmp_path: Path) -> None:
p = tmp_path / "exists.jsonl"
p.write_text("", encoding="utf-8")
w = JsonlWatcher(p, poll_interval=0.01)
# If this doesn't return promptly we'd hang — wrap in a tight timeout.
await asyncio.wait_for(w.wait_for_file(timeout=1.0), timeout=1.0)
@pytest.mark.asyncio
async def test_wait_for_file_picks_up_late_creation(tmp_path: Path) -> None:
p = tmp_path / "later.jsonl"
w = JsonlWatcher(p, poll_interval=0.01)
async def create_later() -> None:
await asyncio.sleep(0.05)
p.write_text("", encoding="utf-8")
creator = asyncio.create_task(create_later())
try:
await asyncio.wait_for(w.wait_for_file(timeout=1.0), timeout=1.0)
finally:
await creator
@pytest.mark.asyncio
async def test_wait_for_file_times_out(tmp_path: Path) -> None:
p = tmp_path / "never.jsonl"
w = JsonlWatcher(p, poll_interval=0.01)
with pytest.raises(TimeoutError):
await w.wait_for_file(timeout=0.05)
@pytest.mark.asyncio
async def test_wait_for_file_rejects_negative_timeout(tmp_path: Path) -> None:
w = JsonlWatcher(tmp_path / "x.jsonl")
with pytest.raises(ValueError, match="timeout"):
await w.wait_for_file(timeout=-1)
# --- tail: long-running async iteration ------------------------------------
@pytest.mark.asyncio
async def test_tail_yields_existing_records_first(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
_write_records(p, [{"i": 0}, {"i": 1}])
w = JsonlWatcher(p, poll_interval=0.01)
seen: list[dict] = []
async def consume() -> None:
async for rec in w.tail():
seen.append(rec)
if len(seen) >= 2:
return
await asyncio.wait_for(consume(), timeout=2.0)
assert seen == [{"i": 0}, {"i": 1}]
@pytest.mark.asyncio
async def test_tail_waits_for_file_then_yields(tmp_path: Path) -> None:
p = tmp_path / "delayed.jsonl"
w = JsonlWatcher(p, poll_interval=0.01)
seen: list[dict] = []
async def consume() -> None:
async for rec in w.tail():
seen.append(rec)
if len(seen) >= 1:
return
async def produce() -> None:
await asyncio.sleep(0.05)
_write_records(p, [{"late": True}])
consumer = asyncio.create_task(consume())
producer = asyncio.create_task(produce())
await asyncio.wait_for(asyncio.gather(consumer, producer), timeout=2.0)
assert seen == [{"late": True}]
@pytest.mark.asyncio
async def test_tail_streams_incremental_appends(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
p.write_text("", encoding="utf-8")
w = JsonlWatcher(p, poll_interval=0.01)
seen: list[dict] = []
target = [{"i": 0}, {"i": 1}, {"i": 2}, {"i": 3}]
async def consume() -> None:
async for rec in w.tail():
seen.append(rec)
if len(seen) >= len(target):
return
async def produce() -> None:
for rec in target:
_write_records(p, [rec])
await asyncio.sleep(0.02)
consumer = asyncio.create_task(consume())
producer = asyncio.create_task(produce())
await asyncio.wait_for(asyncio.gather(consumer, producer), timeout=3.0)
assert seen == target
@pytest.mark.asyncio
async def test_tail_handles_appends_arriving_mid_line(tmp_path: Path) -> None:
"""A record split across two writes (no newline in the first) must arrive
as one parsed record once the second chunk lands."""
p = tmp_path / "s.jsonl"
p.write_text("", encoding="utf-8")
w = JsonlWatcher(p, poll_interval=0.01)
seen: list[dict] = []
async def consume() -> None:
async for rec in w.tail():
seen.append(rec)
if len(seen) >= 1:
return
async def produce() -> None:
# Write the first half, sleep past at least one poll, then the rest.
with p.open("a", encoding="utf-8") as f:
f.write('{"split":')
f.flush()
await asyncio.sleep(0.05)
with p.open("a", encoding="utf-8") as f:
f.write(" true}\n")
f.flush()
consumer = asyncio.create_task(consume())
producer = asyncio.create_task(produce())
await asyncio.wait_for(asyncio.gather(consumer, producer), timeout=2.0)
assert seen == [{"split": True}]
@pytest.mark.asyncio
async def test_tail_is_cancellable(tmp_path: Path) -> None:
p = tmp_path / "s.jsonl"
p.write_text("", encoding="utf-8")
w = JsonlWatcher(p, poll_interval=0.01)
async def consume() -> None:
async for _ in w.tail():
pass
task = asyncio.create_task(consume())
# Give it a few poll ticks to settle into the idle loop, then cancel.
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
+5
View File
@@ -0,0 +1,5 @@
[environment]
python = ".venv"
[src]
exclude = ["tests", "examples", "scripts"]
Generated
+273
View File
@@ -0,0 +1,273 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "claude-code-api"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "ptyprocess" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [{ name = "ptyprocess", specifier = ">=0.7" }]
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=4.0" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.29.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
]
[[package]]
name = "identify"
version = "2.6.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-discovery"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "virtualenv"
version = "21.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
]