diff --git a/pyproject.toml b/pyproject.toml index 3e5dc2c..0a512c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.21.2" +version = "0.21.3" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/wt.py b/src/ratatoskr/wt.py new file mode 100644 index 0000000..674da14 --- /dev/null +++ b/src/ratatoskr/wt.py @@ -0,0 +1,92 @@ +"""worldtree-sdk adapter — ratatoskr's single chokepoint onto the Conversation-API SDK. + +Slice-1 foundation of the SDK cutover (issue #20; +`docs/contracts/worldtree_sdk_cutover.contract.md`). A thin adapter that owns ONE +`WorldtreeClient`, built over ratatoskr's own injected `httpx.AsyncClient` +transport, and translates the SDK's error floor into ratatoskr's caller-semantic +exceptions. Two hard invariants anchor it: + + * **INV-CUT-1** — ratatoskr owns the injected transport's lifecycle; the SDK is + given it as `transport=` (so `_owns_client=False`) and MUST NOT close it. + * **INV-CUT-2** — the adapter raises ratatoskr's caller-semantic exceptions; the + ROUTE is the discriminator (never the error body). + +This slice ships only the construction chokepoint (`build_client`) and the error +adapter's DEFAULT rule (`translate_error`: `ApiError` → `SessionApiFailed`, every +discriminated `WorldtreeError` subclass passing through unchanged). Route-specific +error rows (`AgentNotFound`, `InvalidCursor`, `AuthoredHistoryUnavailable`, ...) +and the CLI / web / TUI surface wiring land in later slices. + +Transient-migration note (DEC-4, incremental cutover): a same-named +`SessionApiFailed` still lives in the retiring `ratatoskr.sessions` wrapper and +serves the old hand-rolled path until slice-2 deletes it. No surface wires the +adapter in this slice, so the two never meet at runtime; slice-2 reconciles them +(deletes the `sessions.py` copy, points presenters at this one, which additionally +carries the SDK's parsed `error_code`). +""" + +from __future__ import annotations + +import httpx +from worldtree_sdk import ApiError, AuthProvider, WorldtreeClient + + +class SessionApiFailed(Exception): + """The adapter's DEFAULT caller-semantic error (INV-CUT-2 default row): any SDK + `ApiError` not mapped to a more specific ratatoskr exception surfaces here, + carrying the SDK's cleanly-parsed `status` / `error_code` / `body` verbatim. + The `body` is already UTF-8-byte-bounded and bearer-scrubbed by the SDK.""" + + def __init__(self, *, status: int, error_code: str, body: str | None = None) -> None: + super().__init__( + f"worldtree API failed: status={status}, error_code={error_code!r}" + ) + self.status = status + self.error_code = error_code + self.body = body + + +def build_client( + base_url: str, + *, + api_key: AuthProvider, + admin_key: AuthProvider | None = None, + transport: httpx.AsyncClient, +) -> WorldtreeClient: + """Construct the adapter's `WorldtreeClient` over a ratatoskr-owned transport. + + `transport` is REQUIRED and ratatoskr-owned: injecting it sets the SDK's + `_owns_client=False`, so `WorldtreeClient.aclose()` never closes it — ratatoskr + owns the lifecycle exactly as today (INV-CUT-1). ratatoskr's `api_key` / + `admin_key` map to the SDK's per-request `auth` / `admin_auth` providers; the + injected transport carries ratatoskr's User-Agent / timeout (wired by the + caller in slice-2), NOT the Authorization header — the SDK adds auth per + request. + """ + return WorldtreeClient( + base_url, + auth=api_key, + admin_auth=admin_key, + transport=transport, + ) + + +def translate_error(exc: BaseException) -> BaseException: + """Map an SDK exception to ratatoskr's caller-semantic exception (INV-CUT-2). + + Foundation scope — the § Error map DEFAULT plus discriminated passthrough: + * SDK `ApiError` (the undiscriminated REST floor) → `SessionApiFailed` + carrying `status` / `error_code` / `body`. + * Every other exception — the SDK's discriminated `WorldtreeError` subclasses + (`AgentNotAvailable`, `SessionRetired`, `ResumeError`, `Cancel*`, ...) and + any non-SDK error — passes through by IDENTITY, unchanged. + + Route-specific rows (a 404 on `sessions.create` → `AgentNotFound`, a 404 on + `sessions.write_history` → `AuthoredHistoryUnavailable`, ...) are the ROUTE's + to add at its call-site in later slices, never inferred from the body here. + """ + if isinstance(exc, ApiError): + return SessionApiFailed( + status=exc.status, error_code=exc.error_code, body=exc.body + ) + return exc diff --git a/tests/test_wt.py b/tests/test_wt.py new file mode 100644 index 0000000..8878c64 --- /dev/null +++ b/tests/test_wt.py @@ -0,0 +1,85 @@ +"""Unit tests for the worldtree-sdk adapter (`ratatoskr.wt`) — slice-1 foundation. + +Covers the two foundation surfaces (issue #20 cutover contract, slice 1): + * `build_client` — construction wiring + injected-transport ownership (INV-CUT-1: + the SDK must never close ratatoskr's transport). + * `translate_error` — the § Error map DEFAULT (`ApiError` → `SessionApiFailed`) + plus discriminated-`WorldtreeError` passthrough (INV-CUT-2). + +No ratatoskr surface (CLI / web / TUI) is exercised here — that wiring lands in +slice 2. These tests hit no network (WorldtreeClient does no I/O at construction). +""" + +from __future__ import annotations + +import httpx +from worldtree_sdk import AgentNotAvailable, ApiError, WorldtreeClient + +from ratatoskr.wt import SessionApiFailed, build_client, translate_error + + +class TestBuildClient: + async def test_constructs_worldtree_client(self) -> None: + transport = httpx.AsyncClient() + try: + client = build_client( + "https://wt.example:8081", api_key="ck-test", transport=transport + ) + assert isinstance(client, WorldtreeClient) + assert client.base_url == "https://wt.example:8081" + finally: + await transport.aclose() + + async def test_injected_transport_is_ratatoskr_owned(self) -> None: + # INV-CUT-1 [hard]: aclose() on the SDK client must NOT close ratatoskr's + # transport — ratatoskr owns the lifecycle exactly as it does today. + transport = httpx.AsyncClient() + client = build_client("https://wt.example", api_key="ck", transport=transport) + await client.aclose() + assert client.closed is True + assert transport.is_closed is False + await transport.aclose() + + async def test_admin_key_optional(self) -> None: + transport = httpx.AsyncClient() + try: + # Absent admin_key → admin_auth=None; still constructs. + without_admin = build_client( + "https://wt.example", api_key="ck", transport=transport + ) + assert isinstance(without_admin, WorldtreeClient) + # Present admin_key → constructs (admin surface available in later slices). + with_admin = build_client( + "https://wt.example", api_key="ck", admin_key="ak", transport=transport + ) + assert isinstance(with_admin, WorldtreeClient) + finally: + await transport.aclose() + + +class TestTranslateError: + def test_apierror_maps_to_session_api_failed_default(self) -> None: + exc = ApiError("some_code", "boom", status=500, body="raw-body") + mapped = translate_error(exc) + assert isinstance(mapped, SessionApiFailed) + assert mapped.status == 500 + assert mapped.error_code == "some_code" + assert mapped.body == "raw-body" + + def test_apierror_with_no_body_maps_cleanly(self) -> None: + exc = ApiError("nope", "no body", status=404) + mapped = translate_error(exc) + assert isinstance(mapped, SessionApiFailed) + assert mapped.status == 404 + assert mapped.error_code == "nope" + assert mapped.body is None + + def test_discriminated_subclass_passes_through_unchanged(self) -> None: + # Discriminated WorldtreeError subclasses are already the right semantic + # type — the adapter passes them through by identity (no re-wrap). + exc = AgentNotAvailable("agent_not_available", "gone", status=409) + assert translate_error(exc) is exc + + def test_non_worldtree_error_passes_through_unchanged(self) -> None: + exc = ValueError("unrelated") + assert translate_error(exc) is exc diff --git a/uv.lock b/uv.lock index f09c39f..602cf8d 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.21.2" +version = "0.21.3" source = { editable = "." } dependencies = [ { name = "httpx" },