feat(#20): worldtree-sdk adapter foundation — ratatoskr.wt (slice-1)
Slice-1 of the SDK cutover (docs/contracts/worldtree_sdk_cutover.contract.md): the adapter chokepoint onto worldtree-sdk 1.0.0, unit-tested but not yet wired to any surface (that is slice-2). - build_client(base_url, *, api_key, admin_key=None, transport) constructs the single WorldtreeClient over a ratatoskr-owned injected httpx.AsyncClient. INV-CUT-1: the SDK is given the transport (_owns_client=False) and never closes it — proven by a test asserting aclose() leaves ratatoskr's transport open. - translate_error implements the § Error map DEFAULT: SDK ApiError → the adapter's SessionApiFailed (carrying the SDK's parsed status/error_code/body); every discriminated WorldtreeError subclass passes through by identity. Route-specific rows land at their call-sites in later slices (the route is the discriminator). - SessionApiFailed gains error_code vs the retiring sessions.py copy (extends it per the contract error-map row); the two coexist transiently and reconcile in slice-2 (DEC-4 incremental cutover — nothing wires the adapter this slice, so they never meet at runtime). Deletes no hand-rolled path, so DEC-4's live-smoke bar does not apply yet. Suite 541 green (534 + 7 new); mypy + ruff clean. Patch (internal foundation; the cutover's minor bump is DEC-6 at slice-7 ship).
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.2"
|
version = "0.21.3"
|
||||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user