From 3f3a9f7b0fb6c807ff98a7a176fe0ef8c8c8e326 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Fri, 17 Jul 2026 13:46:20 -0700 Subject: [PATCH] refactor(cli)!: remove deprecated textual TUI; web console is the interactive surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The textual TUI (tui.py) is superseded by the web console (ratatoskr-web) and is removed per the no-backwards-compat rule. The `ratatoskr` command stays as a headless client: --send / --whoami / --characters / --set-persona-pad / --seed-first-message still work; invoking it with no --send now returns a usage error (rc 10) instead of launching the TUI. Removed: src/ratatoskr/tui.py, tests/test_tui.py, the textual + textual-dev deps, and cli.py's run_tui launch path. cli.py's shared exports (USER_AGENT, ParsedArgs, formatters) stay — web/entrypoint.py and tier3.py depend on them. BREAKING CHANGE: the interactive `ratatoskr --agent X` TUI is gone; use the web console (ratatoskr-web) for interactive debugging, or --send for scripted. Verified: full suite 520 passed; ratatoskr --help exit 0; no-send -> rc 10; web/provider/tier3 import clean; textual absent from the lockfile. --- README.md | 17 +- pyproject.toml | 12 +- src/ratatoskr/cli.py | 27 +- src/ratatoskr/tui.py | 1947 ------------------------ tests/test_cli.py | 28 +- tests/test_tui.py | 3443 ------------------------------------------ uv.lock | 740 +-------- 7 files changed, 41 insertions(+), 6173 deletions(-) delete mode 100644 src/ratatoskr/tui.py delete mode 100644 tests/test_tui.py diff --git a/README.md b/README.md index cb1bda7..4bdb6c0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Ratatoskr -A Worldtree Conversation API debug TUI. Runs up and down Worldtree's +A Worldtree Conversation API debug console. Runs up and down Worldtree's API surface — sessions, turns, persona, tools, admin events, Bifrost state — carrying messages between layers. Like the squirrel. The product **is** the observability surface; chat is the input mechanism. Devs run Ratatoskr against a local Worldtree to watch a turn flow through -every layer of the system, side-by-side, in one terminal. +every layer of the system, side-by-side. The interactive surface is the +web console (`ratatoskr-web`); a headless `--send` CLI drives scripted smokes. ## Status @@ -42,9 +43,11 @@ cat docs/SPEC-PIN.md # documented Worldtree SHA + bump procedure # 3. Tests (none yet; scaffold only) uv run pytest -# 4. Run against a local Worldtree (once implementation lands) -# Worldtree must be running: python -m core.conversation_api -ratatoskr --agent mimir +# 4. Run against a local Worldtree (Worldtree must be running) +# Interactive web console: +ratatoskr-web --host 0.0.0.0 --port 8765 +# Headless CLI (scripted smoke): +ratatoskr --send "hello" --new --agent mimir --api-key "$WORLDTREE_API_KEY" ``` ## What this repo is NOT @@ -58,10 +61,10 @@ The full negative-clause list lives in `docs/design-brief.md` §6. ## Boundary rule -Ratatoskr depends on three things only: +Ratatoskr depends on a small, fixed surface: - `httpx` + `httpx-sse` (network layer) -- `textual` (TUI framework) +- `starlette` + `uvicorn` (the web console; the `web` extra) - Worldtree's **published Conversation API spec** at the pinned SHA Hard rule: **no imports from a Worldtree checkout.** No `core.*` imports, diff --git a/pyproject.toml b/pyproject.toml index c4d4a33..9b5272f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,20 +4,19 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.20.17" -description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" +version = "0.21.0" +description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "Vuong Hoang" }] -keywords = ["worldtree", "tui", "debug", "sse", "textual"] +keywords = ["worldtree", "debug", "sse", "web", "observability"] -# Network + SSE consumer + TUI framework. -# See docs/design-brief.md §1 (Textual), §3 (httpx-sse). +# Network + SSE consumer. +# See docs/design-brief.md §3 (httpx-sse). dependencies = [ "httpx>=0.27", "httpx-sse>=0.4", - "textual>=0.85", ] [project.optional-dependencies] @@ -40,7 +39,6 @@ dev = [ "respx>=0.21", # httpx mocking for SSE-recorded snapshot tests "ruff>=0.6", "mypy>=1.11", - "textual-dev>=1.5", # textual console + live reload during dev "pyyaml>=6", # used by docs/contracts/contract_parser.py and scripts/contract_drift_check.py "ratatoskr[web]", # web extras included in dev so test_web_* can import starlette ] diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 80f3e07..fd67550 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -208,21 +208,18 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: if ns.session and ns.new: raise UsageError("--session and --new are mutually exclusive") if not ns.session and not ns.new: - # Bare TUI mode → startup session picker (design-brief §4). --send is - # non-interactive (no picker can open), so it still requires one flag; - # --agent belongs with --new (bare mode resumes, it doesn't create). + # No --send with neither --session nor --new: no headless action, so + # main() returns a usage error (the interactive TUI was removed). + # --send still requires one of the two flags; --agent belongs with --new. if ns.send is not None: - raise UsageError("--send requires --session or --new (no interactive picker)") + raise UsageError("--send requires --session or --new") if ns.agent: - raise UsageError( - "--agent belongs with --new; bare TUI mode opens the session picker" - ) + raise UsageError("--agent belongs with --new") if ns.session and ns.agent: raise UsageError("--agent is required with --new and forbidden with --session") if ns.new and not ns.agent and ns.send is not None: # Issue #8: --agent stays required for --send --new (non-interactive, - # cannot prompt). Bare --new (TUI mode) accepts None — picker drives - # the choice via list_agents in _resolve_then_run. + # cannot prompt). raise UsageError("--agent is required when --new is passed in --send mode") api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or "" @@ -851,8 +848,12 @@ def main(argv: list[str] | None = None) -> int: if args.seed_first_message is not None: return asyncio.run(_seed_first_message_probe(args)) if args.send_content is None: - # TUI mode — lazy import preserves INV-001 (no textual in cli at module scope). - from ratatoskr.tui import run_tui - - return run_tui(args) + # The interactive textual TUI was removed (v0.21.0); the terminal client is + # headless-only. Interactive use lives in the web console (ratatoskr-web). + sys.stderr.write( + "[usage_error] the interactive TUI has been removed; use --send " + "(with --session/--new) or a probe flag (--whoami/--characters/" + "--set-persona-pad/--seed-first-message), or the web console.\n" + ) + return 10 return asyncio.run(_amain(args)) diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py deleted file mode 100644 index 113f48f..0000000 --- a/src/ratatoskr/tui.py +++ /dev/null @@ -1,1947 +0,0 @@ -"""Ratatoskr Textual TUI shell — interactive primary presenter. - -Implements docs/contracts/issues/4.contract.md, as amended in-place by -docs/contracts/issues/6.contract.md (session resolution lifted out of -on_mount into a pre-App.run() async helper; AsyncClient ownership moves -with it). -""" - -from __future__ import annotations - -import asyncio -import sys -import time as _time -from dataclasses import dataclass -from datetime import datetime as _datetime -from typing import ClassVar, Literal - -import httpx -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Horizontal, Vertical, VerticalScroll -from textual.theme import Theme -from textual.widgets import ( - Footer, - Header, - Input, - ListItem, - ListView, - RichLog, - Static, - TabbedContent, - TabPane, -) - -from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage -from ratatoskr.first_message import seed_preset_first_message -from ratatoskr.sessions import ( - AgentInfo, - AgentNotAvailable, - AgentNotFound, - AuthScopeDenied, - BifrostConsumerKeyMissing, - BifrostHandshakeFailed, - PersonaNotConfigured, - SessionApiFailed, - SessionInfo, - create_session, - get_persona_state, - get_session_bifrost, - get_session_tools, - list_agents, - list_sessions, -) -from ratatoskr.sse_client import ( - AdminEvent, - AffectUpdate, - AwaitingLlmFirstToken, - CancelAlreadyCompleted, - CancelFailed, - Cancelled, - CancelTurnNotFound, - Done, - Error, - Event, - MalformedSseData, - MalformedSseId, - SseConnectFailed, - SseConnectionDropped, - Text, - TextBoundary, - Thinking, - ToolResult, - ToolStart, - TurnIdFlip, - WorkerPhase, - cancel_turn, - stream_admin_events, - stream_turn_resilient, -) - -# ---- Australis theme (https://github.com/lkraven/australis) ------------------ -# -# The Australis Dark color theme, inspired by the Southern Lights. 16 cool-tone -# terminal colors with medium contrast. Preference order: blue > cyan > green -# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for -# terminal-event labels (error/cancelled). -# -# **v0.6.3 single deviation from spec**: `$background` is `#000000` (pure -# black), NOT Australis Ice black `#222531`. The Ice black is RGB(34,37,49) -# — blue dominant — and at App-wide scale the cumulative cast reads as -# "the whole app is blue" to operators (even though no single surface is -# "blue" in the strict-color sense). Pure black for the App background -# kills that perception. EVERY OTHER Australis value — Aurora accents, -# Sea darks for chrome (surface/panel), Ice white foreground, Dawn -# accents — stays verbatim per spec. -# -# Mapping to Textual's Theme semantic tokens: -# primary = Aurora blue (#6388D8) — focus rings, active selection. -# secondary = Aurora cyan (#00b1a8) — secondary highlights. -# accent = Aurora bright cyan (#42dcd1) — bright accents. -# success = Aurora green (#16B866) — [done] label. -# warning = Dawn yellow (#e1c631) — [cancelled] label. -# error = Dawn red (#ff491a) — [error] label. -# foreground = Ice white (#a9bcc3) — default text. -# background = pure black (#000000) — App background (v0.6.3 deviation). -# surface = Sea bright black (#373b46) — raised chrome. -# panel = Sea dark 30 (#414751) — borders, separators. - -AUSTRALIS_THEME = Theme( - name="australis", - primary="#6388D8", - secondary="#00b1a8", - accent="#42dcd1", - success="#16B866", - warning="#e1c631", - error="#ff491a", - foreground="#a9bcc3", - background="#000000", - surface="#373b46", - panel="#414751", - dark=True, - variables={ - # Sea contrast palette — usable via $au-dark-50 etc. in TCSS. - "au-dark-30": "#414751", - "au-dark-40": "#565f69", - "au-dark-50": "#6e7882", - "au-dark-60": "#86929d", - "au-bright-70": "#9daeb6", - "au-bright-80": "#b3cbcf", - "au-bright-white": "#cce7ec", - "au-bright-blue": "#a4c4ff", - "au-bright-cyan": "#42dcd1", - "au-bright-green": "#51e08a", - }, -) - - -# Direct hex constants for Rich Text styling (Done/Error/Cancelled labels + -# transcript user-prompt echo). Themes set TCSS variables; Rich's RichText -# style strings live outside the theme system, so we resolve to hex here. -_AU_SUCCESS = "#16B866" -_AU_ERROR = "#ff491a" -_AU_WARNING = "#e1c631" -_AU_USER_ECHO = "#42dcd1" # Aurora bright cyan — operator's voice -_AU_DEMOTED = "#86929d" # Sea dark 60 — demoted telemetry -_AU_DEMOTED_FAINT = "#6e7882" # Sea dark 50 — empty-state placeholders - - -# ---- Issue #12 presenter contract semantics amendment ------------------------- -# -# TuiPresenterState replaces the stateless _render_event_to_log with a stateful -# per-turn presenter that coalesces thinking runs into ONE closed RichLog entry -# per run + per-delta live updates on the dedicated thinking-current Static -# widget. One instance per `_stream_turn_worker` invocation. - - -def _plain_label(event: Event) -> str: - """Pre-amendment labeled-line shape for INV-009 render-exception fallback. - - Used by `TuiPresenterState.render` ONLY in the except branch, so a failed - state-based render still produces a readable transcript entry per the - pre-amendment behavior. Bracketed labels match the historical - `_render_event_to_log` output verbatim. - """ - if isinstance(event, Text): - return event.content - if isinstance(event, Done): - return ( - f"[done] turn_id={event.sse_id.turn_id} model={event.model} " - f"duration_ms={event.duration_ms} usage={event.usage!r}" - ) - if isinstance(event, Error): - return ( - f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} " - f"message={event.message!r}" - ) - if isinstance(event, Cancelled): - return ( - f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} " - f"partial_message_id={event.partial_message_id}" - ) - if isinstance(event, WorkerPhase): - return f"[worker_phase] phase={event.phase} turn_id={event.turn_id}" - if isinstance(event, Thinking): - return f"[thinking] {event.content[:200]!r}" - if isinstance(event, TextBoundary): - return f"[text_boundary] kind={event.kind} char_offset={event.char_offset}" - if isinstance(event, ToolStart): - return f"[tool_start] name={event.name} args={event.arguments!r}" - if isinstance(event, ToolResult): - return ( - f"[tool_result] name={event.name} duration_ms={event.duration_ms} " - f"result={event.result!r:.200}" - ) - return f"[unknown_event] {type(event).__name__}" - - -def _ts() -> str: - """HH:MM:SS.fff wall-clock timestamp for debug-pane log lines.""" - now = _datetime.now() - return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}" - - -def _format_admin_event(ev: AdminEvent) -> str: - """One-line render of an /admin/events envelope for the AdminEvents pane. - - Drops `session_id` from the detail (the pane is already session-scoped) and - shows HH:MM:SS from the ISO timestamp + the remaining small metadata fields. - """ - ts = (ev.timestamp or "")[11:19] - extras = " ".join(f"{k}={v}" for k, v in ev.data.items() if k != "session_id") - return f"[{ts}] {ev.type} {extras}".rstrip() - - -def _format_bifrost_state(state: dict) -> list[str]: - """Render GET /admin/sessions/{id}/bifrost (#176) into BifrostState-pane lines.""" - tools = [t.get("name", "?") for t in state.get("tools", [])] - caps = state.get("capabilities_granted", []) - return [ - f"bifrost binding: connected={state.get('connected')} " - f"consumer={state.get('consumer_id', '?')}", - f" endpoint: {state.get('endpoint_url', '?')}", - f" caps_granted: {', '.join(caps) or '(none)'}", - f" tools ({len(tools)}): {', '.join(tools) or '(none)'}", - ] - - -def _format_tool_inventory(tools: dict) -> list[str]: - """Render GET /sessions/{id}/tools (#183) into Tools-pane inventory lines. - - The merged tool list the LLM saw at turn-fire — distinct from the live - tool_start/tool_result events that stream into the same pane during a turn. - """ - builtin = [t.get("name", "?") for t in tools.get("builtin_tools", [])] - bifrost = [t.get("name", "?") for t in tools.get("bifrost_tools", [])] - return [ - f"session tool inventory: agent={tools.get('agent_id', '?')} " - f"builtin={len(builtin)} bifrost={len(bifrost)}", - f" builtin: {', '.join(builtin) or '(none)'}", - f" bifrost: {', '.join(bifrost) or '(none)'}", - ] - - -def _format_persona_header(snapshot: dict) -> str: - """One-line persona summary for the sticky header widget. - - Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions active`. - Built for at-a-glance scanning above the chat area — concise enough to - fit one terminal row. Full detail lives in the Persona TabPane. - """ - pad = snapshot.get("pad") or {} - emotions = snapshot.get("emotions_active") or [] - dom = snapshot.get("dominant_emotion") or "?" - pieces = [ - f"{snapshot.get('agent_id', '?')}", - f"{dom}", - f"pad({pad.get('pleasure', '?')}, {pad.get('arousal', '?')}, " - f"{pad.get('dominance', '?')})", - ] - if emotions: - pieces.append(f"{len(emotions)} emotion{'s' if len(emotions) != 1 else ''} active") - return " · ".join(pieces) - - -def _format_persona_detail(snapshot: dict) -> str: - """Multi-line persona detail for the Persona TabPane. - - Renders the full v0.28.0 snapshot shape: dominant_emotion, PAD with - baseline comparison, mood_drift deltas, active emotions list with - intensity + decay, last_updated_at footer. - """ - pad = snapshot.get("pad") or {} - baseline = snapshot.get("baseline_pad") or {} - drift = snapshot.get("mood_drift") or {} - emotions = snapshot.get("emotions_active") or [] - lines: list[str] = [] - agent = snapshot.get("agent_id", "?") - lines.append(f"Persona snapshot · {agent}") - lines.append("") - lines.append(f"Dominant emotion: {snapshot.get('dominant_emotion', '?')}") - lines.append("") - lines.append("PAD") - for axis in ("pleasure", "arousal", "dominance"): - v = pad.get(axis, "?") - b = baseline.get(axis, "?") - delta = "" - if isinstance(v, (int, float)) and isinstance(b, (int, float)): - delta = f" (Δ {v - b:+.2f})" - lines.append(f" {axis:<10} {v} baseline {b}{delta}") - lines.append("") - if drift: - lines.append("Mood drift") - for key in ("valence_delta", "arousal_delta"): - if key in drift: - v = drift[key] - lines.append(f" {key:<16} {v:+}" if isinstance(v, (int, float)) - else f" {key:<16} {v}") - lines.append("") - lines.append(f"Active emotions ({len(emotions)})") - for e in emotions: - et = e.get("type", "?") - ei = e.get("intensity", "?") - decay = e.get("decay_remaining_s") - decay_str = ( - f" decay {decay / 60:.1f}m" if isinstance(decay, (int, float)) else "" - ) - lines.append(f" {et:<20} intensity {ei}{decay_str}") - if not emotions: - lines.append(" (none)") - last = snapshot.get("last_updated_at") - if last: - lines.append("") - lines.append(f"Last updated: {last}") - return "\n".join(lines) - - -def _audit_line(event: Event) -> str: - """One-line wire-level audit summary for the debug pane. - - v0.10.0: every SSE event arrival lands as one of these in the debug - pane (Text and Thinking deltas are aggregated into the turn summary - instead — token-rate per-delta lines would drown the pane). Shape: - `[HH:MM:SS.fff] event_type sse_id=T:S key=val …`. - """ - sid = getattr(event, "sse_id", None) - sid_str = f"{sid.turn_id}:{sid.seq}" if sid is not None else "-" - kind = type(event).__name__.lower() - if isinstance(event, WorkerPhase): - detail = f"phase={event.phase} turn_id={event.turn_id}" - elif isinstance(event, ToolStart): - detail = f"name={event.name} args={event.arguments!r:.80}" - elif isinstance(event, ToolResult): - detail = f"name={event.name} duration_ms={event.duration_ms}" - elif isinstance(event, TextBoundary): - detail = f"kind={event.kind} char_offset={event.char_offset}" - elif isinstance(event, Done): - detail = ( - f"turn_id={event.sse_id.turn_id} model={event.model} " - f"duration_ms={event.duration_ms}" - ) - elif isinstance(event, Error): - detail = ( - f"turn_id={event.sse_id.turn_id} code={event.error_code} " - f"message={event.message!r:.80}" - ) - elif isinstance(event, Cancelled): - detail = f"turn_id={event.turn_id} reason={event.reason!r}" - elif isinstance(event, AwaitingLlmFirstToken): - # Worldtree #201 / v0.29.0. Compact: turn_id + elapsed in seconds - # (the heartbeat itself fires every 5s by default; seconds-rounding - # is the natural unit for operator scanning). - secs = event.elapsed_ms_since_building_prompt / 1000.0 - detail = f"turn_id={event.turn_id} elapsed={secs:.1f}s" - elif isinstance(event, AffectUpdate): - # Worldtree #204 / v0.28.0. status="current" carries the full - # snapshot; surface dominant_emotion + PAD inline so the operator - # sees persona drift at a glance. status="scheduled" is - # lightweight — no PAD, just the appraisal-kickoff marker. - if event.snapshot is not None: - pad = event.snapshot.get("pad") or {} - detail = ( - f"status={event.status} turn_id={event.turn_id} " - f"dominant_emotion={event.snapshot.get('dominant_emotion')!r} " - f"pad=({pad.get('pleasure')},{pad.get('arousal')},{pad.get('dominance')})" - ) - else: - detail = f"status={event.status} turn_id={event.turn_id}" - else: # Text / Thinking handled by counter path; fallback for safety - detail = "" - return f"[{_ts()}] {kind} sse_id={sid_str} {detail}".rstrip() - - -@dataclass(slots=True) -class TuiPresenterState: - """Per-turn presenter state for TUI mode (issue #12). - - See `docs/contracts/issues/12.contract.md` for the full spec. - """ - - thinking_open: bool = False - # Thinking-run counter for turn-scoped start/end markers. - thinking_run_index: int = 0 - # v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas - # at token granularity; flushing each delta as its own RichLog line - # produces per-token-per-newline visual spam. Buffer here and flush - # only on `\n` boundaries (one written line per natural paragraph) or - # when the run closes (any leftover tail). - thinking_chunk_buffer: str = "" - # v0.9.0: Text accumulator for live Markdown rendering. Worldtree emits - # Text deltas at token granularity; each delta appends to this buffer - # and the current_response_widget re-renders Markdown(text_chunk_buffer) - # in place. On terminal event the widget is finalized + reference clears. - text_chunk_buffer: str = "" - # v0.9.0: reference to the Static widget holding the current turn's - # response Markdown Renderable. None between turns. - current_response_widget: object = None - # v0.10.0: per-turn counters for the debug-pane turn-summary line. Text - # and Thinking events arrive at token rate; emitting per-delta debug - # lines would drown the pane. Instead we count them and surface - # aggregated totals when the turn closes. - text_delta_count: int = 0 - text_byte_count: int = 0 - thinking_delta_count: int = 0 - thinking_byte_count: int = 0 - turn_start_ts: float = 0.0 - # v0.14.0: Worldtree #201 heartbeat surface. First - # `awaiting_llm_first_token` mounts a Static; subsequent heartbeats - # update it in place; any non-heartbeat event clears it (the gap - # closed). awaiting_widget is the Static reference (None when - # closed); heartbeat_count tracks emissions for the turn-summary. - awaiting_widget: object = None - heartbeat_count: int = 0 - - def render( - self, - event: Event, - *, - transcript: VerticalScroll, - tools_log: RichLog, - debug_log: RichLog, - thinking_log: RichLog, - raw: bool, - on_persona_snapshot: object = None, - ) -> None: - """Render one Worldtree SSE event with the TUI hierarchy + coalescing. - - v0.9.0 routing: - - `transcript` (VerticalScroll) = chat content: each turn mounts - child widgets (turn-header / prompt-echo / response Markdown / - done-label). Live Markdown rendering during Text streaming. - - `tools_log` (RichLog) = ToolStart + ToolResult. - - `debug_log` (RichLog) = WorkerPhase + TextBoundary. - - `thinking_log` (RichLog) = streaming Thinking deltas inline - (coalesced on `\n`); Rule(start)/Rule(end) wrap each run. - - v0.13.0: optional `on_persona_snapshot` callback receives the - snapshot dict whenever AffectUpdate(status="current") arrives. - Lets the App surface the snapshot to the persona-header + Persona - pane without the presenter needing direct widget access. Default - None — presenter falls back to audit-only routing. - - Exceptions caught at the presenter boundary (INV-009 fallback). - """ - assert isinstance( - event, - ( - WorkerPhase, Thinking, Text, TextBoundary, - ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate, - AwaitingLlmFirstToken, - ), - ) - from rich.text import Text as RichText - - def _dim(s: str) -> RichText: - """Wrap a demoted-telemetry line in Australis Sea dark-60 grey.""" - return RichText(s, style=_AU_DEMOTED) - - try: - # v0.10.0: per-event audit log line to debug pane. Text and - # Thinking arrive at token rate, so we count them rather than - # emit a line per delta — totals are reported in the turn- - # summary on Done/Error/Cancelled. Everything else gets one - # debug-pane line per arrival with timestamp + sse_id + a short - # event-specific summary, giving the operator a wire-level - # timeline of what the server sent. - if isinstance(event, Text): - if self.text_delta_count == 0: - if self.turn_start_ts == 0.0: - self.turn_start_ts = _time.monotonic() - self.text_delta_count += 1 - self.text_byte_count += len(event.content) - elif isinstance(event, Thinking): - if self.thinking_delta_count == 0: - if self.turn_start_ts == 0.0: - self.turn_start_ts = _time.monotonic() - self.thinking_delta_count += 1 - self.thinking_byte_count += len(event.content) - else: - if self.turn_start_ts == 0.0: - self.turn_start_ts = _time.monotonic() - debug_log.write(_dim(_audit_line(event))) - # v0.11.0 → v0.13.0: AffectUpdate gets the audit line (above) - # plus a callback to the App so the persona-header + Persona - # pane refresh from the snapshot. status="scheduled" carries - # no snapshot — the callback is skipped and the next turn's - # status="current" lands the actual update. - if isinstance(event, AffectUpdate): - if event.snapshot is not None and on_persona_snapshot is not None: - try: - on_persona_snapshot(event.snapshot) - except Exception: - # Persona surface failure must not break the SSE - # stream — the audit line above already records - # the event regardless. - pass - return - # v0.14.0: AwaitingLlmFirstToken (Worldtree #201) gets the audit - # line (above) plus a live transcript indicator. First heartbeat - # mounts a Static; subsequent heartbeats update it in place. - # Any non-heartbeat event below closes the gap and the indicator - # is removed (the first text/thinking/done arrived). - if isinstance(event, AwaitingLlmFirstToken): - from rich.text import Text as RichText - self.heartbeat_count += 1 - secs = event.elapsed_ms_since_building_prompt / 1000.0 - label = RichText( - f"awaiting first token · {secs:.1f}s", - style=_AU_DEMOTED, - ) - try: - if self.awaiting_widget is None: - self.awaiting_widget = Static(label, classes="awaiting-label") - transcript.mount(self.awaiting_widget) - else: - self.awaiting_widget.update(label) - transcript.scroll_end(animate=False) - except Exception: - pass - return - # Any non-heartbeat event past this point means the gap closed — - # remove the awaiting indicator if it's still mounted. - if self.awaiting_widget is not None: - try: - self.awaiting_widget.remove() - except Exception: - pass - self.awaiting_widget = None - # v0.7.1: Thinking deltas coalesce by newline before flushing. - # Worldtree emits Thinking events at token granularity; per-delta - # RichLog writes produce one visual line per token (per-token-per- - # newline spam). Buffer the deltas and flush only on `\n` (one - # written line per natural paragraph) or run close. - if isinstance(event, Thinking): - from rich.rule import Rule - - if not self.thinking_open: - self.thinking_run_index += 1 - turn_id = event.sse_id.turn_id - thinking_log.write(Rule( - title=f"turn {turn_id} · thinking #{self.thinking_run_index} start", - style=_AU_DEMOTED, - )) - self.thinking_open = True - self.thinking_chunk_buffer += event.content - # Flush every complete line in the buffer. Whatever's after - # the final `\n` stays buffered for the next delta or close. - while "\n" in self.thinking_chunk_buffer: - line, _, rest = self.thinking_chunk_buffer.partition("\n") - if line: # skip empty lines (blank paragraph separators) - thinking_log.write(line) - self.thinking_chunk_buffer = rest - return - # Non-thinking event: close any open thinking run with Rule(end). - if self.thinking_open: - from rich.rule import Rule - - # Flush the tail (content with no trailing `\n`) before the - # end rule so nothing gets lost on close. - if self.thinking_chunk_buffer: - thinking_log.write(self.thinking_chunk_buffer) - self.thinking_chunk_buffer = "" - turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else ( - event.turn_id if hasattr(event, "turn_id") else "?" - ) - thinking_log.write(Rule( - title=f"turn {turn_id} · thinking #{self.thinking_run_index} end", - style=_AU_DEMOTED, - )) - self.thinking_open = False - # Now render the non-thinking event itself. - if isinstance(event, Text): - # v0.8.1: stream Text deltas into transcript directly, - # coalesced on `\n`. Same pattern as Thinking (v0.7.1). - # The pre-v0.8.1 #current-text Static is gone — its dock- - # bottom growth was overlapping the transcript visually. - # - # v0.9.0: Text deltas accumulate in text_chunk_buffer and - # the current_response_widget renders Markdown(buffer) in - # place. First Text delta of the turn mounts a fresh Static - # holding the Markdown Renderable; subsequent deltas update - # the same widget. Live markdown rendering — no post-Done - # re-render needed. - from rich.markdown import Markdown - - self.text_chunk_buffer += event.content - # --raw bypasses Markdown rendering — useful for debugging - # the raw text stream surface, and matches the pre-v0.9.0 - # --raw semantics (which dropped the post-Done Markdown re- - # render). In raw mode the response widget holds plain str. - rendered = ( - self.text_chunk_buffer if raw else Markdown(self.text_chunk_buffer) - ) - if self.current_response_widget is None: - self.current_response_widget = Static( - rendered, classes="response-md" - ) - transcript.mount(self.current_response_widget) - else: - self.current_response_widget.update(rendered) - transcript.scroll_end(animate=False) - return - if isinstance(event, (Done, Error, Cancelled)): - # v0.10.0: emit turn-summary to debug pane before clearing - # counters. Aggregates the per-event totals (Text + Thinking - # deltas don't get per-event audit lines because they arrive - # at token rate; the summary surfaces what was elided). - elapsed_ms = ( - int((_time.monotonic() - self.turn_start_ts) * 1000) - if self.turn_start_ts - else 0 - ) - turn_id = ( - event.sse_id.turn_id - if hasattr(event, "sse_id") - else getattr(event, "turn_id", "?") - ) - debug_log.write(_dim( - f"[{_ts()}] turn_summary turn_id={turn_id} " - f"text_deltas={self.text_delta_count} " - f"text_bytes={self.text_byte_count} " - f"thinking_deltas={self.thinking_delta_count} " - f"thinking_bytes={self.thinking_byte_count} " - f"heartbeats={self.heartbeat_count} " - f"elapsed_ms={elapsed_ms}" - )) - # Terminal event: finalize the response widget (clear ref so - # the next turn mounts a fresh one). The accumulated text is - # already rendered as Markdown in the widget — no post-Done - # re-render, no double-print. - self.text_chunk_buffer = "" - self.current_response_widget = None - # Terminal labels mount as styled Statics. Tinted per outcome - # (Aurora green / Dawn red / Dawn yellow) for at-a-glance - # scanning. - if isinstance(event, Done): - transcript.mount(Static( - RichText( - f"[done] turn_id={event.sse_id.turn_id} " - f"model={event.model} " - f"duration={_format_duration_ms(event.duration_ms)} " - f"usage {_format_usage(event.usage, arrow='→')}", - style=_AU_SUCCESS, - ), - classes="done-label", - )) - elif isinstance(event, Error): - transcript.mount(Static( - RichText( - f"[error] turn_id={event.sse_id.turn_id} " - f"code={event.error_code} message={event.message!r}", - style=_AU_ERROR, - ), - classes="error-label", - )) - else: # Cancelled - transcript.mount(Static( - RichText( - f"[cancelled] turn_id={event.turn_id} " - f"reason={event.reason!r} " - f"partial_message_id={event.partial_message_id}", - style=_AU_WARNING, - ), - classes="cancelled-label", - )) - transcript.scroll_end(animate=False) - return - if isinstance(event, WorkerPhase): - # v0.5.0: telemetry → Debug pane, not transcript. - debug_log.write(_dim( - f"· worker_phase: phase={event.phase} turn_id={event.turn_id}" - )) - return - if isinstance(event, ToolStart): - # Issue #13 INV-014: tool events route to the Tools pane. - tools_log.write(_dim( - f"· tool_start: name={event.name} args={event.arguments!r}" - )) - return - if isinstance(event, ToolResult): - # Issue #13 INV-014: tool events route to the Tools pane. - tools_log.write(_dim( - f"· tool_result: name={event.name} duration_ms={event.duration_ms} " - f"result={event.result!r:.200}" - )) - return - if isinstance(event, TextBoundary): - # v0.5.0: telemetry → Debug pane, not transcript. - debug_log.write(_dim( - f"· text_boundary: kind={event.kind} char_offset={event.char_offset}" - )) - return - except Exception as exc: - # INV-009 + POST-007 fallback: write pre-amendment plain-label line for - # the original event AND a render_error line with the class name only - # (NO exception message — security clause). Volva F1 fix. - # - # v0.9.0 routing-under-failure: panes (RichLog) still write Strip - # lines; transcript (VerticalScroll) mounts a Static instead. - if isinstance(event, (ToolStart, ToolResult)): - tools_log.write(_plain_label(event)) - tools_log.write(f"[render_error] {type(exc).__name__}") - elif isinstance(event, Thinking): - thinking_log.write(_plain_label(event)) - thinking_log.write(f"[render_error] {type(exc).__name__}") - elif isinstance(event, (WorkerPhase, TextBoundary)): - debug_log.write(_plain_label(event)) - debug_log.write(f"[render_error] {type(exc).__name__}") - else: - # Transcript-bound event (Text / Done / Error / Cancelled). - transcript.mount(Static(_plain_label(event), classes="error-label")) - transcript.mount( - Static(f"[render_error] {type(exc).__name__}", classes="error-label") - ) - transcript.scroll_end(animate=False) - - -class AgentPickerApp(App[str | None]): - """Startup agent picker (issue #8). Opens before RatatoskrApp when --new - is passed without --agent. `run_async()` returns the chosen agent_id (str) - or None on Esc/Ctrl-D dismissal. - - Architecturally separate from RatatoskrApp (deliberate per issue #8 - INV-007): keeps list_agents failures landing on real stderr before any - alt-screen opens, preserving issue #6's invariant. - """ - - DEFAULT_CSS = """ - /* v0.6.1: kill Textual's $primary-blue tints everywhere — Header sub- - widgets (HeaderIcon etc.) have their own $primary tinting that the - parent `Header { background: $surface }` rule alone doesn't cover. - Sub-selectors force the cool palette down to every level. */ - Header, HeaderIcon, HeaderTitle, HeaderClock { - background: $surface; - color: $au-bright-blue; - } - Footer { - background: $surface; - } - /* v0.6.1: scrollbar uses Textual's $primary-tint by default. Force - Australis Sea darks so the scrollbar gutter doesn't read as a blue - strip. Applied to ListView (the scrollable widget here). */ - ListView { - scrollbar-background: $background; - scrollbar-background-hover: $background; - scrollbar-background-active: $background; - scrollbar-color: $au-dark-50; - scrollbar-color-hover: $au-dark-60; - scrollbar-color-active: $au-bright-cyan; - } - #picker-prompt { - dock: top; - height: 1; - padding: 0 1; - color: $au-bright-cyan; - background: $surface; - } - #agent-list { - height: 1fr; - background: $background; - } - /* Multi-line agent items. Each ListItem is auto-height so the full - description wraps below the agent_id/name line — no truncation. - v0.6.4: lock bg to $background so Textual's auto background-tint on - focus doesn't bleed through unwanted color into the non-highlighted - items. */ - #agent-list > ListItem { - height: auto; - padding: 1 1; - background: $background; - } - /* v0.6.4: highlighted item gets Aurora blue background (Textual's - default $block-cursor-background = $primary). Override only the - text-color descendants so id-line/desc stay readable on blue. The - background itself comes from Textual's default ListItem.-highlight - rule — we removed our previous overriding selectors. - - Textual's class is `-highlight` (single dash). Use plain descendant - combinator to bypass internal DOM wrappers. */ - #agent-list:focus ListItem.-highlight { - background: $primary; - } - #agent-list:focus ListItem.-highlight .agent-id-line { - color: $au-bright-white; - text-style: bold; - } - #agent-list:focus ListItem.-highlight .agent-desc { - color: $au-bright-80; - } - /* Default (unhighlighted) item text styling. */ - .agent-id-line { - color: $au-bright-blue; - text-style: bold; - } - .agent-desc { - color: $au-bright-70; - } - """ - - BINDINGS: ClassVar[list[Binding]] = [ - Binding("enter", "pick", "Pick", priority=True), - Binding("escape", "dismiss", "Cancel", priority=True), - Binding("ctrl+d", "dismiss", "Cancel", priority=True), - Binding("ctrl+c", "dismiss", "Cancel", priority=True), - ] - - def __init__(self, agents: list[AgentInfo]) -> None: - super().__init__() - # PRE-002: caller (_resolve_then_run) checks for empty list and emits - # [no_agents] before constructing the picker. - assert agents - self.agents = agents - self.register_theme(AUSTRALIS_THEME) - self.theme = "australis" - - def compose(self) -> ComposeResult: - yield Header() - yield Static("Pick an agent for the new session:", id="picker-prompt") - # v0.6.0: each ListItem has two Static children — the id/name line - # in bold blue + the wrapped description in muted dark-60. No - # description truncation; tall items breathe so the operator can - # actually read what each agent does. - yield ListView( - *[ - ListItem( - Static(f"{a.agent_id} · {a.name}", classes="agent-id-line"), - Static(a.description, classes="agent-desc"), - ) - for a in self.agents - ], - id="agent-list", - ) - yield Footer() - - async def on_mount(self) -> None: - self.query_one("#agent-list", ListView).focus() - - def action_pick(self) -> None: - lv = self.query_one("#agent-list", ListView) - idx = lv.index - if idx is None: - return # nothing highlighted; ignore - self.exit(self.agents[idx].agent_id) - - def action_dismiss(self) -> None: - self.exit(None) - - -def _session_desc(s: SessionInfo) -> str: - """One-line session summary for the picker's second row.""" - tail = f"session {s.session_id} · last active {s.last_active}" - if s.message_count is not None: - tail += f" · {s.message_count} msgs" - return tail - - -class SessionPickerApp(App[str | None]): - """Startup session picker (design-brief §4, slice b2). Opens before - RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns - the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. - - Resume-only (design-brief §4 negative clause "no in-app session creation — - --new flag only"): the picker chooses among EXISTING sessions; starting a - fresh one is the --new flag's job. Architecturally separate from - RatatoskrApp (mirrors AgentPickerApp): list_sessions failures + dismissal - land before any alt-screen opens (preserves #6 INV-001). - """ - - DEFAULT_CSS = """ - Header, HeaderIcon, HeaderTitle, HeaderClock { - background: $surface; - color: $au-bright-blue; - } - Footer { - background: $surface; - } - ListView { - scrollbar-background: $background; - scrollbar-background-hover: $background; - scrollbar-background-active: $background; - scrollbar-color: $au-dark-50; - scrollbar-color-hover: $au-dark-60; - scrollbar-color-active: $au-bright-cyan; - } - #picker-prompt { - dock: top; - height: 1; - padding: 0 1; - color: $au-bright-cyan; - background: $surface; - } - #session-list { - height: 1fr; - background: $background; - } - #session-list > ListItem { - height: auto; - padding: 1 1; - background: $background; - } - #session-list:focus ListItem.-highlight { - background: $primary; - } - #session-list:focus ListItem.-highlight .session-id-line { - color: $au-bright-white; - text-style: bold; - } - #session-list:focus ListItem.-highlight .session-desc { - color: $au-bright-80; - } - .session-id-line { - color: $au-bright-blue; - text-style: bold; - } - .session-desc { - color: $au-bright-70; - } - """ - - BINDINGS: ClassVar[list[Binding]] = [ - Binding("enter", "pick", "Resume", priority=True), - Binding("escape", "dismiss", "Cancel", priority=True), - Binding("ctrl+d", "dismiss", "Cancel", priority=True), - Binding("ctrl+c", "dismiss", "Cancel", priority=True), - ] - - def __init__(self, sessions: list[SessionInfo]) -> None: - super().__init__() - # PRE-001: caller (_resolve_then_run) resolves the 0-session and - # 1-session cases BEFORE constructing the picker. - assert sessions - self.sessions = sessions - self.register_theme(AUSTRALIS_THEME) - self.theme = "australis" - - def compose(self) -> ComposeResult: - yield Header() - yield Static( - "Pick a session to resume (relaunch with --new for a fresh one):", - id="picker-prompt", - ) - yield ListView( - *[ - ListItem( - Static( - f"{s.name or s.session_id} · {s.agent_id}", - classes="session-id-line", - ), - Static(_session_desc(s), classes="session-desc"), - ) - for s in self.sessions - ], - id="session-list", - ) - yield Footer() - - async def on_mount(self) -> None: - self.query_one("#session-list", ListView).focus() - - def action_pick(self) -> None: - lv = self.query_one("#session-list", ListView) - idx = lv.index - if idx is None: - return # nothing highlighted; ignore - self.exit(self.sessions[idx].session_id) - - def action_dismiss(self) -> None: - self.exit(None) - - -class RatatoskrApp(App[int]): - """Textual TUI shell — single chat pane.""" - - # Issue #13 + v0.5.0 follow-up: Horizontal two-column layout per - # design-brief §5. Left column (2fr) is the **content-only** chat - # surface — assistant text, user prompt echo, [done]/[error]/[cancelled] - # terminal labels, post-Done markdown render. Right column (1fr) houses - # ALL telemetry: live thinking preview docked above TabbedContent; - # tab strip carries Tools (ToolStart/ToolResult) + Debug (Thinking - # closed runs + WorkerPhase + TextBoundary). - # - # v0.5.0 routing change: thinking-current Static moved from left column - # to right column header so the left column is genuinely content-only; - # closed thinking runs go to debug-log instead of transcript. - # - # v0.5.0 chrome fix: Header/Footer backgrounds explicitly set to $surface - # (Sea bright-black #373b46) overriding Textual's default $primary-blue - # tinting. TabbedContent active-tab tinting also softened. - # - # Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/ - # etc.) carry colors so a future theme swap rebinds centrally. - DEFAULT_CSS = """ - /* v0.6.1: kill Textual's default $primary-blue tinting on chrome — - Header sub-widgets (HeaderIcon, HeaderTitle, HeaderClock) each carry - their own $primary tint that the parent `Header { background }` rule - doesn't override; sub-selectors force the cool palette down. */ - Header, HeaderIcon, HeaderTitle, HeaderClock { - background: $surface; - color: $au-bright-blue; - } - Footer { - background: $surface; - } - /* v0.6.1: scrollbars default to $primary-tint blue. Force Sea darks - on the scrollable widgets (RichLog instances). */ - RichLog { - scrollbar-background: $background; - scrollbar-background-hover: $background; - scrollbar-background-active: $background; - scrollbar-color: $au-dark-50; - scrollbar-color-hover: $au-dark-60; - scrollbar-color-active: $au-bright-cyan; - } - #main-row { - height: 1fr; - } - #left-column { - width: 2fr; - border-right: solid $panel; - } - #right-column { - width: 1fr; - } - /* v0.6.5: thinking-current Static removed; thinking now streams - directly into thinking-log so the whole pane scrolls naturally. */ - /* v0.9.0: transcript is a VerticalScroll container holding dynamically - mounted Statics + Markdown widgets per turn. Live Markdown rendering - replaces the v0.8.x RichLog approach which couldn't render Markdown - in-flight (only on Done as a re-render → double-print bug). */ - #transcript-scroll { - height: 1fr; - background: $background; - padding: 0 1; - } - /* Per-turn mounted widgets carry id-prefix conventions: - - .turn-header "── turn N ──" (dim) - - .prompt-echo "❯ user input" (aurora bright cyan) - - .response-md Markdown(accumulated_text) — updated live - - .done-label "[done] turn_id=…" (aurora green) - - .error-label "[error] …" (dawn red) - - .cancelled-label "[cancelled] …" (dawn yellow) - */ - .turn-header { - height: auto; - padding: 0 1; - color: $au-dark-60; - } - .prompt-echo { - height: auto; - padding: 0 1; - } - .response-md { - height: auto; - padding: 0 1; - } - .done-label, .error-label, .cancelled-label { - height: auto; - padding: 0 1; - } - /* v0.14.0: Worldtree #201 — live "awaiting first token · Ns" indicator - in the transcript during the BuildingPrompt → CallingLLM gap. - Demoted styling so it reads as ambient progress, not content. */ - .awaiting-label { - height: auto; - padding: 0 1; - color: $au-dark-60; - } - /* v0.8.1: #current-text Static removed. Streaming text now coalesces - on `\n` and writes directly to #transcript (same pattern as v0.7.1 - thinking fix). Eliminates the dock-bottom-growth-overlap bug. */ - #tools-log, #debug-log, #thinking-log, #bifrost-log, #admin-events-log { - background: $background; - padding: 0 1; - } - /* Tab strip + active-tab underline — kill blue, use Australis cyan. */ - #side-panes > ContentTabs { - background: $surface; - } - #side-panes ContentTab.-active { - color: $au-bright-cyan; - text-style: bold; - } - #side-panes Underline > .underline--bar { - color: $au-bright-cyan; - } - #prompt { - dock: bottom; - border: tall $panel; - } - /* v0.6.0: focused border uses Australis bright-cyan instead of $primary - (Aurora blue) — kills the lingering blue tint the user flagged. */ - #prompt:focus { - border: tall $au-bright-cyan; - } - /* Placeholder text in the Input — dimmer than typed content. */ - #prompt > .input--placeholder { - color: $au-dark-50; - } - #identity { - dock: bottom; - height: 1; - color: $au-bright-blue; - padding: 0 1; - } - #pane-name { - dock: bottom; - height: 1; - color: $au-bright-cyan; - padding: 0 1; - } - #hint { - dock: bottom; - height: 1; - color: $au-dark-60; - padding: 0 1; - } - /* v0.13.0: sticky persona-header — one-line agent persona summary - above the main row. Empty (height: 0) when the agent has no - persona surface (PersonaNotConfigured) so the chat layout - collapses cleanly. */ - #persona-header { - dock: top; - height: 1; - color: $au-bright-80; - background: $surface; - padding: 0 1; - } - #persona-header.empty { - display: none; - } - """ - - BINDINGS: ClassVar[list[Binding]] = [ - Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True), - Binding("ctrl+d", "quit", "Exit immediately", priority=True), - # §5 keybinding family Ctrl+1..5 jumps between side panes - # without losing Input focus (INV-016). - Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), - Binding("ctrl+2", "focus_debug", "Debug tab", priority=False), - Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False), - Binding("ctrl+4", "focus_persona", "Persona tab", priority=False), - ] - - HINT_IDLE = "Ctrl-C twice to exit" - HINT_STREAMING = "Ctrl-C to cancel" - HINT_CANCELLING = "Press Ctrl-C again to exit" - - def __init__( - self, - args: ParsedArgs, - *, - session_id: str, - agent_id: str | None, - client: httpx.AsyncClient, - ) -> None: - super().__init__() - self.args = args - self.session_id: str = session_id - self.agent_id: str | None = agent_id - self.client: httpx.AsyncClient = client - self.state: Literal["idle", "streaming", "cancelling"] = "idle" - self.active_turn_id: int | None = None - self.stream_worker = None - self.hint: str = self.HINT_IDLE - self.register_theme(AUSTRALIS_THEME) - self.theme = "australis" - - def compose(self) -> ComposeResult: - yield Header() - # v0.13.0: sticky persona-header docks at the top, above main-row. - # One-line summary refreshed on each AffectUpdate(status=current). - # Starts in the .empty CSS class (height collapses to 0) until - # on_mount's get_persona_state hydration succeeds. - yield Static("", id="persona-header", classes="empty") - # v0.6.0 layout: left column is content-only (transcript + streaming - # text Static + prompt). Right column hosts thinking-current live - # preview above TabbedContent cycling Tools / Debug / Thinking. - # - # The current-text Static buffers in-flight assistant tokens so - # streaming doesn't spam the RichLog with one line per delta — - # the operator sees a single growing live line, then on Done the - # Static clears and the final Markdown body lands in the transcript. - # - # markup=False on RichLog so labeled lines render verbatim; the - # post-Done Markdown() / Rule() renders are Rich Renderables and - # work without widget-level markup=True. - with Horizontal(id="main-row"): - with Vertical(id="left-column"): - # v0.9.0: transcript is a VerticalScroll holding per-turn - # mounted widgets (turn header, prompt echo, response Markdown, - # done label). Live Markdown rendering happens via Static - # widgets holding `Markdown` Renderables, updated as Text - # deltas arrive. - yield VerticalScroll(id="transcript-scroll") - yield Input(id="prompt", placeholder="Type a message and press Enter") - with Vertical(id="right-column"): - with TabbedContent(id="side-panes"): - # v0.14.2: min_width=0 disables Textual's 78-cell floor - # on RichLog. The right column is 1fr against the left - # column's 2fr, so at typical terminal widths the right- - # column panes are narrower than 78 cells — and the - # default min_width=78 was forcing content to render at - # 78 wide and horizontally scroll instead of wrapping at - # the actual widget width. With min_width=0, wrap=True - # finally takes effect on long lines. - with TabPane("Tools", id="tools-tab"): - yield RichLog( - id="tools-log", wrap=True, markup=False, - highlight=False, min_width=0, - ) - with TabPane("Debug", id="debug-tab"): - yield RichLog( - id="debug-log", wrap=True, markup=False, - highlight=False, min_width=0, - ) - with TabPane("Thinking", id="thinking-tab"): - # v0.6.5: thinking streams directly into this - # RichLog (no separate bottom Static). Each delta - # writes a line; Rule(start)/Rule(end) mark run - # boundaries. The whole pane scrolls naturally - # as content arrives — no more "200-char tail - # window scrolling at the bottom". - yield RichLog( - id="thinking-log", wrap=True, markup=False, - highlight=False, min_width=0, - ) - with TabPane("Persona", id="persona-tab"): - # v0.13.0: full persona-snapshot detail (PAD, - # mood drift, active emotions). Replaced (not - # appended) on each AffectUpdate(current) — the - # snapshot is absolute state, not incremental. - yield RichLog( - id="persona-log", wrap=True, markup=False, - highlight=False, min_width=0, - ) - with TabPane("Bifrost", id="bifrost-tab"): - # #176: admin-scoped Bifrost dispatch state (endpoint, - # connected, granted caps, tools) via - # GET /admin/sessions/{id}/bifrost. Hydrated on mount - # with the admin key; "not configured" when absent. - yield RichLog( - id="bifrost-log", wrap=True, markup=False, - highlight=False, min_width=0, - ) - with TabPane("AdminEvents", id="admin-events-tab"): - # #11: live GET /admin/events SSE stream, admin-scoped, - # FILTERED to the active session (design-brief §6). A - # long-lived worker appends matching lifecycle events; - # "not configured" when no admin key is set. - yield RichLog( - id="admin-events-log", wrap=True, markup=False, - highlight=False, min_width=0, - ) - # INV-002 + INV-003: visible identity + hint widgets (Footer-area). - # pane-name widget displays current side-pane name. - yield Static("", id="identity") - yield Static("Tools", id="pane-name") - yield Static(self.HINT_IDLE, id="hint") - yield Footer() - - async def on_mount(self) -> None: - """Populate identity widget from pre-resolved state; set idle hint. - - Per issue #6: session resolution + client open happen in `_resolve_then_run` - BEFORE App.run_async() — only identity-widget population lives here. - """ - assert self.client is not None and self.session_id is not None - agent_slot = self.agent_id or "" - identity = f"{agent_slot} · …{self.session_id[-8:]}" - self.sub_title = identity # mirror to Header subtitle for redundancy - self.query_one("#identity", Static).update(identity) - # v0.5.1 polish: empty-state placeholder lines so the operator sees - # the pane is intentionally empty (not broken) before any turn fires. - # Wrapped in Australis dark-50 italic so they read distinctly as - # placeholder text, not real telemetry. Disappear naturally as the - # log fills with real events (the placeholders scroll off the top). - from rich.text import Text as RichText - placeholder_style = f"{_AU_DEMOTED_FAINT} italic" - self.query_one("#tools-log", RichLog).write( - RichText("(no tool events yet — start a turn that uses tools)", - style=placeholder_style) - ) - self.query_one("#debug-log", RichLog).write( - RichText("(waiting for worker_phase + text_boundary telemetry)", - style=placeholder_style) - ) - self.query_one("#thinking-log", RichLog).write( - RichText("(no chain-of-thought captured yet — start a turn)", - style=placeholder_style) - ) - self.state = "idle" - self._set_hint(self.HINT_IDLE) - # v0.10.0: startup audit so the debug pane carries a complete - # session bootstrap line (server URL, agent, end_user_id, raw flag, - # session tail) before the first turn fires. - self._audit( - f"app_mounted server={self.args.server_url} agent_id={self.agent_id!r} " - f"session={self.session_id[-8:]} raw={self.args.raw} " - f"end_user_id={getattr(self.args, 'end_user_id', None)!r}" - ) - # v0.13.0: hydrate persona surface on mount via - # GET /agents/{id}/persona_state. Spawns as a Textual worker so the - # network call doesn't block mount. Agents without a persona - # surface (PersonaNotConfigured) get a placeholder + empty header. - if self.agent_id is not None: - self.run_worker(self._hydrate_persona()) - # #183: hydrate the Tools pane with the session's tool inventory via - # GET /sessions/{id}/tools (owner-scoped — consumer key, no admin scope). - # Unconditional: every session has a tool inventory to introspect. - self.run_worker(self._hydrate_session_tools()) - # #176: hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost - # (admin-scoped). Self-labels "not configured" when no admin key is set, - # "not bound" for the common unbound-session 404 — always writes at mount. - self.run_worker(self._hydrate_bifrost_state()) - # #11: long-lived worker streaming GET /admin/events into the AdminEvents - # pane, filtered to this session. Admin-key-gated; cancelled on app exit. - self.run_worker(self._stream_admin_events()) - - async def _hydrate_persona(self) -> None: - """Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state. - - Failure modes are absorbed (this is best-effort observability): - - PersonaNotConfigured: pane shows placeholder, header stays empty - - AgentNotAvailable / AuthScopeDenied: error placeholder; header empty - - Network error: error placeholder; header empty - On 200: header populated, pane shows full detail, audit logged. - """ - assert self.client is not None and self.agent_id is not None - try: - snapshot = await get_persona_state(self.client, self.agent_id) - self._update_persona_surfaces(snapshot) - self._audit( - f"persona_hydrated agent_id={self.agent_id!r} " - f"dominant_emotion={snapshot.get('dominant_emotion')!r}" - ) - except PersonaNotConfigured: - self._set_persona_placeholder( - f"(persona not configured for {self.agent_id})" - ) - self._audit(f"persona_not_configured agent_id={self.agent_id!r}") - except (AgentNotAvailable, AuthScopeDenied, SessionApiFailed, Exception) as exc: - # Best-effort — never let a persona hydration failure crash the - # TUI. Surface the error in the persona pane and audit log. - self._set_persona_placeholder( - f"(persona hydration failed: {type(exc).__name__})" - ) - self._audit( - f"persona_hydration_failed agent_id={self.agent_id!r} " - f"err={type(exc).__name__}: {exc!s:.120}" - ) - - async def _hydrate_session_tools(self) -> None: - """Hydrate the Tools pane inventory via GET /sessions/{id}/tools (#183). - - Best-effort observability (mirrors _hydrate_persona): on 200, writes the - merged tool inventory (builtin + bifrost) the LLM saw at turn-fire into - the Tools pane + audits; on any failure, audits and moves on — never - crashes the TUI. Owner-scoped, so reachable with the consumer key. - """ - assert self.client is not None and self.session_id is not None - from rich.text import Text as RichText - - try: - tools = await get_session_tools(self.client, self.session_id) - except Exception as exc: # best-effort — never crash the TUI on hydrate - self._audit( - f"session_tools_hydration_failed session={self.session_id[-8:]} " - f"err={type(exc).__name__}: {exc!s:.120}" - ) - return - log = self.query_one("#tools-log", RichLog) - for line in _format_tool_inventory(tools): - log.write(RichText(line)) - self._audit( - f"session_tools_hydrated session={self.session_id[-8:]} " - f"builtin={len(tools.get('builtin_tools', []))} " - f"bifrost={len(tools.get('bifrost_tools', []))}" - ) - - async def _hydrate_bifrost_state(self) -> None: - """Hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost (#176). - - Admin-scoped (admin.sessions.read) — uses `self.args.admin_key`. Best-effort - (mirrors _hydrate_session_tools): on 200 writes the live binding (endpoint, - connected, granted caps, tools) + audits; on failure a labeled line + audit, - never crashes. No admin key → "not configured". 404 session_not_bifrost_bound - is the routine unbound-session case; 403 means the key lacks the scope. - """ - assert self.client is not None and self.session_id is not None - from rich.text import Text as RichText - - log = self.query_one("#bifrost-log", RichLog) - admin_key = getattr(self.args, "admin_key", None) - if not admin_key: - log.write( - RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)") - ) - self._audit( - f"bifrost_state_skipped session={self.session_id[-8:]} reason=no_admin_key" - ) - return - try: - state = await get_session_bifrost( - self.client, self.session_id, admin_key=admin_key - ) - except SessionApiFailed as exc: - label = ( - "(session not bound to Bifrost)" - if exc.status == 404 - else f"(bifrost state unavailable: HTTP {exc.status})" - ) - log.write(RichText(label)) - self._audit( - f"bifrost_state_unavailable session={self.session_id[-8:]} status={exc.status}" - ) - return - except Exception as exc: # best-effort — never crash the TUI on hydrate - log.write(RichText(f"(bifrost state hydration failed: {type(exc).__name__})")) - self._audit( - f"bifrost_state_hydration_failed session={self.session_id[-8:]} " - f"err={type(exc).__name__}: {exc!s:.120}" - ) - return - for line in _format_bifrost_state(state): - log.write(RichText(line)) - self._audit( - f"bifrost_state_hydrated session={self.session_id[-8:]} " - f"connected={state.get('connected')} tools={len(state.get('tools', []))}" - ) - - def _admin_event_matches(self, ev: AdminEvent) -> bool: - """AdminEvents filter (design-brief §6): active-session events + non-heartbeat - system.* (stream-integrity signals). Heartbeats are keepalive noise.""" - if ev.type == "system.heartbeat": - return False - if ev.type.startswith("system."): - return True - return ev.data.get("session_id") == self.session_id - - async def _stream_admin_events(self) -> None: - """Stream GET /admin/events (admin-scoped) into the AdminEvents pane (#11). - - Long-lived + best-effort (never crashes the TUI). Filtered to the active - session (design-brief §6): appends matching lifecycle events as they - arrive. No admin key → "not configured". On connect failure (e.g. 403 - scope-denied) or a mid-stream drop, writes a labeled line and stops. - """ - assert self.client is not None and self.session_id is not None - from rich.text import Text as RichText - - log = self.query_one("#admin-events-log", RichLog) - admin_key = getattr(self.args, "admin_key", None) - if not admin_key: - log.write(RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)")) - self._audit( - f"admin_events_skipped session={self.session_id[-8:]} reason=no_admin_key" - ) - return - try: - async for ev in stream_admin_events(self.client, admin_key=admin_key): - if self._admin_event_matches(ev): - log.write(RichText(_format_admin_event(ev))) - except SseConnectFailed as exc: - log.write(RichText(f"(admin events unavailable: HTTP {exc.status})")) - self._audit( - f"admin_events_unavailable session={self.session_id[-8:]} status={exc.status}" - ) - except Exception as exc: # drop / best-effort — never crash the TUI - log.write(RichText(f"(admin events stream ended: {type(exc).__name__})")) - self._audit( - f"admin_events_ended session={self.session_id[-8:]} err={type(exc).__name__}" - ) - - def _update_persona_surfaces(self, snapshot: dict) -> None: - """Update sticky header + Persona pane from a fresh snapshot. - - Called on bootstrap (on_mount) and on each AffectUpdate(current). - Header gets the compact one-liner; pane gets the full detail. - """ - from rich.text import Text as RichText - try: - header = self.query_one("#persona-header", Static) - header.update(RichText(_format_persona_header(snapshot))) - header.remove_class("empty") - except Exception: - pass - try: - log = self.query_one("#persona-log", RichLog) - log.clear() - log.write(_format_persona_detail(snapshot)) - except Exception: - pass - - def _set_persona_placeholder(self, text: str) -> None: - """Render an italic-dim placeholder in the Persona pane; keep header empty. - - Used when persona hydration returns PersonaNotConfigured or fails — - the pane stays usable as documentation of *why* it's empty without - the sticky header consuming a row for nothing. - """ - from rich.text import Text as RichText - try: - log = self.query_one("#persona-log", RichLog) - log.clear() - log.write(RichText(text, style=f"{_AU_DEMOTED_FAINT} italic")) - except Exception: - pass - - def _write_turn_headers(self, turn_id: int) -> None: - """v0.6.0: turn-ID headers across every pane for cross-pane - correlation. v0.9.0: transcript is a VerticalScroll; mounts a - Static with rule-style text instead of writing a Rule Renderable - to RichLog. Other panes still use RichLog.write(Rule). - """ - from rich.rule import Rule - from rich.text import Text as RichText - - title = f"turn {turn_id}" - rule = Rule(title=title, style=_AU_DEMOTED) - try: - # Transcript (VerticalScroll): mount a styled Static. - transcript = self.query_one("#transcript-scroll", VerticalScroll) - transcript.mount( - Static( - RichText(f"── turn {turn_id} ──", style=_AU_DEMOTED), - classes="turn-header", - ) - ) - # Other panes (RichLog): write the Rule Renderable. - self.query_one("#tools-log", RichLog).write(rule) - self.query_one("#debug-log", RichLog).write(rule) - self.query_one("#thinking-log", RichLog).write(rule) - transcript.scroll_end(animate=False) - except Exception: - # Defensive: widget tree may be tearing down — never let a - # turn-header write block the SSE consumer. - pass - - def _set_hint(self, hint: str) -> None: - """Set the hint state attribute AND update the visible Static widget.""" - self.hint = hint - try: - self.query_one("#hint", Static).update(hint) - except Exception: - # Widget may be gone during shutdown; ignore. - pass - - def _audit(self, line: str) -> None: - """Write a timestamped audit line to the debug pane. - - v0.10.0: shared sink for app-level events that don't pass through - the presenter — state transitions, worker spawn/cancel, cancel POST - lifecycle, startup probes. The presenter's per-event audit lives at - `_audit_line()`; this is its app-side counterpart. - """ - try: - from rich.text import Text as RichText - self.query_one("#debug-log", RichLog).write( - RichText(f"[{_ts()}] {line}", style=_AU_DEMOTED) - ) - except Exception: - # Widget may not exist yet (pre-mount) or be tearing down. - pass - - def _transition( - self, new_state: Literal["idle", "streaming", "cancelling"], reason: str - ) -> None: - """Set self.state with debug-pane audit log. - - Every state machine transition flows through here so the debug pane - carries a complete idle→streaming→cancelling→idle timeline with the - triggering reason. Cheap; safe to call from any context. - """ - old = self.state - self.state = new_state - if old != new_state: - self._audit(f"state {old} → {new_state} reason={reason}") - - async def on_input_submitted(self, event: Input.Submitted) -> None: - """Echo user prompt, spawn stream worker; busy notice if not idle. - - v0.9.0: prompt echo mounts as a Static in the transcript VerticalScroll - (was log.write to RichLog). - """ - if event.input.id != "prompt": - return - transcript = self.query_one("#transcript-scroll", VerticalScroll) - if self.state != "idle": - transcript.mount( - Static("[busy] turn in flight; input ignored", classes="error-label") - ) - transcript.scroll_end(animate=False) - event.input.value = "" - return - content = event.input.value.strip() - if not content: - return - # v0.4.1 retheme: operator's voice gets Australis bright cyan so it - # stands out against the default-foreground assistant text below it. - from rich.text import Text as RichText - transcript.mount( - Static( - RichText(f"❯ {content}", style=_AU_USER_ECHO), # noqa: RUF001 - classes="prompt-echo", - ) - ) - transcript.scroll_end(animate=False) - event.input.value = "" - self._transition("streaming", "input_submitted") - self._audit(f"worker_spawn content_len={len(content)}") - self._set_hint(self.HINT_STREAMING) - self.stream_worker = self.run_worker( - self._stream_turn_worker(content), exclusive=True - ) - - async def _stream_turn_worker(self, content: str) -> None: - """Drive stream_turn, render events via TuiPresenterState. - - v0.9.0: transcript is a VerticalScroll; the presenter's `transcript` - argument is the container, and the presenter mounts Static / Markdown- - backed widgets directly. Wire-error labels mount as `error-label` - Statics into the transcript-scroll. - """ - assert self.state == "streaming" - assert self.client is not None - assert content - transcript = self.query_one("#transcript-scroll", VerticalScroll) - tools_log = self.query_one("#tools-log", RichLog) - debug_log = self.query_one("#debug-log", RichLog) - thinking_log = self.query_one("#thinking-log", RichLog) - presenter = TuiPresenterState() - - def _mount_wire_error(label: str) -> None: - try: - transcript.mount(Static(label, classes="error-label")) - transcript.scroll_end(animate=False) - except Exception: - pass - - try: - async for event in stream_turn_resilient(self.client, self.session_id, content): - if self.active_turn_id is None: - self.active_turn_id = event.sse_id.turn_id - self._write_turn_headers(self.active_turn_id) - presenter.render( - event, - transcript=transcript, - tools_log=tools_log, - debug_log=debug_log, - thinking_log=thinking_log, - raw=self.args.raw, - on_persona_snapshot=self._update_persona_surfaces, - ) - if isinstance(event, (Done, Error, Cancelled)): - break - except SseConnectFailed as exc: - self._audit(f"sse_connect_failed status={exc.status} body={exc.body!r:.120}") - _mount_wire_error(f"[sse_connect_failed] status={exc.status} body={exc.body!r}") - except SseConnectionDropped as exc: - self._audit(f"connection_dropped last_seen={exc.last_seen_sse_id}") - _mount_wire_error(f"[connection_dropped] last_seen={exc.last_seen_sse_id}") - except MalformedSseId as exc: - self._audit(f"malformed_sse_id raw={exc.raw!r}") - _mount_wire_error(f"[malformed_sse_id] raw={exc.raw!r}") - except MalformedSseData as exc: - self._audit(f"malformed_sse_data raw={exc.raw!r:.120}") - _mount_wire_error(f"[malformed_sse_data] raw={exc.raw!r}") - except TurnIdFlip as exc: - self._audit(f"turn_id_flip expected={exc.established} got={exc.got}") - _mount_wire_error(f"[turn_id_flip] expected={exc.established} got={exc.got}") - finally: - self._transition("idle", "worker_finally") - self.active_turn_id = None - self._set_hint(self.HINT_IDLE) - - async def on_unmount(self) -> None: - """No-op — client lifetime is managed by run_tui's async-with (INV-002).""" - return None - - def action_interrupt(self) -> None: - """Two-stage Ctrl-C state machine per INV-003.""" - assert self.state in ("idle", "streaming", "cancelling") - if self.state == "idle": - self._audit("ctrl_c state=idle action=exit code=0") - self.exit(0) - elif self.state == "streaming": - if self.active_turn_id is None: - self._audit("ctrl_c state=streaming active_turn_id=None action=force_exit code=3") - if self.stream_worker is not None: - self.stream_worker.cancel() - self.exit(3) - return - self._audit(f"ctrl_c state=streaming turn_id={self.active_turn_id} action=cancel_post") - self._transition("cancelling", "ctrl_c_cancel_post_issued") - self._set_hint(self.HINT_CANCELLING) - transcript = self.query_one("#transcript-scroll", VerticalScroll) - self.run_worker( - _cancel_via_sse( - self.client, self.session_id, self.active_turn_id, - transcript=transcript, - audit=self._audit, - ) - ) - elif self.state == "cancelling": - self._audit("ctrl_c state=cancelling action=force_exit code=3") - if self.stream_worker is not None: - self.stream_worker.cancel() - self.exit(3) - - def action_quit(self) -> None: - """Ctrl-D — immediate exit regardless of state.""" - self._audit(f"ctrl_d state={self.state} action=exit code=0") - if self.stream_worker is not None and not self.stream_worker.is_finished: - self.stream_worker.cancel() - self.exit(0) - - def action_focus_tools(self) -> None: - """Issue #13: Ctrl+1 activates the Tools tab. INV-016 preserves Input focus. - - Current Textual behavior preserves Input focus when TabbedContent.active is - set programmatically. If a future Textual regresses on that, add an - explicit `self.query_one('#prompt', Input).focus()` after the assignment - — `test_ctrl_1_preserves_input_focus` is the regression guard. - """ - self.query_one("#side-panes", TabbedContent).active = "tools-tab" - self.query_one("#pane-name", Static).update("Tools") - - def action_focus_debug(self) -> None: - """v0.5.0: Ctrl+2 activates the Debug tab. INV-016 preserves Input focus.""" - self.query_one("#side-panes", TabbedContent).active = "debug-tab" - self.query_one("#pane-name", Static).update("Debug") - - def action_focus_thinking(self) -> None: - """v0.6.0: Ctrl+3 activates the Thinking tab. INV-016 preserves Input focus.""" - self.query_one("#side-panes", TabbedContent).active = "thinking-tab" - self.query_one("#pane-name", Static).update("Thinking") - - def action_focus_persona(self) -> None: - """v0.13.0: Ctrl+4 activates the Persona tab. INV-016 preserves Input focus.""" - self.query_one("#side-panes", TabbedContent).active = "persona-tab" - self.query_one("#pane-name", Static).update("Persona") - - -def run_tui(args: ParsedArgs) -> int: - """Sync entry point — delegates to the async resolve-then-run flow. - - Per issue #6: session resolution + AsyncClient open happen BEFORE the - Textual alt-screen opens, so startup errors land on the operator's real - stderr instead of getting eaten by the alt-screen teardown. - """ - # PRE-001: TUI-mode marker (issue #4 contract) - assert isinstance(args, ParsedArgs) and args.send_content is None - # PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER - # is now valid — bare TUI mode opens the startup session picker (§4). - assert not (args.session_id and args.new) - return asyncio.run(_resolve_then_run(args)) - - -async def _resolve_then_run(args: ParsedArgs) -> int: - """Pre-flight session resolution then App.run_async() inside one event loop. - - Errors at this layer print to `sys.stderr` (the operator's real terminal) - and short-circuit BEFORE the alt-screen opens (INV-001). The label format - + exit codes match `ratatoskr.cli._amain`'s exactly (INV-006), so operators - see one vocabulary across `--send` and TUI modes. - """ - # PRE-001 (defense-in-depth; run_tui also asserts at the sync boundary) - assert isinstance(args, ParsedArgs) and args.send_content is None - async with httpx.AsyncClient( - base_url=args.server_url, - headers={ - "Authorization": f"Bearer {args.api_key}", - "User-Agent": USER_AGENT, - }, - # SSE streaming sits idle between events while the LLM thinks. - # Default 5s read timeout would kill mid-stream; disable it. - timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), - ) as client: - # Issue #8: startup agent picker — fetch GET /agents and prompt when - # --new is passed without --agent. list_agents errors land on real - # stderr before any alt-screen opens (preserves issue #6 INV-001). - # - # v0.8.0: merge in local tier-3 agent index. Worldtree's GET /agents - # doesn't return consumer-defined agents (issue #15 smoke finding); - # ratatoskr keeps its own JSON-backed index of agents the operator - # defined via `python -m ratatoskr.tier3 define`. Merged here so the - # picker shows foundational + local-tier-3 in one list. Dedup by - # agent_id (remote wins on conflict, since a server-listed agent - # is the authoritative source). - chosen_agent_id: str | None = args.agent_id - # slice b2: bare TUI mode (no --session, no --new) → startup session - # picker (design-brief §4). Resolve into a concrete session_id BEFORE - # the new/resume branches. Resume-only: bare + 0 sessions is an error - # (creating a session is the --new flag's job). - resolved_session_id: str | None = args.session_id - if not args.new and args.session_id is None: - try: - page = await list_sessions(client) - except SessionApiFailed as exc: - sys.stderr.write( - f"[session_api_failed] status={exc.status} body={exc.body!r}\n" - ) - return 20 - except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: - sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") - return 21 - if not page.items: - sys.stderr.write( - "[no_sessions] no sessions to resume; " - "launch with --new --agent \n" - ) - return 14 - if len(page.items) == 1: - # §4: picker only when >1 — a single session auto-resumes. - resolved_session_id = page.items[0].session_id - else: - resolved_session_id = await SessionPickerApp(page.items).run_async() - if resolved_session_id is None: - return 0 # Esc / Ctrl-D — clean exit, no session opened - if args.new and args.agent_id is None: - try: - agents = await list_agents(client) - except SessionApiFailed as exc: - sys.stderr.write( - f"[session_api_failed] status={exc.status} body={exc.body!r}\n" - ) - return 20 - except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: - sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") - return 21 - # v0.8.0: append local tier-3 entries not already in the remote list. - from ratatoskr.local_agents import load_local_agents - - remote_ids = {a.agent_id for a in agents} - for entry in load_local_agents(): - if entry.agent_id in remote_ids: - continue - agents.append(AgentInfo( - agent_id=entry.agent_id, - name=entry.agent_name, - description=entry.description, - version=None, - capabilities=[], - supported_models=[], - persona_traits={}, - ui_hints={}, - )) - if not agents: - sys.stderr.write("[no_agents] server returned empty agent list\n") - return 13 - picker = AgentPickerApp(agents) - chosen_agent_id = await picker.run_async() - if chosen_agent_id is None: - return 0 # Esc / Ctrl-D — clean exit, no session created - if args.new: - assert chosen_agent_id is not None - try: - info = await create_session( - client, - chosen_agent_id, - end_user_id=args.end_user_id, - bifrost=args.bifrost, - consumer_key=args.consumer_key, - ) - except AgentNotFound as exc: - sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n") - return 12 - except BifrostConsumerKeyMissing as exc: - # INV-001/INV-002: bind failures land on real stderr BEFORE the - # alt-screen opens (mirrors cli._amain exit codes / vocab, INV-006). - sys.stderr.write( - f"[bifrost_consumer_key_missing] {exc} " - f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n" - ) - return 22 - except BifrostHandshakeFailed as exc: - sys.stderr.write( - f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n" - ) - if exc.bifrost_error == "bifrost.auth_rejected": - sys.stderr.write( - " bound create requires the consumer key " - "(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n" - ) - return 23 - except SessionApiFailed as exc: - sys.stderr.write( - f"[session_api_failed] status={exc.status} body={exc.body!r}\n" - ) - return 20 - except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: - sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") - return 21 - # Issue #17 bound-state indicator (pre-alt-screen, mirrors cli._amain). - if args.bifrost is not None: - plane = args.bifrost_plane or "direct" - sys.stderr.write( - f". bifrost: status=bound plane={plane} " - f"endpoint={args.bifrost.endpoint_url}\n" - ) - session_id = info.session_id - agent_id: str | None = info.agent_id - # #347 authored first-message: seed the agent's preset opening (best-effort). - await seed_preset_first_message(client, session_id, chosen_agent_id) - else: - assert resolved_session_id is not None - session_id = resolved_session_id - agent_id = args.agent_id # may be None — INV-002 carve-out preserved - app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client) - exit_code = await app.run_async() - return exit_code or 0 - - -async def _cancel_via_sse( - client: httpx.AsyncClient, - session_id: str, - turn_id: int, - *, - transcript: VerticalScroll, - audit: Callable[[str], None] | None = None, -) -> None: - """Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009). - - v0.9.0: mounts a `[cancel_failed]` Static into the transcript-scroll - container on failure (was log.write to RichLog). - v0.10.0: optional `audit` callback (RatatoskrApp._audit) receives one - line on POST issue + one on POST result, so the debug pane carries the - full cancel lifecycle. Defaults to no-op for legacy callers. - """ - assert client is not None - assert isinstance(turn_id, int) and turn_id > 0 - if audit is not None: - audit(f"cancel_post issued session_id={session_id} turn_id={turn_id}") - try: - await cancel_turn(client, session_id, turn_id) - if audit is not None: - audit(f"cancel_post ok turn_id={turn_id}") - except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc: - if audit is not None: - audit(f"cancel_post failed turn_id={turn_id} {type(exc).__name__}: {exc!s:.120}") - try: - transcript.mount(Static( - f"[cancel_failed] {type(exc).__name__}: {exc}", - classes="error-label", - )) - transcript.scroll_end(animate=False) - except Exception: - pass diff --git a/tests/test_cli.py b/tests/test_cli.py index a66e445..86e397e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -150,7 +150,8 @@ class TestParseArgs: assert args.server_url == "flag" def test_no_send_marks_tui_mode(self) -> None: - """no_send_marks_tui_mode: missing --send → send_content=None (TUI marker).""" + """no_send_marks_tui_mode: missing --send → send_content=None (the no-headless- + action marker; main() then returns a usage error since the TUI was removed).""" args = _parse_args(["--new", "--agent", "mimir", "--api-key", "k"]) assert args.send_content is None # Other fields still populate normally @@ -184,8 +185,8 @@ class TestParseArgs: _parse_args(["--send", "hi", "--api-key", "k"]) def test_bare_tui_mode_accepted(self) -> None: - """bare_tui_mode (slice b2): no --send, no --session, no --new → valid; - _resolve_then_run drives the startup session picker (design-brief §4).""" + """bare_tui_mode (slice b2): no --send, no --session, no --new → parses valid + (send_content=None); main() then returns a usage error (the TUI was removed).""" args = _parse_args(["--api-key", "k"]) assert args.send_content is None assert args.session_id is None @@ -1414,29 +1415,22 @@ class TestMain: # argparse prints help text to stdout assert "ratatoskr" in capsys.readouterr().out - def test_no_send_dispatches_to_tui(self, monkeypatch: pytest.MonkeyPatch) -> None: - """no_send_dispatches_to_tui: --send omitted → main calls run_tui, NOT _amain.""" - from ratatoskr import tui as tui_mod - - tui_calls: list[ParsedArgs] = [] + def test_no_send_is_usage_error( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """no_send_is_usage_error: --send omitted → usage error (rc 10, the interactive + TUI was removed in v0.21.0), and _amain is NOT called.""" amain_calls: list[int] = [] - def fake_run_tui(args: ParsedArgs) -> int: - tui_calls.append(args) - return 0 - async def fake_amain(args: ParsedArgs) -> int: amain_calls.append(1) return 0 - monkeypatch.setattr(tui_mod, "run_tui", fake_run_tui) monkeypatch.setattr(cli_mod, "_amain", fake_amain) rc = main(["--session", "s-1", "--api-key", "k"]) - assert rc == 0 - assert len(tui_calls) == 1 - assert tui_calls[0].send_content is None - assert tui_calls[0].session_id == "s-1" + assert rc == 10 assert amain_calls == [] + assert "TUI has been removed" in capsys.readouterr().err class TestBifrostBindCli: diff --git a/tests/test_tui.py b/tests/test_tui.py deleted file mode 100644 index 2c4c3da..0000000 --- a/tests/test_tui.py +++ /dev/null @@ -1,3443 +0,0 @@ -"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md.""" - -from pathlib import Path -from unittest.mock import MagicMock - -import httpx -import pytest -import respx -from textual.widgets import RichLog - -from ratatoskr.cli import ParsedArgs -from ratatoskr.sessions import BifrostBinding -from ratatoskr.sse_client import ( - Cancelled, - Done, - SseId, - Text, - Thinking, - ToolResult, - ToolStart, - WorkerPhase, -) -from ratatoskr.tui import RatatoskrApp, _cancel_via_sse - -_CANCEL_OK_RESP = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None} -_CREATE_OK_RESP = { - "session_id": "s-new12345", - "agent_id": "mimir", - "message_count": 0, - "created_at": "2026-05-21T00:00:00+00:00", - "last_active": "2026-05-21T00:00:00+00:00", - "metadata": {}, -} - - -def _args_new(**overrides) -> ParsedArgs: - base = dict( - send_content=None, - session_id=None, - new=True, - agent_id="mimir", - api_key="k", - server_url="https://w.example", - raw=False, - ) - base.update(overrides) - return ParsedArgs(**base) - - -def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs: - base = dict( - send_content=None, - session_id=session_id, - new=False, - agent_id=None, - api_key="k", - server_url="https://w.example", - raw=False, - ) - base.update(overrides) - return ParsedArgs(**base) - - -def _spy_writes(monkeypatch) -> list: - """Patch RichLog.write AND VerticalScroll.mount to record every renderable - or mounted-widget content into a single list (returned). - - v0.9.0: transcript content is mounted into a VerticalScroll, not written - to a RichLog. The spy captures both shapes — for each mounted Static, the - Static's `renderable` (Markdown / RichText / str) lands in the list, - indistinguishably from RichLog.write entries. Integration tests assert - on substrings or types in `writes` so the merged shape is the right - abstraction. - - Accepts *args/**kwargs so Textual's internal deferred-render paths still - work after a write-during-mount + Resize sequence. - """ - from textual.containers import VerticalScroll - from textual.widgets import Static - - writes: list = [] - - original_write = RichLog.write - - def spy_write(self, content, *args, **kw): - writes.append(content) - return original_write(self, content, *args, **kw) - - monkeypatch.setattr(RichLog, "write", spy_write) - - original_mount = VerticalScroll.mount - - def spy_mount(self, *children, **kw): - for child in children: - if isinstance(child, Static): - writes.append(child.content) - else: - writes.append(child) - return original_mount(self, *children, **kw) - - monkeypatch.setattr(VerticalScroll, "mount", spy_mount) - return writes - - -def _resolved_app( - args: ParsedArgs, - *, - session_id: str | None = None, - agent_id: str | None = None, - client: httpx.AsyncClient | None = None, -) -> RatatoskrApp: - """Construct RatatoskrApp with pre-resolved state (issue #6 lifecycle). - - Production path: `run_tui` → `_resolve_then_run` opens AsyncClient, mints - or attaches session, then constructs the App with the resolved tuple. This - helper inlines that shape so tests bypass the pre-flight without - re-implementing it. The client is opened here (and leaks at test teardown - — acceptable; respx mocks all network calls and pytest exits cleanly). - """ - sid = session_id if session_id is not None else (args.session_id or "s-default") - aid = args.agent_id if agent_id is None else agent_id - if client is None: - client = httpx.AsyncClient( - base_url=args.server_url, - headers={"Authorization": f"Bearer {args.api_key}"}, - timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), - ) - return RatatoskrApp(args, session_id=sid, agent_id=aid, client=client) - - -SID = SseId(42, 5) - - -# Issue #12 — TuiPresenterState replaces _render_event_to_log with a stateful -# per-turn presenter. (Pre-amendment TestRenderEventToLog class and -# `_render_event_to_log` function have been removed under the project's -# no-backwards-compatibility rule.) - - -class TestTuiPresenterState: - """Tests for the new TuiPresenterState — per issue #12 contract.""" - - def test_thinking_coalesces_until_newline(self) -> None: - """thinking_coalesces_until_newline [happy,tracer, v0.7.1]: - Per-token deltas accumulate in the buffer; flush only on `\\n`. - Three short token-shaped deltas without `\\n` → thinking_log gets - ONLY Rule(start); content stays buffered. - """ - from rich.rule import Rule - - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - thinking_log = MagicMock() - state = TuiPresenterState() - for chunk in ("Let", " me", " think"): - state.render( - Thinking(sse_id=SID, content=chunk), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=thinking_log, - raw=False, - ) - writes = [c[0][0] for c in thinking_log.write.call_args_list] - # Only Rule(start) — content stays buffered (no `\n` seen). - assert len(writes) == 1 - assert isinstance(writes[0], Rule) - assert state.thinking_chunk_buffer == "Let me think" - assert transcript.mount.call_count == 0 - - def test_thinking_flushes_on_newline(self) -> None: - """thinking_flushes_on_newline [happy, v0.7.1]: - Delta carrying `\\n` flushes the accumulated buffer as ONE line. - """ - from ratatoskr.tui import TuiPresenterState - - thinking_log = MagicMock() - state = TuiPresenterState() - for chunk in ("Hello", " world", "\n"): - state.render( - Thinking(sse_id=SID, content=chunk), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=thinking_log, - raw=False, - ) - writes = [c[0][0] for c in thinking_log.write.call_args_list] - # Rule(start) + "Hello world" (one coalesced line) = 2 writes - assert len(writes) == 2 - assert writes[1] == "Hello world" - assert state.thinking_chunk_buffer == "" - - def test_thinking_closes_to_thinking_log(self) -> None: - """thinking_closes_to_thinking_log [happy, v0.7.1]: 2x Thinking + WorkerPhase → - v0.7.1 coalesces "a"+"b" into one buffered string; the close flushes - "ab" as a single line before Rule(end). Result: Rule(start) + "ab" + - Rule(end) = 3 writes. debug_log gets worker_phase; transcript untouched. - """ - from rich.rule import Rule - - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - debug_log = MagicMock() - thinking_log = MagicMock() - state = TuiPresenterState() - for content in ("a", "b"): - state.render( - Thinking(sse_id=SID, content=content), - transcript=transcript, - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=thinking_log, - raw=False, - ) - state.render( - WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), - transcript=transcript, - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=thinking_log, - raw=False, - ) - thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list] - # v0.7.1: 1 Rule(start) + 1 coalesced "ab" tail-flush + 1 Rule(end) = 3 writes - assert len(thinking_writes) == 3 - assert isinstance(thinking_writes[0], Rule) - assert thinking_writes[1] == "ab" - assert isinstance(thinking_writes[2], Rule) - # worker_phase still goes to debug_log; transcript untouched. - assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0]) - assert not transcript.mount.called - - # v0.6.5: thinking-current Static removed; test_thinking_widget_truncation - # and test_thinking_widget_visibility_lifecycle deleted (no longer apply). - - def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None: - """multiple_thinking_runs_each_get_section [scenario, v0.8.1]: - Thinking → Text → Thinking → Done → TWO start/end Rule pairs in - thinking_log (deltas coalesced into tail-flushes per run). - Text deltas now stream into the transcript via coalesce-on-newline - (no current-text Static); "hi" with no `\\n` stays buffered until - Done's tail-flush. - """ - from rich.rule import Rule - - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - thinking_log = MagicMock() - state = TuiPresenterState() - for evt in ( - Thinking(sse_id=SID, content="first"), - Text(sse_id=SID, content="hi"), - Thinking(sse_id=SID, content="second"), - ): - state.render( - evt, transcript=transcript, - tools_log=MagicMock(), debug_log=MagicMock(), - thinking_log=thinking_log, raw=False, - ) - state.render( - _make_tui_done(), - transcript=transcript, - tools_log=MagicMock(), debug_log=MagicMock(), - thinking_log=thinking_log, raw=False, - ) - # thinking_log: 4 Rules (start+end per run) + 2 tail-flush strings. - thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list] - rules = [w for w in thinking_writes if isinstance(w, Rule)] - delta_strs = [w for w in thinking_writes if isinstance(w, str)] - assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}" - assert "first" in delta_strs - assert "second" in delta_strs - # v0.8.1: Text "hi" flushes as a line in transcript on Done. - transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)] - assert "hi" in transcript_renderables - assert any(w.startswith("[done]") for w in transcript_renderables if isinstance(w, str)) - - def test_render_exception_fallback(self) -> None: - """render_exception_fallback [adversarial, v0.6.5]: - thinking_log.write raises → catch in presenter, write plain-label - fallback + render_error line via INV-009 fallback path (routing - preservation: thinking events still route to thinking_log). - """ - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - thinking_log = MagicMock() - # First call (Rule write) raises; subsequent calls succeed for fallback. - thinking_log.write.side_effect = [ - AttributeError("rule write failed (msg should NOT leak)"), - None, - None, - ] - state = TuiPresenterState() - state.render( - Thinking(sse_id=SID, content="x"), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=thinking_log, - raw=False, - ) - writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)] - assert any(w.startswith("[thinking]") for w in writes), writes - assert any(w == "[render_error] AttributeError" for w in writes), writes - assert not any("rule write failed" in w for w in writes), writes - assert not transcript.mount.called - - def test_state_reset_per_worker(self) -> None: - """state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open.""" - from ratatoskr.tui import TuiPresenterState - - s1 = TuiPresenterState() - s1.render( - Thinking(sse_id=SID, content="x"), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - s2 = TuiPresenterState() - assert s1.thinking_open is True - assert s2.thinking_open is False - - def test_cancelled_mid_thinking_closes(self) -> None: - """cancelled_mid_thinking_closes [scenario, v0.5.0]: - Thinking, Cancelled → ONE closed thinking entry in debug_log + a - [cancelled] entry in transcript; widget hidden. - """ - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - thinking_log = MagicMock() - state = TuiPresenterState() - state.render( - Thinking(sse_id=SID, content="partial"), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), thinking_log=thinking_log, raw=False, - ) - state.render( - Cancelled( - sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None - ), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), thinking_log=thinking_log, raw=False, - ) - # v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript. - transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)] - assert any(w.startswith("[cancelled]") for w in transcript_renderables) - # thinking_log got at least Rule(start) + "partial" delta + Rule(end) - assert thinking_log.write.call_count >= 3 - - def test_text_then_done_mounts_widget_and_finalizes(self) -> None: - """text_then_done_mounts_widget_and_finalizes [happy, v0.9.0]: - First Text delta mounts a Static(Markdown(buffer)) into the transcript; - Done finalizes the widget reference and mounts a styled [done] label. - No duplicate content (v0.9.0 replaces v0.8.x's flush-on-Done with - live in-place Markdown updates). - """ - from rich.markdown import Markdown - - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - state = TuiPresenterState() - state.render( - Text(sse_id=SID, content="hi"), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - # v0.9.0: response widget mounted on first Text delta with Markdown wrapper. - assert transcript.mount.called - first_widget = transcript.mount.call_args_list[0][0][0] - assert isinstance(first_widget.content, Markdown) - assert first_widget.content.markup == "hi" - assert state.text_chunk_buffer == "hi" - # Done finalizes: text_chunk_buffer cleared, widget ref released, label mounted. - state.render( - _make_tui_done(), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - writes = _mounted_renderables(transcript) - assert any(_text_of(w).startswith("[done]") for w in writes) - # v0.9.0: response Markdown rendered live during stream — only ONE - # Markdown renderable lands in the transcript (no post-Done re-render). - markdowns = [w for w in writes if isinstance(w, Markdown)] - assert len(markdowns) == 1 - assert state.text_chunk_buffer == "" - assert state.current_response_widget is None - - def test_raw_flag_skips_markdown(self) -> None: - """raw_flag_skips_markdown [v0.9.0]: raw=True → response widget holds - plain str instead of Markdown. Live in-place update still happens; - only the wrapper differs. - """ - from rich.markdown import Markdown - - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - state = TuiPresenterState() - state.render( - Text(sse_id=SID, content="hi"), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, - ) - state.render( - _make_tui_done(), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, - ) - writes = _mounted_renderables(transcript) - # Raw mode bypasses Markdown entirely — content lives as plain str. - assert not any(isinstance(w, Markdown) for w in writes) - assert "hi" in writes - - def test_worker_phase_demoted_to_debug_log(self) -> None: - """worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log - "· worker_phase:" prefix rendered with Australis dark-60 Rich style. - Transcript receives nothing. - """ - from rich.text import Text as RichText - - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - debug_log = MagicMock() - state = TuiPresenterState() - state.render( - WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), - transcript=transcript, - tools_log=MagicMock(), - debug_log=debug_log, thinking_log=MagicMock(), raw=False, - ) - # v0.5.0: WorkerPhase routes to debug_log, NOT transcript. - assert not transcript.mount.called - renderable = debug_log.write.call_args[0][0] - # INV-003: must be a styled Rich Text renderable, not a plain str. - # v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead - # of the terminal-dim filter "dim". Assert non-empty styling either way. - assert isinstance(renderable, RichText), type(renderable) - assert renderable.style, "demoted telemetry must carry SOME style" - text = renderable.plain - assert text.startswith("· worker_phase:") - assert "[worker_phase]" not in text - - # v0.6.5: test_terminal_events_belt_and_braces_widget_cleanup deleted. - # The thinking-current Static is gone, so there's no widget to clean up - # on terminal events. The corresponding Volva F3 invariant is obsoleted - # by the streaming-into-thinking_log architecture. - - def test_tool_start_routes_to_tools_log(self) -> None: - """tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript. - - Issue #13: tool events route to the dedicated Tools pane (right column). - Pre-#13 wrote them to the main transcript with `· tool_start:` prefix. - Post-#13 the prefix is preserved but the destination shifts. - """ - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - tools_log = MagicMock() - state = TuiPresenterState() - state.render( - ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}), - transcript=transcript, - tools_log=tools_log, - debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, - ) - # INV-014: write went to tools_log - assert tools_log.write.called - assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:") - # INV-014: transcript was NOT written to - assert not transcript.mount.called - - def test_tool_result_routes_to_tools_log(self) -> None: - """tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript.""" - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - tools_log = MagicMock() - state = TuiPresenterState() - state.render( - ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12), - transcript=transcript, - tools_log=tools_log, - debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, - ) - assert tools_log.write.called - assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:") - assert not transcript.mount.called - - def test_text_first_delta_mounts_response_widget(self) -> None: - """text_first_delta_mounts_response_widget [v0.9.0]: first Text delta - mounts a Static carrying Markdown(buffer) into the transcript. The - text_chunk_buffer holds the accumulated content for the next delta's - in-place update. - """ - from rich.markdown import Markdown - - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - tools_log = MagicMock() - state = TuiPresenterState() - state.render( - Text(sse_id=SID, content="hello"), - transcript=transcript, - tools_log=tools_log, - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - assert state.text_chunk_buffer == "hello" - assert transcript.mount.call_count == 1 - widget = transcript.mount.call_args[0][0] - assert isinstance(widget.content, Markdown) - assert widget.content.markup == "hello" - assert state.current_response_widget is widget - assert not tools_log.write.called - - def test_text_subsequent_deltas_update_in_place(self) -> None: - """text_subsequent_deltas_update_in_place [v0.9.0]: deltas after the - first do NOT mount a new widget — they update the existing widget's - Markdown content in place. The text_chunk_buffer accumulates. - """ - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - state = TuiPresenterState() - for tok in ("Hel", "lo", " ", "world"): - state.render( - Text(sse_id=SID, content=tok), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - # Exactly ONE mount (the first delta); subsequent deltas update. - assert transcript.mount.call_count == 1 - assert state.text_chunk_buffer == "Hello world" - # Widget reference held; buffer is the source of truth re-rendered - # into Markdown(...) for each Static.update call. - assert state.current_response_widget is not None - - def test_duration_format_seconds(self) -> None: - """duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s".""" - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - state = TuiPresenterState() - state.render( - _make_tui_done(duration_ms=5467), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, - ) - done_line = next( - _text_of(r) - for r in _mounted_renderables(transcript) - if _text_of(r).startswith("[done]") - ) - assert "duration=5.5s" in done_line - assert "duration_ms=5467" not in done_line - - def test_usage_format_unicode_arrow(self) -> None: - """usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII).""" - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - state = TuiPresenterState() - usage = { - "prompt_tokens": 6756, - "completion_tokens": 126, - "total_tokens": 6882, - "cached_input_tokens": 0, - } - state.render( - _make_tui_done(usage=usage), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, - ) - done_line = next( - _text_of(r) - for r in _mounted_renderables(transcript) - if _text_of(r).startswith("[done]") - ) - assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line - - -class TestPresenterAuditLogging: - """v0.10.0 — per-event audit lines + turn-summary in the debug pane. - - The presenter emits one debug-pane line per arriving event (Text and - Thinking are aggregated into the turn-summary instead of per-delta to - avoid drowning the pane at token rate). - """ - - def test_worker_phase_emits_audit_line(self) -> None: - """worker_phase_emits_audit_line: WorkerPhase arrival adds an audit - line to debug_log alongside the existing `· worker_phase:` entry. - Audit line shape: `[HH:MM:SS.fff] workerphase sse_id=N:M …`. - """ - from ratatoskr.tui import TuiPresenterState - - debug_log = MagicMock() - state = TuiPresenterState() - state.render( - WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=MagicMock(), - raw=False, - ) - # Two writes: audit line + worker_phase telemetry. - assert debug_log.write.call_count == 2 - audit_line = _text_of(debug_log.write.call_args_list[0][0][0]) - assert "workerphase" in audit_line - assert "sse_id=42:5" in audit_line - assert "phase=streaming" in audit_line - - def test_tool_start_emits_audit_line(self) -> None: - """tool_start_emits_audit_line: ToolStart adds one audit line to - debug_log even though the tool event itself routes to tools_log. - """ - from ratatoskr.tui import TuiPresenterState - - debug_log = MagicMock() - state = TuiPresenterState() - state.render( - ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=MagicMock(), - raw=False, - ) - assert debug_log.write.call_count == 1 - audit_line = _text_of(debug_log.write.call_args[0][0]) - assert "toolstart" in audit_line - assert "sse_id=42:5" in audit_line - assert "name=read_file" in audit_line - - def test_text_delta_counted_not_per_event_audit_line(self) -> None: - """text_delta_counted_not_per_event_audit_line: a Text delta does - NOT emit a per-event audit line (token-rate would drown the pane); - instead it bumps text_delta_count / text_byte_count for the turn- - summary at Done. - """ - from ratatoskr.tui import TuiPresenterState - - debug_log = MagicMock() - state = TuiPresenterState() - state.render( - Text(sse_id=SID, content="hello world"), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=MagicMock(), - raw=False, - ) - # No debug-pane writes — text deltas are silent at token rate. - assert not debug_log.write.called - assert state.text_delta_count == 1 - assert state.text_byte_count == len("hello world") - - def test_affect_update_routes_to_audit_only(self) -> None: - """affect_update_routes_to_audit_only [v0.11.0]: AffectUpdate emits ONE - debug-pane audit line (with dominant_emotion + PAD for status=current) - and touches NO other pane (no transcript mount, no tools_log, no - thinking_log). UX shape (persona pane / sticky header) is deferred. - """ - from ratatoskr.sse_client import AffectUpdate - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - tools_log = MagicMock() - thinking_log = MagicMock() - debug_log = MagicMock() - state = TuiPresenterState() - snapshot = { - "agent_id": "mimir", - "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, - "dominant_emotion": "curiosity", - } - state.render( - AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot), - transcript=transcript, - tools_log=tools_log, - debug_log=debug_log, - thinking_log=thinking_log, - raw=False, - ) - assert debug_log.write.call_count == 1 - audit = _text_of(debug_log.write.call_args[0][0]) - assert "affectupdate" in audit - assert "status=current" in audit - assert "dominant_emotion='curiosity'" in audit - assert "pad=(0.5,0.4,0.5)" in audit - assert not transcript.mount.called - assert not tools_log.write.called - assert not thinking_log.write.called - - def test_awaiting_llm_first_token_mounts_indicator(self) -> None: - """awaiting_llm_first_token_mounts_indicator [v0.14.0]: first heartbeat - mounts a Static into the transcript and bumps heartbeat_count; - debug-pane audit line carries turn_id + elapsed in seconds. - """ - from ratatoskr.sse_client import AwaitingLlmFirstToken - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - debug_log = MagicMock() - state = TuiPresenterState() - state.render( - AwaitingLlmFirstToken( - sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5012.3 - ), - transcript=transcript, - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=MagicMock(), - raw=False, - ) - assert state.heartbeat_count == 1 - assert state.awaiting_widget is not None - assert transcript.mount.call_count == 1 - audit = _text_of(debug_log.write.call_args[0][0]) - assert "awaitingllmfirsttoken" in audit - assert "turn_id=42" in audit - assert "elapsed=5.0s" in audit - - def test_awaiting_subsequent_heartbeats_update_in_place(self) -> None: - """awaiting_subsequent_heartbeats_update_in_place [v0.14.0]: second+ - heartbeats reuse the existing Static (no new mount); heartbeat_count - tracks the total. - """ - from ratatoskr.sse_client import AwaitingLlmFirstToken - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - state = TuiPresenterState() - for elapsed in (5000.0, 10005.4, 15011.8): - state.render( - AwaitingLlmFirstToken( - sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=elapsed - ), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - assert state.heartbeat_count == 3 - assert transcript.mount.call_count == 1 # mounted once on first - - def test_awaiting_indicator_removed_when_gap_closes(self) -> None: - """awaiting_indicator_removed_when_gap_closes [v0.14.0]: any non- - heartbeat event after one or more heartbeats removes the indicator - and clears the awaiting_widget reference. Text event simulates the - gap closing (CallingLLM fires, text begins). - """ - from ratatoskr.sse_client import AwaitingLlmFirstToken - from ratatoskr.tui import TuiPresenterState - - transcript = MagicMock() - state = TuiPresenterState() - state.render( - AwaitingLlmFirstToken( - sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5000.0 - ), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - widget = state.awaiting_widget - assert widget is not None - state.render( - Text(sse_id=SID, content="hello"), - transcript=transcript, - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - ) - # State reference cleared (the widget itself is a real Static whose - # .remove() schedules removal — we verify the cleanup intent via - # the state field, which is the contract callers actually observe). - assert state.awaiting_widget is None - - def test_affect_update_scheduled_has_no_pad_detail(self) -> None: - """affect_update_scheduled_has_no_pad_detail [v0.11.0]: status=scheduled - carries no snapshot — the audit line omits dominant_emotion / pad and - contains only status + turn_id. - """ - from ratatoskr.sse_client import AffectUpdate - from ratatoskr.tui import TuiPresenterState - - debug_log = MagicMock() - state = TuiPresenterState() - state.render( - AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=MagicMock(), - raw=False, - ) - audit = _text_of(debug_log.write.call_args[0][0]) - assert "status=scheduled" in audit - assert "turn_id=42" in audit - assert "dominant_emotion" not in audit - assert "pad=" not in audit - - def test_done_emits_turn_summary_line(self) -> None: - """done_emits_turn_summary_line: when Done arrives the presenter - emits a `turn_summary` line aggregating per-delta Text + Thinking - counters. The shape exposes the totals that per-event audit lines - elided. - """ - from ratatoskr.tui import TuiPresenterState - - debug_log = MagicMock() - state = TuiPresenterState() - # 3 Text deltas + 2 Thinking deltas, then Done. - state.render( - Text(sse_id=SID, content="a"), - transcript=MagicMock(), tools_log=MagicMock(), - debug_log=debug_log, thinking_log=MagicMock(), raw=False, - ) - state.render( - Text(sse_id=SID, content="bc"), - transcript=MagicMock(), tools_log=MagicMock(), - debug_log=debug_log, thinking_log=MagicMock(), raw=False, - ) - state.render( - Thinking(sse_id=SID, content="thought\n"), - transcript=MagicMock(), tools_log=MagicMock(), - debug_log=debug_log, thinking_log=MagicMock(), raw=False, - ) - state.render( - _make_tui_done(), - transcript=MagicMock(), tools_log=MagicMock(), - debug_log=debug_log, thinking_log=MagicMock(), raw=False, - ) - writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list] - summary = next(w for w in writes if "turn_summary" in w) - assert "text_deltas=2" in summary - assert "text_bytes=3" in summary # "a" + "bc" - assert "thinking_deltas=1" in summary - assert "elapsed_ms=" in summary - - -def _text_of(write_arg: object) -> str: - """Extract plain text from a RichLog.write() arg (str or rich.text.Text). - - Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")` - so the RichLog can apply dim styling; non-demoted writes stay as plain str. - Tests that want to assert against content need both shapes flattened. - - v0.9.0: also extracts plain text from Markdown wrappers (the streaming-text - response path uses Markdown(buffer) now; tests assert against the source - markup, which lives in `Markdown.markup`). - """ - from rich.markdown import Markdown - from rich.text import Text as RichText - - if isinstance(write_arg, RichText): - return write_arg.plain - if isinstance(write_arg, Markdown): - return write_arg.markup - if isinstance(write_arg, str): - return write_arg - return "" # Rule / etc. — not text content - - -def _mounted_renderables(transcript_mock: MagicMock) -> list: - """v0.9.0: TuiPresenterState now mounts Static widgets into the transcript - VerticalScroll instead of writing renderables to a RichLog. Tests using a - MagicMock transcript inspect `transcript.mount.call_args_list`; each call's - first positional arg is the Static child whose `.content` carries the - Markdown / RichText / str that pre-v0.9.0 would have been the write arg. - Returns those renderables in mount-call order so tests can assert on them - with the same shape they used for `log.write.call_args_list` previously. - """ - out: list = [] - for call in transcript_mock.mount.call_args_list: - for child in call.args: - renderable = getattr(child, "content", child) - out.append(renderable) - return out - - -def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done: - return Done( - sse_id=SID, - phase="succeeded", - response="r", - model="m", - duration_ms=duration_ms, - usage=usage - if usage is not None - else { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "cached_input_tokens": 0, - }, - ) - - -class TestCancelViaSse: - @respx.mock - async def test_happy_cancel(self) -> None: - """happy_cancel [happy,tracer]: 200 OK → returns None; transcript has no [cancel_failed].""" - respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( - return_value=httpx.Response(200, json=_CANCEL_OK_RESP) - ) - transcript = MagicMock() - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await _cancel_via_sse(client, "s-1", 42, transcript=transcript) - assert result is None - transcript.mount.assert_not_called() - - @respx.mock - async def test_cancel_failed_500(self) -> None: - """cancel_failed_500 [error]: …""" - respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( - return_value=httpx.Response(500, content=b"boom") - ) - transcript = MagicMock() - async with httpx.AsyncClient(base_url="https://w.example") as client: - await _cancel_via_sse(client, "s-1", 42, transcript=transcript) - line = transcript.mount.call_args[0][0].content - assert "[cancel_failed]" in line - assert "CancelFailed" in line - - @respx.mock - async def test_cancel_already_completed(self) -> None: - """cancel_already_completed [scenario]: 409 → '[cancel_failed] CancelAlreadyCompleted:'.""" - respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( - return_value=httpx.Response(409) - ) - transcript = MagicMock() - async with httpx.AsyncClient(base_url="https://w.example") as client: - await _cancel_via_sse(client, "s-1", 42, transcript=transcript) - line = transcript.mount.call_args[0][0].content - assert "[cancel_failed]" in line - assert "CancelAlreadyCompleted" in line - - @respx.mock - async def test_transport_error_swallowed(self) -> None: - """transport_error_swallowed [error]: …""" - respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( - side_effect=httpx.ConnectError("network down") - ) - transcript = MagicMock() - async with httpx.AsyncClient(base_url="https://w.example") as client: - await _cancel_via_sse(client, "s-1", 42, transcript=transcript) - line = transcript.mount.call_args[0][0].content - assert "[cancel_failed]" in line - assert "ConnectError" in line - - @respx.mock - async def test_audit_callback_records_lifecycle(self) -> None: - """audit_callback_records_lifecycle [v0.10.0]: when the caller passes - an `audit` callback, _cancel_via_sse emits two lines on the happy - path (`cancel_post issued …` + `cancel_post ok …`) and two lines on - the failure path (`issued` + `failed …`). Gives the debug pane a - complete cancel-POST timeline. - """ - respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( - return_value=httpx.Response(200, json=_CANCEL_OK_RESP) - ) - transcript = MagicMock() - audit_lines: list = [] - async with httpx.AsyncClient(base_url="https://w.example") as client: - await _cancel_via_sse( - client, "s-1", 42, transcript=transcript, audit=audit_lines.append - ) - assert len(audit_lines) == 2 - assert audit_lines[0].startswith("cancel_post issued ") - assert "session_id=s-1" in audit_lines[0] - assert "turn_id=42" in audit_lines[0] - assert audit_lines[1] == "cancel_post ok turn_id=42" - - @respx.mock - async def test_audit_callback_records_failure(self) -> None: - """audit_callback_records_failure [v0.10.0]: failure path emits - `cancel_post issued` then `cancel_post failed …` with exception type. - """ - respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( - return_value=httpx.Response(500, content=b"boom") - ) - transcript = MagicMock() - audit_lines: list = [] - async with httpx.AsyncClient(base_url="https://w.example") as client: - await _cancel_via_sse( - client, "s-1", 42, transcript=transcript, audit=audit_lines.append - ) - assert len(audit_lines) == 2 - assert audit_lines[0].startswith("cancel_post issued ") - assert audit_lines[1].startswith("cancel_post failed turn_id=42 CancelFailed") - - -class TestPersonaFormatters: - """v0.13.0 — _format_persona_header / _format_persona_detail rendering.""" - - def test_header_compact_summary(self) -> None: - """header_compact_summary: agent_id · dominant_emotion · pad(P,A,D) · N emotions.""" - from ratatoskr.tui import _format_persona_header - - snapshot = { - "agent_id": "mimir", - "dominant_emotion": "curiosity", - "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, - "emotions_active": [ - {"type": "curiosity"}, {"type": "joy"}, - ], - } - line = _format_persona_header(snapshot) - assert "mimir" in line - assert "curiosity" in line - assert "pad(0.52, 0.47, 0.5)" in line - assert "2 emotions active" in line - - def test_header_singular_emotion(self) -> None: - """header_singular_emotion: single emotion → '1 emotion active' (no 's').""" - from ratatoskr.tui import _format_persona_header - - line = _format_persona_header( - { - "agent_id": "mimir", - "dominant_emotion": "calm", - "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, - "emotions_active": [{"type": "calm"}], - } - ) - assert "1 emotion active" in line - assert "1 emotions" not in line - - def test_header_no_emotions_drops_count(self) -> None: - """header_no_emotions_drops_count: empty emotions list → no count suffix.""" - from ratatoskr.tui import _format_persona_header - - line = _format_persona_header( - { - "agent_id": "mimir", - "dominant_emotion": "?", - "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, - "emotions_active": [], - } - ) - assert "emotion" not in line # neither "1 emotion" nor "N emotions" - - def test_detail_renders_full_snapshot(self) -> None: - """detail_renders_full_snapshot: PAD axes + baseline + delta + drift + - emotions + last_updated_at all surface in the multi-line render. - """ - from ratatoskr.tui import _format_persona_detail - - snapshot = { - "agent_id": "mimir", - "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, - "dominant_emotion": "curiosity", - "emotions_active": [ - {"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7} - ], - "baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50}, - "mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07}, - "last_updated_at": "2026-05-25T22:30:18+00:00", - } - detail = _format_persona_detail(snapshot) - assert "Persona snapshot · mimir" in detail - assert "Dominant emotion: curiosity" in detail - assert "pleasure" in detail - assert "baseline 0.5" in detail - assert "+0.02" in detail or "0.02" in detail - assert "valence_delta" in detail - assert "curiosity" in detail - assert "intensity 0.6" in detail - assert "decay 3.4m" in detail - assert "2026-05-25T22:30:18+00:00" in detail - - -class TestPresenterPersonaCallback: - """v0.13.0 — presenter wires AffectUpdate snapshots into a callback.""" - - def test_current_invokes_callback_with_snapshot(self) -> None: - """current_invokes_callback_with_snapshot: AffectUpdate(current, snapshot) - calls on_persona_snapshot(snapshot).""" - from ratatoskr.sse_client import AffectUpdate - from ratatoskr.tui import TuiPresenterState - - captured: list = [] - state = TuiPresenterState() - snapshot = {"agent_id": "mimir", "pad": {"pleasure": 0.5}} - state.render( - AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - on_persona_snapshot=captured.append, - ) - assert captured == [snapshot] - - def test_scheduled_does_not_invoke_callback(self) -> None: - """scheduled_does_not_invoke_callback: status=scheduled has no snapshot, - so the callback is skipped (would be called with None otherwise).""" - from ratatoskr.sse_client import AffectUpdate - from ratatoskr.tui import TuiPresenterState - - captured: list = [] - state = TuiPresenterState() - state.render( - AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=MagicMock(), - thinking_log=MagicMock(), - raw=False, - on_persona_snapshot=captured.append, - ) - assert captured == [] - - def test_callback_exception_swallowed(self) -> None: - """callback_exception_swallowed: a raising callback does NOT crash the - presenter — the audit line still landed (it precedes the callback). - """ - from ratatoskr.sse_client import AffectUpdate - from ratatoskr.tui import TuiPresenterState - - def boom(_snap: dict) -> None: - raise RuntimeError("widget tearing down") - - debug_log = MagicMock() - state = TuiPresenterState() - # Should NOT raise - state.render( - AffectUpdate( - sse_id=SID, status="current", turn_id=42, snapshot={"agent_id": "x"} - ), - transcript=MagicMock(), - tools_log=MagicMock(), - debug_log=debug_log, - thinking_log=MagicMock(), - raw=False, - on_persona_snapshot=boom, - ) - # Audit line still emitted (it runs before the callback) - assert debug_log.write.called - - -class TestAppMount: - """on_mount narrows per issue #6: only identity-widget population. - - Session resolution + AsyncClient open + error-on-resolve are exercised at - the `_resolve_then_run` layer (see TestResolveThenRun); only happy mount - paths remain here, exercised with pre-resolved state via _resolved_app. - """ - - async def test_happy_new_session_mount(self) -> None: - """happy_new_session_mount [happy,tracer]: identity populated from pre-resolved state.""" - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - assert app.session_id == "s-new12345" - assert app.agent_id == "mimir" - assert app.state == "idle" - # INV-002: session identity visible — agent_id + last 8 of session_id - assert "mimir" in (app.sub_title or "") - assert app.session_id[-8:] in (app.sub_title or "") - - async def test_happy_existing_session_mount(self) -> None: - """happy_existing_session_mount: identity shows when agent_id is None.""" - app = _resolved_app(_args_existing(session_id="s-existing-tail8x")) - async with app.run_test() as pilot: - await pilot.pause() - assert app.session_id == "s-existing-tail8x" - assert app.state == "idle" - # INV-002 carve-out: agent unknown → · … - assert "" in (app.sub_title or "") - assert app.session_id[-8:] in (app.sub_title or "") - - async def test_footer_identity_visible_first_frame(self) -> None: - """footer_identity_visible_first_frame [trace]: identity widget rendered first frame.""" - from textual.widgets import Static - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - identity_widget = app.query_one("#identity", Static) - rendered = str(identity_widget.render()) - assert "mimir" in rendered - assert "·" in rendered - assert app.session_id[-8:] in rendered - - -# Issue #13 — TUI layout reshape + Tools pane (§5 v1 entry point) - - -class TestLayoutShape: - """INV-013 + INV-014 + INV-017: Horizontal two-column layout with Tools tab.""" - - async def test_main_row_is_horizontal(self) -> None: - """main_row_is_horizontal [tracer]: compose() yields Horizontal#main-row.""" - from textual.containers import Horizontal - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - row = app.query_one("#main-row", Horizontal) - assert row is not None - - async def test_left_column_content_only(self) -> None: - """left_column_content_only [v0.9.0]: left column = transcript-scroll - VerticalScroll + prompt Input. thinking-current Static removed in - v0.6.5; transcript RichLog replaced by VerticalScroll in v0.9.0. - """ - from textual.containers import Vertical, VerticalScroll - from textual.widgets import Input - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - left = app.query_one("#left-column", Vertical) - transcript = app.query_one("#transcript-scroll", VerticalScroll) - prompt = app.query_one("#prompt", Input) - assert transcript in left.walk_children() - assert prompt in left.walk_children() - # v0.6.5: thinking-current Static removed; no longer in DOM at all. - from textual.css.query import NoMatches - try: - app.query_one("#thinking-current") - raise AssertionError("thinking-current should not exist in v0.6.5") - except NoMatches: - pass # expected - - async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None: - """right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab.""" - from textual.widgets import TabbedContent, TabPane - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - tabbed = app.query_one("#side-panes", TabbedContent) - assert tabbed is not None - tools_tab = app.query_one("#tools-tab", TabPane) - assert tools_tab is not None - - async def test_tools_log_inside_tools_tab(self) -> None: - """tools_log_inside_tools_tab: tools-transcript RichLog is a descendant of tools-tab TabPane.""" - from textual.widgets import RichLog, TabPane - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - tools_tab = app.query_one("#tools-tab", TabPane) - tools_log = app.query_one("#tools-log", RichLog) - assert tools_log in tools_tab.walk_children() - - async def test_pane_name_widget_renders_tools(self) -> None: - """pane_name_widget_renders_tools [INV-pane-name]: #pane-name == 'Tools' on first frame.""" - from textual.widgets import Static - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - pane_name = app.query_one("#pane-name", Static) - rendered = str(pane_name.render()) - assert rendered == "Tools" - - async def test_ctrl_1_activates_tools_tab(self) -> None: - """ctrl_1_activates_tools_tab [tracer]: Ctrl+1 → TabbedContent.active == 'tools-tab'.""" - from textual.widgets import TabbedContent - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("ctrl+1") - await pilot.pause() - tabbed = app.query_one("#side-panes", TabbedContent) - assert tabbed.active == "tools-tab" - - async def test_ctrl_1_preserves_input_focus(self) -> None: - """ctrl_1_preserves_input_focus [INV-016]: Ctrl+1 does NOT steal focus from Input.""" - from textual.widgets import Input - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - prompt = app.query_one("#prompt", Input) - prompt.focus() - await pilot.pause() - assert app.focused is prompt - await pilot.press("ctrl+1") - await pilot.pause() - assert app.focused is prompt, ( - f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}" - ) - - async def test_debug_tab_exists(self) -> None: - """debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-transcript RichLog.""" - from textual.widgets import RichLog, TabPane - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - debug_tab = app.query_one("#debug-tab", TabPane) - debug_log = app.query_one("#debug-log", RichLog) - assert debug_log in debug_tab.walk_children() - - async def test_ctrl_2_activates_debug_tab(self) -> None: - """ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'.""" - from textual.widgets import TabbedContent - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("ctrl+2") - await pilot.pause() - assert app.query_one("#side-panes", TabbedContent).active == "debug-tab" - - async def test_persona_tab_exists(self) -> None: - """persona_tab_exists [v0.13.0]: right column has Persona TabPane + - #persona-log RichLog as descendant. - """ - from textual.widgets import RichLog, TabPane - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - persona_tab = app.query_one("#persona-tab", TabPane) - persona_log = app.query_one("#persona-log", RichLog) - assert persona_log in persona_tab.walk_children() - - async def test_ctrl_4_activates_persona_tab(self) -> None: - """ctrl_4_activates_persona_tab [v0.13.0]: Ctrl+4 → active == 'persona-tab'.""" - from textual.widgets import TabbedContent - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("ctrl+4") - await pilot.pause() - assert app.query_one("#side-panes", TabbedContent).active == "persona-tab" - - async def test_persona_header_starts_empty(self) -> None: - """persona_header_starts_empty [v0.13.0]: sticky persona-header widget - exists, starts hidden (height collapsed via .empty class) until - hydration succeeds. - """ - from textual.widgets import Static - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - header = app.query_one("#persona-header", Static) - assert "empty" in header.classes - - async def test_done_label_styled_success(self) -> None: - """done_label_styled_success [v0.9.0]: [done] label mounts as Static - carrying a RichText with Aurora green style. Inspect the mounted - Static's `.content`. - """ - from rich.text import Text as RichText - from textual.containers import VerticalScroll - from textual.widgets import RichLog - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - from ratatoskr.tui import TuiPresenterState - transcript = app.query_one("#transcript-scroll", VerticalScroll) - state = TuiPresenterState() - mounted: list = [] - orig_mount = transcript.mount - - def spy_mount(*ch, **kw): - mounted.extend(ch) - return orig_mount(*ch, **kw) - - transcript.mount = spy_mount # type: ignore[method-assign] - state.render( - _make_tui_done(), - transcript=transcript, - tools_log=app.query_one("#tools-log", RichLog), - debug_log=app.query_one("#debug-log", RichLog), - thinking_log=MagicMock(), - raw=True, - ) - done = next( - w.content for w in mounted - if isinstance(getattr(w, "content", None), RichText) - and _text_of(w.content).startswith("[done]") - ) - assert done.style == "#16B866" # Aurora green - - async def test_empty_state_placeholders_present(self) -> None: - """empty_state_placeholders_present [v0.5.1]: tools-transcript + debug-transcript show - placeholder lines before any turn fires.""" - from textual.widgets import RichLog - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - # Activate Debug tab so its content actually renders. - from textual.widgets import TabbedContent - tabbed = app.query_one("#side-panes", TabbedContent) - tabbed.active = "debug-tab" - await pilot.pause() - tabbed.active = "tools-tab" - await pilot.pause() - tools_log = app.query_one("#tools-log", RichLog) - debug_log = app.query_one("#debug-log", RichLog) - tools_text = " ".join(str(line) for line in tools_log.lines) - tabbed.active = "debug-tab" - await pilot.pause() - debug_text = " ".join(str(line) for line in debug_log.lines) - assert "no tool events" in tools_text - assert "worker_phase" in debug_text - - async def test_pane_name_updates_on_tab_switch(self) -> None: - """pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab. - - Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug" - and back as the operator switches via Ctrl+1 / Ctrl+2. - """ - from textual.widgets import Static - - app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") - async with app.run_test() as pilot: - await pilot.pause() - pane_name = app.query_one("#pane-name", Static) - await pilot.press("ctrl+2") - await pilot.pause() - assert str(pane_name.render()) == "Debug" - await pilot.press("ctrl+1") - await pilot.pause() - assert str(pane_name.render()) == "Tools" - - -import asyncio # noqa: E402 - -from textual.widgets import Input # noqa: E402 - - -async def _noop_worker(self, content: str) -> None: - """Fake _stream_turn_worker that never completes (lets state stay 'streaming').""" - await asyncio.Future() # await forever; cancelled when test exits - - -class TestOnInputSubmitted: - @respx.mock - async def test_happy_submit_echoes_and_spawns(self, monkeypatch: pytest.MonkeyPatch) -> None: - """happy_submit_echoes_and_spawns [happy,tracer]: …""" - monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - inp.value = "hello" - await inp.action_submit() - await pilot.pause() - assert any("❯ hello" in str(w) for w in writes) # noqa: RUF001 - assert inp.value == "" - assert app.state == "streaming" - assert app.stream_worker is not None - - @respx.mock - async def test_empty_submit_no_op(self, monkeypatch: pytest.MonkeyPatch) -> None: - """empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned.""" - monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - # Spy AFTER mount so identity-widget writes (if any) aren't counted. - writes = _spy_writes(monkeypatch) - inp = app.query_one("#prompt", Input) - inp.value = "" - await inp.action_submit() - await pilot.pause() - assert app.state == "idle" - assert app.stream_worker is None - # POST: no RichLog write fires on empty submit - assert writes == [] - - @respx.mock - async def test_submit_during_streaming_shows_busy_notice( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """submit_during_streaming_shows_busy_notice [adversarial]: …""" - monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - # First submit: enters streaming - inp.value = "first" - await inp.action_submit() - await pilot.pause() - first_worker = app.stream_worker - assert app.state == "streaming" - # Second submit while streaming → busy notice; no new worker - writes.clear() - inp.value = "second" - await inp.action_submit() - await pilot.pause() - assert any("[busy] turn in flight; input ignored" in str(w) for w in writes) - assert app.stream_worker is first_worker # unchanged - assert app.state == "streaming" - assert inp.value == "" - - @respx.mock - async def test_submit_during_cancelling_shows_busy_notice( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """submit_during_cancelling_shows_busy_notice [adversarial]: …""" - monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - app.state = "cancelling" # bypass the natural transition for the test - assert app.stream_worker is None # no live worker before non-idle submit - inp = app.query_one("#prompt", Input) - inp.value = "x" - await inp.action_submit() - await pilot.pause() - assert any("[busy]" in str(w) for w in writes) - assert app.state == "cancelling" - # POST-005 (from issue #4 on_input_submitted contract): - # input cleared; NO new worker spawned during non-idle submit. - assert inp.value == "" - assert app.stream_worker is None - - @respx.mock - async def test_footer_hint_flips_to_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None: - """footer_hint_flips_to_cancel [trace]: hint widget shows 'Ctrl-C to cancel'.""" - from textual.widgets import Static - - monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - hint_widget = app.query_one("#hint", Static) - assert str(hint_widget.render()) == RatatoskrApp.HINT_IDLE - inp = app.query_one("#prompt", Input) - inp.value = "hi" - await inp.action_submit() - await pilot.pause() - assert str(hint_widget.render()) == RatatoskrApp.HINT_STREAMING - - -import json # noqa: E402 - - -def _sse_chunk(sse_id: str, body: dict) -> bytes: - return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode() - - -_DONE_BODY = { - "type": "done", - "phase": "succeeded", - "response": "hello", - "model": "m", - "duration_ms": 1, - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "cached_input_tokens": 0, - }, -} -_CANCELLED_STREAM_BODY = { - "type": "cancelled", - "phase": "cancelled", - "turn_id": 42, - "reason": "user_cancel", - "partial_message_id": None, -} - - -def _sse_resp(body: bytes | httpx.AsyncByteStream) -> httpx.Response: - headers = {"content-type": "text/event-stream"} - if isinstance(body, bytes): - return httpx.Response(200, headers=headers, content=body) - return httpx.Response(200, headers=headers, stream=body) - - -async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None: - """Type content into the input and submit; wait for worker to finish.""" - inp = app.query_one("#prompt", Input) - inp.value = content - await inp.action_submit() - await pilot.pause() # let the Input.Submitted message dispatch - # Poll until the worker resolves (state returns to idle) - for _ in range(100): - if app.state == "idle" and app.stream_worker is not None: - return - await pilot.pause(0.02) - - -class TestStreamTurnWorker: - @respx.mock - async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None: - """happy_text_done_no_double_print [happy,tracer, v0.9.0]: - Text("hello") mounts a Static(Markdown("hello")) into the transcript; - Done mounts a [done] label Static. The Markdown is rendered live (one - widget for the whole stream, updated in place), so there is NO - post-Done re-render — exactly ONE Markdown renderable lands in the - transcript for the response body. v0.9.0 supersedes v0.8.2's - drop-Markdown patch with proper live rendering. - """ - stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk( - "42:2", _DONE_BODY - ) - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(stream) - ) - - writes = _spy_writes(monkeypatch) - - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "hi") - assert app.state == "idle" - from rich.markdown import Markdown - - # The response body lives as ONE Markdown renderable mounted into - # the transcript; live updates happen via Static.update, not via - # re-mount, so there's exactly one Markdown in the spy stream. - markdowns = [w for w in writes if isinstance(w, Markdown)] - assert len(markdowns) == 1, ( - f"v0.9.0: expected exactly ONE Markdown mounted, got {len(markdowns)}" - ) - assert markdowns[0].markup == "hello" - # [done] label fires too. - assert any("[done]" in _text_of(w) for w in writes) - - @respx.mock - async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None: - """raw_flag_skips_markdown_render [trace, v0.9.0]: - With --raw, the response widget holds plain str instead of Markdown. - Turn-header markers still appear in every pane: 3 RichLog panes - receive a Rule, the transcript-scroll receives a Static-wrapped - RichText (mounted, not written), giving 3 Rules in the captured - writes list. - """ - stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk( - "42:2", _DONE_BODY - ) - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(stream) - ) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing(raw=True)) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "x") - from rich.markdown import Markdown - from rich.rule import Rule - - # No Markdown in raw mode. - assert not any(isinstance(w, Markdown) for w in writes) - # 3 Rules — one per RichLog pane (tools / debug / thinking). - # Transcript-scroll uses a Static turn-header Markdown alternative. - rules = [w for w in writes if isinstance(w, Rule)] - assert len(rules) == 3, f"expected 3 turn-header Rules, got {len(rules)}" - # Accumulated text "hi" mounted as plain str into transcript. - assert "hi" in writes - - @respx.mock - async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None: - """error_terminal_returns_to_idle [happy]: …""" - stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk( - "42:2", - { - "type": "error", - "phase": "failed", - "error_code": "llm_output_invalid", - "message": "boom", - }, - ) - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(stream) - ) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "x") - assert app.state == "idle" - assert any("[error]" in str(w) for w in writes) - - @respx.mock - async def test_cancelled_terminal_returns_to_idle( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """cancelled_terminal_returns_to_idle [happy]: …""" - stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk( - "42:2", _CANCELLED_STREAM_BODY - ) - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(stream) - ) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "x") - assert app.state == "idle" - assert any("[cancelled]" in str(w) for w in writes) - - @respx.mock - async def test_active_turn_id_set_on_first_event(self, monkeypatch: pytest.MonkeyPatch) -> None: - """active_turn_id_set_on_first_event [trace]: …""" - # Use a gated stream: yield first event, then hold, so we can inspect mid-stream - first = _sse_chunk("42:1", {"type": "text", "content": "x"}) - gate = asyncio.Event() - - class _GatedAfterFirst(httpx.AsyncByteStream): - async def __aiter__(self): - yield first - await gate.wait() - yield _sse_chunk("42:2", _DONE_BODY) - - async def aclose(self) -> None: - return None - - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(_GatedAfterFirst()) - ) - - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - inp.value = "x" - await inp.action_submit() - # Wait for first event to be processed (active_turn_id set) - for _ in range(50): - if app.active_turn_id is not None: - break - await pilot.pause(0.02) - assert app.active_turn_id == 42 - # Release the gate so the worker can finish and the app can shut down cleanly - gate.set() - for _ in range(50): - if app.state == "idle": - break - await pilot.pause(0.02) - - @respx.mock - async def test_sse_connect_failed_returns_to_idle( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """sse_connect_failed_returns_to_idle [error]: …""" - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=httpx.Response(404, json={"error": "session_not_found"}) - ) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "x") - assert app.state == "idle" - assert any("[sse_connect_failed]" in str(w) for w in writes) - assert app.return_value is None # app NOT exited per INV-008 - - @respx.mock - async def test_connection_dropped_returns_to_idle( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """connection_dropped_returns_to_idle [error]: …""" - - class _DropAfter(httpx.AsyncByteStream): - async def __aiter__(self): - yield _sse_chunk("42:1", {"type": "text", "content": "x"}) - raise httpx.RemoteProtocolError("drop") - - async def aclose(self) -> None: - return None - - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(_DropAfter()) - ) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "x") - assert app.state == "idle" - assert any("[connection_dropped]" in str(w) for w in writes) - - @respx.mock - async def test_malformed_sse_data_returns_to_idle( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """malformed_sse_data_returns_to_idle [error]: bad-JSON → [malformed_sse_data]; idle.""" - stream = ( - _sse_chunk("42:1", {"type": "text", "content": "x"}) + b"id: 42:2\ndata: not-json\n\n" - ) - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(stream) - ) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "x") - assert app.state == "idle" - assert any("[malformed_sse_data]" in str(w) for w in writes) - assert any("not-json" in str(w) for w in writes) - # INV-008: mid-session error does NOT exit the app - assert app.return_value is None - - @respx.mock - async def test_rendered_event_per_event(self, monkeypatch: pytest.MonkeyPatch) -> None: - """rendered_event_per_event [trace]: …""" - chunks = ( - _sse_chunk("42:1", {"type": "worker_phase", "phase": "streaming", "turn_id": 42}) - + _sse_chunk("42:2", {"type": "text", "content": "hi"}) - + _sse_chunk("42:3", _DONE_BODY) - ) - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(chunks) - ) - - # Per issue #12: rendering went from stateless _render_event_to_log to - # TuiPresenterState.render; the spy moves to the new method. - from ratatoskr.tui import TuiPresenterState - - call_count = 0 - original = TuiPresenterState.render - - def spy(self, event, **kw): # type: ignore[no-untyped-def] - nonlocal call_count - call_count += 1 - return original(self, event, **kw) - - monkeypatch.setattr(TuiPresenterState, "render", spy) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await _submit_and_wait(app, pilot, "x") - assert call_count == 3 - - -class TestActionInterrupt: - @respx.mock - async def test_idle_ctrl_c_exits_zero(self) -> None: - """idle_ctrl_c_exits_zero [happy,tracer]: state=idle; ctrl+c → exit(0).""" - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - assert app.state == "idle" - await pilot.press("ctrl+c") - await pilot.pause() - assert app.return_value == 0 - - @respx.mock - async def test_streaming_first_ctrl_c_cancels(self, monkeypatch: pytest.MonkeyPatch) -> None: - """streaming_first_ctrl_c_cancels [scenario,tracer]: …""" - # Stream that yields one text event (sets active_turn_id) then waits forever - first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"}) - gate = asyncio.Event() - - class _GatedAfterFirst(httpx.AsyncByteStream): - async def __aiter__(self): - yield first_chunk - await gate.wait() - - async def aclose(self) -> None: - return None - - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(_GatedAfterFirst()) - ) - cancel_observed = asyncio.Event() - - def cancel_handler(req: httpx.Request) -> httpx.Response: - cancel_observed.set() - return httpx.Response(200, json=_CANCEL_OK_RESP) - - cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock( - side_effect=cancel_handler - ) - - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - inp.value = "go" - await inp.action_submit() - await pilot.pause() - # Wait for active_turn_id to be set (first event consumed) - for _ in range(50): - if app.active_turn_id == 42: - break - await pilot.pause(0.02) - assert app.active_turn_id == 42 - assert app.state == "streaming" - await pilot.press("ctrl+c") - # Wait for cancel POST to land - for _ in range(50): - if cancel_observed.is_set(): - break - await pilot.pause(0.02) - assert cancel_route.call_count == 1 - assert app.state == "cancelling" - from textual.widgets import Static - - hint_widget = app.query_one("#hint", Static) - assert str(hint_widget.render()) == RatatoskrApp.HINT_CANCELLING - # Release the gate so the stream worker can finish cleanly during teardown - gate.set() - - @respx.mock - async def test_streaming_no_turn_id_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None: - """streaming_no_turn_id_force_exits [scenario]: …""" - cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/0/cancel").mock( - return_value=httpx.Response(200, json=_CANCEL_OK_RESP) - ) - # Stream that hangs forever (no events to set active_turn_id) - gate = asyncio.Event() - - class _NeverYields(httpx.AsyncByteStream): - async def __aiter__(self): - await gate.wait() - if False: - yield b"" - - async def aclose(self) -> None: - return None - - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(_NeverYields()) - ) - - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - inp.value = "go" - await inp.action_submit() - await pilot.pause() - assert app.state == "streaming" - assert app.active_turn_id is None - # Capture worker reference + spy on its .cancel() before ctrl+c - worker_ref = app.stream_worker - assert worker_ref is not None - cancel_calls: list = [] - original_cancel = type(worker_ref).cancel - monkeypatch.setattr( - type(worker_ref), - "cancel", - lambda self: (cancel_calls.append(self), original_cancel(self))[-1], - ) - await pilot.press("ctrl+c") - await pilot.pause() - gate.set() # let the gated stream resolve so teardown is clean - assert app.return_value == 3 - assert cancel_route.call_count == 0 - # action_interrupt MUST cancel the stream worker on the no-active_turn_id force-exit path - assert worker_ref in cancel_calls - - @respx.mock - async def test_cancelling_second_ctrl_c_force_exits( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """cancelling_second_ctrl_c_force_exits [scenario]: …""" - # Set up a real live stream worker (gated, hangs forever) so we can - # observe action_interrupt's cancel() call on the second-Ctrl-C path. - monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - inp.value = "go" - await inp.action_submit() - await pilot.pause() - assert app.stream_worker is not None - app.state = "cancelling" # bypass the natural transition for the test - worker_ref = app.stream_worker - cancel_calls: list = [] - original_cancel = type(worker_ref).cancel - monkeypatch.setattr( - type(worker_ref), - "cancel", - lambda self: (cancel_calls.append(self), original_cancel(self))[-1], - ) - await pilot.press("ctrl+c") - await pilot.pause() - assert app.return_value == 3 - # Second-Ctrl-C in cancelling state MUST cancel the in-flight worker - assert worker_ref in cancel_calls - - @respx.mock - async def test_cancel_failed_swallowed(self) -> None: - """cancel_failed_swallowed [scenario]: …""" - first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"}) - stream_gate = asyncio.Event() - - class _GatedAfterFirst(httpx.AsyncByteStream): - async def __aiter__(self): - yield first_chunk - await stream_gate.wait() - - async def aclose(self) -> None: - return None - - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(_GatedAfterFirst()) - ) - cancel_observed = asyncio.Event() - - def cancel_handler(req: httpx.Request) -> httpx.Response: - cancel_observed.set() - return httpx.Response(500, content=b"boom") - - respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock( - side_effect=cancel_handler - ) - - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - inp.value = "go" - await inp.action_submit() - await pilot.pause() - for _ in range(50): - if app.active_turn_id == 42: - break - await pilot.pause(0.02) - await pilot.press("ctrl+c") - for _ in range(50): - if cancel_observed.is_set(): - break - await pilot.pause(0.02) - # Give _cancel_via_sse time to write the [cancel_failed] line - await pilot.pause(0.05) - from textual.containers import VerticalScroll - from textual.widgets import Static - transcript = app.query_one("#transcript-scroll", VerticalScroll) - rendered = "\n".join( - str(child.content) - for child in transcript.children - if isinstance(child, Static) - ) - assert "[cancel_failed]" in rendered - assert app.state == "cancelling" - stream_gate.set() # let stream finish for teardown - - -class TestActionQuit: - @respx.mock - async def test_idle_ctrl_d_exits_zero(self) -> None: - """idle_ctrl_d_exits_zero [happy,tracer]: state=idle; ctrl+d → exit(0).""" - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("ctrl+d") - await pilot.pause() - assert app.return_value == 0 - - @respx.mock - async def test_streaming_ctrl_d_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None: - """streaming_ctrl_d_force_exits [scenario]: …""" - cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock( - return_value=httpx.Response(200, json=_CANCEL_OK_RESP) - ) - first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"}) - gate = asyncio.Event() - - class _GatedAfterFirst(httpx.AsyncByteStream): - async def __aiter__(self): - yield first_chunk - await gate.wait() - - async def aclose(self) -> None: - return None - - respx.post("https://w.example/sessions/s-1existing/messages").mock( - return_value=_sse_resp(_GatedAfterFirst()) - ) - app = _resolved_app(_args_existing()) - async with app.run_test() as pilot: - await pilot.pause() - inp = app.query_one("#prompt", Input) - inp.value = "go" - await inp.action_submit() - await pilot.pause() - for _ in range(50): - if app.active_turn_id == 42: - break - await pilot.pause(0.02) - # Capture worker + spy on cancel before ctrl+d - worker_ref = app.stream_worker - assert worker_ref is not None - cancel_calls: list = [] - original_cancel = type(worker_ref).cancel - monkeypatch.setattr( - type(worker_ref), - "cancel", - lambda self: (cancel_calls.append(self), original_cancel(self))[-1], - ) - await pilot.press("ctrl+d") - await pilot.pause() - gate.set() - assert app.return_value == 0 - assert cancel_route.call_count == 0 - # POST-002: Ctrl-D MUST cancel the in-flight stream worker (abandon-and-exit) - assert worker_ref in cancel_calls - - -from ratatoskr.tui import run_tui # noqa: E402 - - -class TestResolveThenRun: - """Tests at the `_resolve_then_run` layer — pre-`App.run()` session - resolution + AsyncClient ownership + stderr error routing per issue #6. - """ - - @respx.mock - def test_happy_new_session_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None: - """happy_new_session_resolve [happy]: --new path through _resolve_then_run. - - Verifies POST /sessions count + SessionInfo propagation to RatatoskrApp's - pre-resolved state (session_id / agent_id / client). The corresponding - TestAppMount.test_happy_new_session_mount uses _resolved_app and bypasses - _resolve_then_run entirely; this test exercises the production resolve - path with a real POST /sessions mock. - """ - sessions_route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(201, json=_CREATE_OK_RESP) - ) - snapshot: dict = {} - - async def capture_run_async(self, *a, **kw): - snapshot["session_id"] = self.session_id - snapshot["agent_id"] = self.agent_id - snapshot["client"] = self.client - snapshot["client_open"] = not self.client.is_closed - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async) - rc = run_tui(_args_new()) - assert rc == 0 - # Exactly one POST /sessions invocation by _resolve_then_run - assert sessions_route.call_count == 1 - # SessionInfo fields propagated into the constructed App - assert snapshot["session_id"] == "s-new12345" - assert snapshot["agent_id"] == "mimir" - assert snapshot["client"] is not None - assert snapshot["client_open"] is True - - @respx.mock - def test_happy_new_with_end_user_id_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None: - """happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST body. - - Issue #5 amends #4: _resolve_then_run's create_session call now forwards - args.end_user_id (renamed from the contract's _mount target, since #6 - moved session resolution out of on_mount into _resolve_then_run). - """ - import json as _json - - sessions_route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(201, json=_CREATE_OK_RESP) - ) - - async def fake_run_async(self, *a, **kw): - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) - rc = run_tui(_args_new(end_user_id="alice")) - assert rc == 0 - assert sessions_route.call_count == 1 - body = _json.loads(sessions_route.calls[0].request.content) - assert body == {"agent_id": "mimir", "end_user_id": "alice"} - - @respx.mock - def test_user_agent_header_sent(self, monkeypatch: pytest.MonkeyPatch) -> None: - """user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent. - - Worldtree-dev (althing 2026-05-23) requested consumers send `User-Agent: - ratatoskr/ ()` so server logs can distinguish ratatoskr - traffic from other consumers. - """ - sessions_route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(201, json=_CREATE_OK_RESP) - ) - - async def fake_run_async(self, *a, **kw): - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) - rc = run_tui(_args_new()) - assert rc == 0 - ua = sessions_route.calls[0].request.headers["User-Agent"] - assert ua.startswith("ratatoskr/") - assert "vh@phasefinal.com" in ua - - @respx.mock - def test_alt_screen_never_opens_on_resolve_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """alt_screen_never_opens_on_resolve_error [trace]: 404 → run_tui=12; run_async unhit. - - Directly probes INV-001: session resolution failures MUST short-circuit - BEFORE the alt-screen opens. - """ - respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) - ) - sentinel_called = False - - async def sentinel(self, *a, **kw): - nonlocal sentinel_called - sentinel_called = True - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", sentinel) - rc = run_tui(_args_new()) - assert rc == 12 - assert not sentinel_called - - @respx.mock - def test_agent_not_found_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None: - """agent_not_found_on_resolve [error]: --new + 404 → stderr [agent_not_found]; exit 12.""" - respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) - ) - rc = run_tui(_args_new()) - err = capsys.readouterr().err - assert rc == 12 - assert "[agent_not_found]" in err - assert "agent_id=mimir" in err - - @respx.mock - def test_session_api_failed_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None: - """session_api_failed_on_resolve [error]: --new + 500 → [session_api_failed] stderr.""" - respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(500, content=b"server error") - ) - rc = run_tui(_args_new()) - err = capsys.readouterr().err - assert rc == 20 - assert "[session_api_failed]" in err - assert "status=500" in err - - @respx.mock - def test_network_error_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None: - """network_error_on_resolve [error]: --new + ConnectError → [network_error] stderr.""" - respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down")) - rc = run_tui(_args_new()) - err = capsys.readouterr().err - assert rc == 21 - assert "[network_error]" in err - assert "ConnectError" in err - - @respx.mock - def test_stderr_label_format_matches_cli(self, capsys: pytest.CaptureFixture[str]) -> None: - """stderr_label_format_matches_cli [trace]: cli._amain and _resolve_then_run produce - identical stderr lines for AgentNotFound (INV-006). - """ - # Re-fetch cli's ParsedArgs from the current module state — test_cli's - # `importlib.reload(ratatoskr.cli)` rebinds the class, so the top-of-file - # `from ratatoskr.cli import ParsedArgs` may now refer to a stale class. - from ratatoskr import cli as cli_mod - - respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) - ) - cli_args = cli_mod.ParsedArgs( - send_content="x", - session_id=None, - new=True, - agent_id="mimir", - api_key="k", - server_url="https://w.example", - raw=False, - ) - # Drive cli._amain's error path (--send mode) - cli_rc = asyncio.run(cli_mod._amain(cli_args)) - cli_err = capsys.readouterr().err - # Drive _resolve_then_run's error path (TUI mode); _args_new() uses the - # pre-reload ParsedArgs which still matches tui.run_tui's isinstance check. - tui_rc = run_tui(_args_new()) - tui_err = capsys.readouterr().err - # Same exit code, same verbatim stderr line. - assert cli_rc == 12 - assert tui_rc == 12 - assert cli_err == tui_err - assert cli_err == "[agent_not_found] agent_id=mimir\n" - - def test_client_open_after_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None: - """client_open_after_resolve [trace]: app.client is open at the time run_async runs.""" - snapshot: dict = {} - - async def capture_run_async(self, *a, **kw): - snapshot["client_is"] = self.client - snapshot["closed_during_run"] = self.client.is_closed - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async) - rc = run_tui(_args_existing()) - assert rc == 0 - assert snapshot["client_is"] is not None - assert snapshot["closed_during_run"] is False - - def test_client_lifetime_owned_by_run_tui(self, monkeypatch: pytest.MonkeyPatch) -> None: - """client_lifetime_owned_by_run_tui [trace]: open during run_async, closed after run_tui. - - Probes INV-002: the App is a consumer of an externally-owned client; - the async-with in run_tui closes it, not on_unmount. - """ - snapshot: dict = {} - - async def capture_run_async(self, *a, **kw): - # During run_async (the alt-screen lifetime) the client is open. - snapshot["client"] = self.client - snapshot["closed_during_run"] = self.client.is_closed - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async) - rc = run_tui(_args_existing()) - assert rc == 0 - client = snapshot["client"] - assert client is not None - # Open while the app was running; closed by run_tui's async-with after. - assert snapshot["closed_during_run"] is False - assert client.is_closed is True - - def test_run_tui_closes_client_on_app_exit(self, monkeypatch: pytest.MonkeyPatch) -> None: - """run_tui_closes_client_on_app_exit: async-with closes client after app.run_async ret.""" - seen_clients: list[httpx.AsyncClient] = [] - - async def fake_run_async(self, *a, **kw): - seen_clients.append(self.client) - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) - rc = run_tui(_args_existing()) - assert rc == 0 - assert len(seen_clients) == 1 - # After run_tui returns, the client should be closed by the async-with - assert seen_clients[0].is_closed - - async def test_on_unmount_does_not_close_client(self) -> None: - """on_unmount narrowed [trace]: probes INV-002 from the on_unmount side. - - The complementary check to test_client_lifetime_owned_by_run_tui (which - patches run_async and so never exercises on_unmount). Here we DO run the - real on_unmount via Pilot ctrl+d → app teardown, and assert the client - is still open afterward (close site is run_tui's async-with, which is - NOT entered in this Pilot-driven test). - """ - client = httpx.AsyncClient( - base_url="https://w.example", - headers={"Authorization": "Bearer k"}, - timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), - ) - app = RatatoskrApp( - _args_existing(), - session_id="s-1existing", - agent_id=None, - client=client, - ) - async with app.run_test() as pilot: - await pilot.pause() - assert client.is_closed is False - await pilot.press("ctrl+d") - await pilot.pause() - # After app.run_test() teardown, on_unmount has fired. Per INV-002 the - # client MUST still be open — only run_tui's async-with closes it. - assert client.is_closed is False - await client.aclose() # test-side cleanup - - -class TestRunTui: - def test_happy_returns_zero_on_quit(self, monkeypatch: pytest.MonkeyPatch) -> None: - """happy_returns_zero_on_quit [happy,tracer]: run_tui propagates app.run_async exit code.""" - captured: list[ParsedArgs] = [] - - async def fake_run_async(self, *a, **kw): - captured.append(self.args) - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) - rc = run_tui(_args_existing()) - assert rc == 0 - assert len(captured) == 1 - assert captured[0].send_content is None - - def test_precondition_send_content_none(self) -> None: - """precondition_send_content_none [adversarial]: …""" - bad_args = ParsedArgs( - send_content="x", # PRE-001 violation - session_id="s-1", - new=False, - agent_id=None, - api_key="k", - server_url="https://w.example", - raw=False, - ) - with pytest.raises(AssertionError): - run_tui(bad_args) - - -# ---- Issue #8: startup agent picker ---------------------------------------- - - -def _args_new_no_agent(**overrides) -> ParsedArgs: - """ParsedArgs for bare --new (no --agent) — TUI-mode picker entry.""" - base = dict( - send_content=None, - session_id=None, - new=True, - agent_id=None, # Issue #8: bare --new, picker drives the choice - api_key="k", - server_url="https://w.example", - raw=False, - ) - base.update(overrides) - return ParsedArgs(**base) - - -_AGENTS_RESP = [ - { - "agent_id": "mimir", - "name": "Mimir", - "description": "Keeper of the Well of Knowledge.", - }, - { - "agent_id": "lofn", - "name": "Lofn", - "description": "Mediator of secret affairs.", - }, -] - - -class TestAgentPickerApp: - def test_picker_renders_rows(self) -> None: - """picker_renders_rows: AgentPickerApp composes one ListItem per agent.""" - from textual.widgets import ListView - - from ratatoskr.sessions import AgentInfo - from ratatoskr.tui import AgentPickerApp - - agents = [ - AgentInfo( - agent_id="a", - name="A", - description="x", - version=None, - capabilities=[], - supported_models=[], - persona_traits={}, - ui_hints={}, - ), - AgentInfo( - agent_id="b", - name="B", - description="y", - version=None, - capabilities=[], - supported_models=[], - persona_traits={}, - ui_hints={}, - ), - ] - app = AgentPickerApp(agents) - - async def probe() -> None: - async with app.run_test() as pilot: - lv = app.query_one("#agent-list", ListView) - assert len(lv.children) == 2 - await pilot.pause() - app.exit(None) - - import asyncio - - asyncio.run(probe()) - - def test_picker_pick_returns_agent_id(self) -> None: - """picker_pick_returns_agent_id: highlight idx 1 + Enter → exit value == 'b'.""" - from ratatoskr.sessions import AgentInfo - from ratatoskr.tui import AgentPickerApp - - agents = [ - AgentInfo( - agent_id="a", - name="A", - description="x", - version=None, - capabilities=[], - supported_models=[], - persona_traits={}, - ui_hints={}, - ), - AgentInfo( - agent_id="b", - name="B", - description="y", - version=None, - capabilities=[], - supported_models=[], - persona_traits={}, - ui_hints={}, - ), - ] - app = AgentPickerApp(agents) - - async def drive() -> str | None: - async with app.run_test() as pilot: - from textual.widgets import ListView - - lv = app.query_one("#agent-list", ListView) - lv.index = 1 - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - return app.return_value - - import asyncio - - chosen = asyncio.run(drive()) - assert chosen == "b" - - def test_picker_esc_returns_none(self) -> None: - """picker_esc_returns_none: Esc → exit value is None.""" - from ratatoskr.sessions import AgentInfo - from ratatoskr.tui import AgentPickerApp - - agents = [ - AgentInfo( - agent_id="a", - name="A", - description="x", - version=None, - capabilities=[], - supported_models=[], - persona_traits={}, - ui_hints={}, - ), - ] - app = AgentPickerApp(agents) - - async def drive() -> str | None: - async with app.run_test() as pilot: - await pilot.press("escape") - await pilot.pause() - return app.return_value - - import asyncio - - chosen = asyncio.run(drive()) - assert chosen is None - - -class TestResolveThenRunWithPicker: - """Issue #8: picker integration in _resolve_then_run.""" - - @respx.mock - def test_picker_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: - """picker_happy_path [happy,tracer]: agents fetched → picker exits with id → create_session. - - Patches AgentPickerApp.run_async to return 'lofn' (simulating user pick); - asserts list_agents fired once, POST /sessions body carries agent_id=lofn, - and RatatoskrApp opens with the chosen identity. - """ - agents_route = respx.get("https://w.example/agents").mock( - return_value=httpx.Response(200, json=_AGENTS_RESP) - ) - sessions_route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response( - 201, - json={ - **_CREATE_OK_RESP, - "agent_id": "lofn", - }, - ) - ) - - from ratatoskr.tui import AgentPickerApp - - async def picker_returns_lofn(self, *a, **kw): - return "lofn" - - monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_lofn) - - snapshot: dict = {} - - async def capture_main(self, *a, **kw): - snapshot["session_id"] = self.session_id - snapshot["agent_id"] = self.agent_id - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", capture_main) - from ratatoskr.tui import run_tui - - rc = run_tui(_args_new_no_agent()) - assert rc == 0 - assert agents_route.call_count == 1 - assert sessions_route.call_count == 1 - import json as _json - - body = _json.loads(sessions_route.calls[0].request.content) - assert body == {"agent_id": "lofn"} - assert snapshot["agent_id"] == "lofn" - - @respx.mock - def test_picker_esc_clean_exit(self, monkeypatch: pytest.MonkeyPatch) -> None: - """picker_esc_clean_exit: picker returns None → exit 0; no create_session; no main App.""" - agents_route = respx.get("https://w.example/agents").mock( - return_value=httpx.Response(200, json=_AGENTS_RESP) - ) - sessions_route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(201, json=_CREATE_OK_RESP) - ) - - from ratatoskr.tui import AgentPickerApp - - async def picker_dismissed(self, *a, **kw): - return None - - monkeypatch.setattr(AgentPickerApp, "run_async", picker_dismissed) - - main_called = False - - async def sentinel(self, *a, **kw): - nonlocal main_called - main_called = True - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", sentinel) - from ratatoskr.tui import run_tui - - rc = run_tui(_args_new_no_agent()) - assert rc == 0 - assert agents_route.call_count == 1 - assert sessions_route.call_count == 0 - assert main_called is False - - @respx.mock - def test_picker_skipped_when_agent_id_provided(self, monkeypatch: pytest.MonkeyPatch) -> None: - """picker_skipped_when_agent_id_provided: --new --agent mimir → list_agents NOT called.""" - agents_route = respx.get("https://w.example/agents").mock( - return_value=httpx.Response(200, json=_AGENTS_RESP) - ) - sessions_route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(201, json=_CREATE_OK_RESP) - ) - - async def fake_main(self, *a, **kw): - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", fake_main) - from ratatoskr.tui import run_tui - - rc = run_tui(_args_new()) # agent_id="mimir" - assert rc == 0 - assert agents_route.call_count == 0 - assert sessions_route.call_count == 1 - - @respx.mock - def test_picker_merges_local_tier3_agents( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - ) -> None: - """picker_merges_local_tier3_agents [v0.8.0]: local index entries - appear in the picker's agent list alongside remote agents.""" - from ratatoskr.local_agents import LocalAgentEntry, add_local_agent - - # Isolate the local index in a tmp file. - monkeypatch.setenv( - "RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json") - ) - add_local_agent(LocalAgentEntry( - agent_id="ratatoskr:wizard", - agent_name="wizard", - model="qwen3.6-35-a3b", - description="(tier 3) test wizard", - defined_at="2026-05-25T00:00:00+00:00", - )) - respx.get("https://w.example/agents").mock( - return_value=httpx.Response(200, json=_AGENTS_RESP) - ) - respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(201, json=_CREATE_OK_RESP) - ) - - from ratatoskr.tui import AgentPickerApp - - captured: list = [] - - async def capture_picker_init(self, *a, **kw): - captured.append(list(self.agents)) - return "mimir" # auto-pick something so the rest succeeds - - # Patch __init__ to capture the agent list passed to the picker. - orig_init = AgentPickerApp.__init__ - - def init_spy(self, agents): - captured.append(list(agents)) - orig_init(self, agents) - - monkeypatch.setattr(AgentPickerApp, "__init__", init_spy) - - async def picker_returns_mimir(self): - return "mimir" - - monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_mimir) - - async def fake_main(self, *a, **kw): - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", fake_main) - from ratatoskr.tui import run_tui - - rc = run_tui(_args_new_no_agent()) - assert rc == 0 - # The local tier-3 agent should appear in the picker's agents list. - assert captured, "AgentPickerApp.__init__ was never called" - agent_ids = {a.agent_id for a in captured[0]} - assert "ratatoskr:wizard" in agent_ids - # Plus the remote agents. - assert "mimir" in agent_ids - assert "lofn" in agent_ids - - @respx.mock - def test_picker_skipped_when_session_mode(self, monkeypatch: pytest.MonkeyPatch) -> None: - """picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session.""" - agents_route = respx.get("https://w.example/agents").mock( - return_value=httpx.Response(200, json=_AGENTS_RESP) - ) - sessions_route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response(201, json=_CREATE_OK_RESP) - ) - - async def fake_main(self, *a, **kw): - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", fake_main) - from ratatoskr.tui import run_tui - - rc = run_tui(_args_existing()) - assert rc == 0 - assert agents_route.call_count == 0 - assert sessions_route.call_count == 0 - - @respx.mock - def test_picker_list_agents_session_api_failed( - self, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - ) -> None: - """list_agents 500 → stderr [session_api_failed]; exit 20; picker NOT opened.""" - respx.get("https://w.example/agents").mock( - return_value=httpx.Response(500, content=b"oops") - ) - - from ratatoskr.tui import AgentPickerApp - - picker_called = False - - async def sentinel(self, *a, **kw): - nonlocal picker_called - picker_called = True - return None - - monkeypatch.setattr(AgentPickerApp, "run_async", sentinel) - - main_called = False - - async def main_sentinel(self, *a, **kw): - nonlocal main_called - main_called = True - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", main_sentinel) - from ratatoskr.tui import run_tui - - rc = run_tui(_args_new_no_agent()) - assert rc == 20 - err = capsys.readouterr().err - assert "[session_api_failed]" in err - assert "status=500" in err - assert picker_called is False - assert main_called is False - - @respx.mock - def test_picker_empty_list( - self, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - tmp_path: Path, - ) -> None: - """list_agents returns [] AND no local tier-3 entries → stderr - [no_agents]; exit 13; picker NOT opened. Isolate - $RATATOSKR_LOCAL_AGENTS so the operator's real local index - doesn't merge in and turn this into a non-empty list.""" - # v0.8.0 isolation: point local agents at an empty tmp file. - monkeypatch.setenv( - "RATATOSKR_LOCAL_AGENTS", str(tmp_path / "empty_local_agents.json") - ) - respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[])) - - from ratatoskr.tui import AgentPickerApp - - picker_called = False - - async def sentinel(self, *a, **kw): - nonlocal picker_called - picker_called = True - return None - - monkeypatch.setattr(AgentPickerApp, "run_async", sentinel) - from ratatoskr.tui import run_tui - - rc = run_tui(_args_new_no_agent()) - assert rc == 13 - err = capsys.readouterr().err - assert "[no_agents]" in err - assert picker_called is False - - -class TestTuiBifrostBind: - """Issue #17 slice 3b — TUI bind trigger: bind failures route to the real - stderr BEFORE the alt-screen opens (INV-002, mirrors issue #6; same exit - codes/vocabulary as cli._amain per INV-006).""" - - @respx.mock - async def test_handshake_failure_routes_pre_altscreen( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - from ratatoskr.tui import _resolve_then_run - - respx.post("https://w.example/sessions").mock( - return_value=httpx.Response( - 502, - json={ - "error_code": "bifrost_handshake_failed", - "detail": {"bifrost_error": "bifrost.auth_rejected"}, - }, - ) - ) - args = _args_new( - agent_id="ratatoskr:sindra", - bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"), - bifrost_plane="memory", - consumer_key="ck", - ) - rc = await _resolve_then_run(args) - assert rc == 23 - err = capsys.readouterr().err - assert "bifrost.auth_rejected" in err - assert "consumer key" in err # the 401-scoping hint - - @respx.mock - async def test_consumer_key_missing_routes_pre_altscreen( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - from ratatoskr.tui import _resolve_then_run - - args = _args_new( - agent_id="a", - bifrost=BifrostBinding(endpoint_url="http://x:8391"), - consumer_key=None, - ) - rc = await _resolve_then_run(args) - assert rc == 22 - assert "bifrost_consumer_key_missing" in capsys.readouterr().err - - @respx.mock - async def test_bound_create_carries_binding_and_consumer_key( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """A successful bound create sends the bifrost body + the consumer-key - bearer and prints the bound-state indicator (run_async stubbed so no - alt-screen opens).""" - from ratatoskr import tui as tui_mod - from ratatoskr.tui import _resolve_then_run - - route = respx.post("https://w.example/sessions").mock( - return_value=httpx.Response( - 201, - json={ - "session_id": "s-bound", - "agent_id": "ratatoskr:sindra", - "message_count": 0, - "created_at": "2026-06-18T12:00:00+00:00", - "last_active": "2026-06-18T12:00:00+00:00", - "metadata": {}, - }, - ) - ) - # sindra is a preset agent → the TUI create path now auto-seeds a #347 first-message. - respx.post("https://w.example/sessions/s-bound/history").mock( - return_value=httpx.Response(201, json={}) - ) - - async def fake_run_async(self) -> int: - return 0 - - monkeypatch.setattr(tui_mod.RatatoskrApp, "run_async", fake_run_async) - args = _args_new( - agent_id="ratatoskr:sindra", - bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"), - bifrost_plane="memory", - consumer_key="ck", - ) - rc = await _resolve_then_run(args) - assert rc == 0 - import json as _json - - body = _json.loads(route.calls[0].request.content) - assert body["bifrost"] == { - "endpoint_url": "http://10.100.10.50:8391", "scope": None - } - assert route.calls[0].request.headers["Authorization"] == "Bearer ck" - err = capsys.readouterr().err - assert "bifrost: status=bound" in err - assert "plane=memory" in err - - -class TestSessionPickerApp: - """docs/contracts/issues/6.contract.md FN SessionPickerApp (amendment slice b2).""" - - @staticmethod - def _two(): - from ratatoskr.sessions import SessionInfo - - return [ - SessionInfo( - session_id="s-first-0001", agent_id="mimir", created_at="t0", - last_active="t1", metadata={}, message_count=3, name=None, - archived=False, tags=[], - ), - SessionInfo( - session_id="s-second-002", agent_id="echo", created_at="t0", - last_active="t2", metadata={}, message_count=None, name="probe", - archived=False, tags=[], - ), - ] - - def test_pick_returns_session_id(self) -> None: - """pick_returns_session_id [happy,tracer]: idx 1 + Enter → exit value == that session_id.""" - from ratatoskr.tui import SessionPickerApp - - app = SessionPickerApp(self._two()) - - async def drive() -> str | None: - async with app.run_test() as pilot: - from textual.widgets import ListView - - lv = app.query_one("#session-list", ListView) - lv.index = 1 - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - return app.return_value - - import asyncio - - assert asyncio.run(drive()) == "s-second-002" - - def test_esc_returns_none(self) -> None: - """esc_returns_none [happy]: Esc → exit value is None (dismiss, resume nothing).""" - from ratatoskr.tui import SessionPickerApp - - app = SessionPickerApp(self._two()) - - async def drive() -> str | None: - async with app.run_test() as pilot: - await pilot.press("escape") - await pilot.pause() - return app.return_value - - import asyncio - - assert asyncio.run(drive()) is None - - def test_ctrl_d_returns_none(self) -> None: - """ctrl_d_returns_none [adversarial]: Ctrl-D → None.""" - from ratatoskr.tui import SessionPickerApp - - app = SessionPickerApp(self._two()) - - async def drive() -> str | None: - async with app.run_test() as pilot: - await pilot.press("ctrl+d") - await pilot.pause() - return app.return_value - - import asyncio - - assert asyncio.run(drive()) is None - - -class TestBareSessionPicker: - """docs/contracts/issues/6.contract.md amendment (slice b2): _resolve_then_run bare mode.""" - - @staticmethod - def _bare_args() -> ParsedArgs: - return ParsedArgs( - send_content=None, session_id=None, new=False, agent_id=None, - api_key="k", server_url="https://w.example", raw=False, - end_user_id=None, bifrost=None, bifrost_plane=None, consumer_key=None, - ) - - @staticmethod - def _sess(sid: str, agent: str = "mimir"): - from ratatoskr.sessions import SessionInfo - - return SessionInfo( - session_id=sid, agent_id=agent, created_at="t0", last_active="t1", - metadata={}, message_count=1, name=None, archived=False, tags=[], - ) - - def test_bare_zero_sessions_errors( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """bare_zero_sessions_errors [error]: 0 sessions → exit 14 [no_sessions]; App not opened.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionPage - - async def fake_list(client, **kw): - return SessionPage(items=[], next_cursor=None) - - monkeypatch.setattr(tui_mod, "list_sessions", fake_list) - opened: list[int] = [] - - async def spy(self, *a, **k): - opened.append(1) - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", spy) - rc = run_tui(self._bare_args()) - assert rc == 14 - assert "[no_sessions]" in capsys.readouterr().err - assert not opened - - def test_bare_one_session_auto_resumes(self, monkeypatch: pytest.MonkeyPatch) -> None: - """bare_one_session_auto_resumes: exactly 1 → auto-resume, no picker (§4 >1 rule).""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionPage - from ratatoskr.tui import SessionPickerApp - - async def fake_list(client, **kw): - return SessionPage(items=[self._sess("s-solo")], next_cursor=None) - - monkeypatch.setattr(tui_mod, "list_sessions", fake_list) - picker_used: list[int] = [] - - async def spy_picker(self, *a, **k): - picker_used.append(1) - return None - - monkeypatch.setattr(SessionPickerApp, "run_async", spy_picker) - snap: dict = {} - - async def cap(self, *a, **k): - snap["sid"] = self.session_id - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", cap) - rc = run_tui(self._bare_args()) - assert rc == 0 - assert snap["sid"] == "s-solo" - assert not picker_used - - def test_bare_multi_opens_picker(self, monkeypatch: pytest.MonkeyPatch) -> None: - """bare_multi_opens_picker [scenario,tracer]: >1 → picker; its choice resumes.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionPage - from ratatoskr.tui import SessionPickerApp - - async def fake_list(client, **kw): - return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None) - - monkeypatch.setattr(tui_mod, "list_sessions", fake_list) - - async def pick_b(self, *a, **k): - return "s-b" - - monkeypatch.setattr(SessionPickerApp, "run_async", pick_b) - snap: dict = {} - - async def cap(self, *a, **k): - snap["sid"] = self.session_id - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", cap) - rc = run_tui(self._bare_args()) - assert rc == 0 - assert snap["sid"] == "s-b" - - def test_bare_picker_dismiss_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: - """bare_picker_dismiss_exits_zero [scenario]: picker None → exit 0; App not opened.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionPage - from ratatoskr.tui import SessionPickerApp - - async def fake_list(client, **kw): - return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None) - - monkeypatch.setattr(tui_mod, "list_sessions", fake_list) - - async def pick_none(self, *a, **k): - return None - - monkeypatch.setattr(SessionPickerApp, "run_async", pick_none) - opened: list[int] = [] - - async def spy(self, *a, **k): - opened.append(1) - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", spy) - rc = run_tui(self._bare_args()) - assert rc == 0 - assert not opened - - def test_bare_list_sessions_api_failure( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """bare_list_sessions_api_failure [error]: list_sessions 500 → exit 20; App not opened.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionApiFailed - - async def fake_list(client, **kw): - raise SessionApiFailed(status=500, body=b"boom") - - monkeypatch.setattr(tui_mod, "list_sessions", fake_list) - opened: list[int] = [] - - async def spy(self, *a, **k): - opened.append(1) - return 0 - - monkeypatch.setattr(RatatoskrApp, "run_async", spy) - rc = run_tui(self._bare_args()) - assert rc == 20 - assert "[session_api_failed]" in capsys.readouterr().err - assert not opened - - -class TestSessionToolsHydration: - """get_session_tools + the #183 Tools-pane inventory hydrate (GET /sessions/{id}/tools).""" - - def test_format_tool_inventory(self) -> None: - """format_tool_inventory [unit]: header + builtin + bifrost lines.""" - from ratatoskr.tui import _format_tool_inventory - - lines = _format_tool_inventory( - { - "agent_id": "alice:wizard", - "builtin_tools": [], - "bifrost_tools": [{"name": "bifrost.x"}, {"name": "bifrost.y"}], - } - ) - joined = "\n".join(lines) - assert "agent=alice:wizard" in joined - assert "builtin=0 bifrost=2" in joined - assert "builtin: (none)" in joined - assert "bifrost.x, bifrost.y" in joined - - async def test_hydrate_writes_inventory_and_audits( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """hydrate_writes_inventory [scenario,tracer]: 200 → inventory in Tools pane + audit.""" - import ratatoskr.tui as tui_mod - - writes = _spy_writes(monkeypatch) - - async def fake_tools(client, session_id): - return { - "agent_id": "alice:wizard", - "builtin_tools": [], - "bifrost_tools": [{"name": "bifrost.set_field"}], - } - - monkeypatch.setattr(tui_mod, "get_session_tools", fake_tools) - app = _resolved_app(_args_existing(session_id="s-tools-01")) - async with app.run_test() as pilot: - await pilot.pause() - await app._hydrate_session_tools() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "session tool inventory" in joined - assert "bifrost.set_field" in joined - assert "session_tools_hydrated" in joined # audit line landed - - async def test_hydrate_failure_audits_no_crash( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """hydrate_failure [error]: get_session_tools raises → failure audit; no crash.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionApiFailed - - writes = _spy_writes(monkeypatch) - - async def boom(client, session_id): - raise SessionApiFailed(status=404, body=b"session_not_found") - - monkeypatch.setattr(tui_mod, "get_session_tools", boom) - app = _resolved_app(_args_existing(session_id="s-tools-02")) - async with app.run_test() as pilot: - await pilot.pause() - await app._hydrate_session_tools() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "session_tools_hydration_failed" in joined - - -class TestBifrostStateHydration: - """get_session_bifrost + the #176 BifrostState pane (GET /admin/sessions/{id}/bifrost).""" - - @staticmethod - def _mute_tools(monkeypatch: pytest.MonkeyPatch) -> None: - """Neutralize the on_mount Tools-pane worker so it makes no real call.""" - import ratatoskr.tui as tui_mod - - async def noop(client, session_id): - return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []} - - monkeypatch.setattr(tui_mod, "get_session_tools", noop) - - def test_format_bifrost_state(self) -> None: - """format_bifrost_state [unit]: connected / endpoint / caps / tools lines.""" - from ratatoskr.tui import _format_bifrost_state - - lines = _format_bifrost_state( - { - "endpoint_url": "https://b/mcp", - "consumer_id": "alice", - "connected": True, - "capabilities_granted": ["tools:call", "tools:read"], - "tools": [{"name": "bifrost.echo"}], - } - ) - joined = "\n".join(lines) - assert "connected=True" in joined - assert "consumer=alice" in joined - assert "https://b/mcp" in joined - assert "tools:call, tools:read" in joined - assert "bifrost.echo" in joined - - async def test_hydrate_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None: - """hydrate_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit.""" - self._mute_tools(monkeypatch) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing(session_id="s-bf-1")) # admin_key defaults None - async with app.run_test() as pilot: - await pilot.pause() - await app._hydrate_bifrost_state() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "admin key not configured" in joined - assert "bifrost_state_skipped" in joined - - async def test_hydrate_success(self, monkeypatch: pytest.MonkeyPatch) -> None: - """hydrate_success [scenario,tracer]: 200 → binding in BifrostState pane + audit.""" - import ratatoskr.tui as tui_mod - - self._mute_tools(monkeypatch) - writes = _spy_writes(monkeypatch) - - async def fake_bifrost(client, session_id, *, admin_key): - return { - "endpoint_url": "https://b/mcp", - "consumer_id": "alice", - "connected": True, - "capabilities_granted": ["tools:call"], - "tools": [{"name": "bifrost.echo"}], - } - - monkeypatch.setattr(tui_mod, "get_session_bifrost", fake_bifrost) - app = _resolved_app(_args_existing(session_id="s-bf-2", admin_key="ak")) - async with app.run_test() as pilot: - await pilot.pause() - await app._hydrate_bifrost_state() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "bifrost binding" in joined - assert "bifrost.echo" in joined - assert "bifrost_state_hydrated" in joined - - async def test_hydrate_404_not_bound(self, monkeypatch: pytest.MonkeyPatch) -> None: - """hydrate_404_not_bound [error]: 404 → 'not bound to Bifrost' + audit; no crash.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionApiFailed - - self._mute_tools(monkeypatch) - writes = _spy_writes(monkeypatch) - - async def not_bound(client, session_id, *, admin_key): - raise SessionApiFailed(status=404, body=b"session_not_bifrost_bound") - - monkeypatch.setattr(tui_mod, "get_session_bifrost", not_bound) - app = _resolved_app(_args_existing(session_id="s-bf-3", admin_key="ak")) - async with app.run_test() as pilot: - await pilot.pause() - await app._hydrate_bifrost_state() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "not bound to Bifrost" in joined - assert "bifrost_state_unavailable" in joined - - -class TestAdminEventsStream: - """stream_admin_events + the #11 AdminEvents pane (GET /admin/events, session-filtered).""" - - @staticmethod - def _mute_hydrates(monkeypatch: pytest.MonkeyPatch) -> None: - """Neutralize the other on_mount workers (tools + bifrost) — no real calls.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sessions import SessionApiFailed - - async def noop_tools(client, session_id): - return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []} - - async def noop_bifrost(client, session_id, *, admin_key): - raise SessionApiFailed(status=404, body=b"nb") - - monkeypatch.setattr(tui_mod, "get_session_tools", noop_tools) - monkeypatch.setattr(tui_mod, "get_session_bifrost", noop_bifrost) - - def test_format_admin_event(self) -> None: - """format_admin_event [unit]: HH:MM:SS + type + fields; session_id dropped.""" - from ratatoskr.sse_client import AdminEvent - from ratatoskr.tui import _format_admin_event - - line = _format_admin_event( - AdminEvent( - 42, "turn.completed", "2026-05-06T10:00:05.000Z", - {"session_id": "s1", "turn_id": 7, "duration_ms": 1200, "phase": "succeeded"}, - ) - ) - assert "turn.completed" in line - assert "[10:00:05]" in line - assert "turn_id=7" in line - assert "session_id" not in line # dropped — pane is already session-scoped - - def test_admin_event_matches_filter(self) -> None: - """admin_event_matches [unit]: active-session + non-heartbeat system.* pass (§6).""" - from ratatoskr.sse_client import AdminEvent - - E = AdminEvent - app = _resolved_app(_args_existing(session_id="s-match")) - assert app._admin_event_matches(E(1, "session.created", "t", {"session_id": "s-match"})) - assert not app._admin_event_matches(E(2, "turn.started", "t", {"session_id": "other"})) - assert not app._admin_event_matches(E(0, "system.heartbeat", "t", {})) - assert app._admin_event_matches(E(3, "system.events_dropped", "t", {"count": 5})) - - async def test_stream_writes_filtered_events(self, monkeypatch: pytest.MonkeyPatch) -> None: - """stream_filtered [scenario,tracer]: only active-session + non-heartbeat lines land.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sse_client import AdminEvent - - self._mute_hydrates(monkeypatch) - writes = _spy_writes(monkeypatch) - - async def fake_stream(client, *, admin_key, last_event_id=None): - yield AdminEvent(41, "session.created", "t", {"session_id": "s-ae-2"}) - yield AdminEvent(0, "system.heartbeat", "t", {}) # filtered (noise) - yield AdminEvent(42, "turn.started", "t", {"session_id": "other"}) # diff session - yield AdminEvent(43, "session.deleted", "t", {"session_id": "s-ae-2"}) - - monkeypatch.setattr(tui_mod, "stream_admin_events", fake_stream) - app = _resolved_app(_args_existing(session_id="s-ae-2", admin_key="ak")) - async with app.run_test() as pilot: - await pilot.pause() - await app._stream_admin_events() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "session.created" in joined - assert "session.deleted" in joined - assert "system.heartbeat" not in joined - assert "turn.started" not in joined # different session → filtered - - async def test_stream_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None: - """stream_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit.""" - self._mute_hydrates(monkeypatch) - writes = _spy_writes(monkeypatch) - app = _resolved_app(_args_existing(session_id="s-ae-3")) # admin_key None - async with app.run_test() as pilot: - await pilot.pause() - await app._stream_admin_events() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "admin key not configured" in joined - assert "admin_events_skipped" in joined - - async def test_stream_403_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None: - """stream_403 [error]: 403 scope-denied → 'unavailable' + audit; no crash.""" - import ratatoskr.tui as tui_mod - from ratatoskr.sse_client import SseConnectFailed - - self._mute_hydrates(monkeypatch) - writes = _spy_writes(monkeypatch) - - async def denied(client, *, admin_key, last_event_id=None): - raise SseConnectFailed(status=403, body=b"auth_scope_denied") - yield # unreachable — makes this an async generator - - monkeypatch.setattr(tui_mod, "stream_admin_events", denied) - app = _resolved_app(_args_existing(session_id="s-ae-4", admin_key="ak")) - async with app.run_test() as pilot: - await pilot.pause() - await app._stream_admin_events() - await pilot.pause() - joined = " ".join(_text_of(w) for w in writes) - assert "admin events unavailable: HTTP 403" in joined - assert "admin_events_unavailable" in joined diff --git a/uv.lock b/uv.lock index e9c64b5..11a79b7 100644 --- a/uv.lock +++ b/uv.lock @@ -6,126 +6,6 @@ resolution-markers = [ "python_full_version < '3.15'", ] -[[package]] -name = "aiohappyeyeballs" -version = "2.6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062 }, -] - -[[package]] -name = "aiohttp" -version = "3.13.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876 }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557 }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258 }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199 }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013 }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501 }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981 }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934 }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671 }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219 }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049 }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557 }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931 }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125 }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427 }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534 }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446 }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930 }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927 }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141 }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476 }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507 }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465 }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523 }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113 }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351 }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205 }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618 }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185 }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311 }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147 }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356 }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637 }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896 }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721 }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663 }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094 }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701 }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360 }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023 }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795 }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405 }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082 }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346 }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891 }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113 }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088 }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976 }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444 }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128 }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029 }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758 }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883 }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668 }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461 }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661 }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800 }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382 }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724 }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027 }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644 }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630 }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403 }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924 }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119 }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072 }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819 }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441 }, -] - -[[package]] -name = "aiohttp-jinja2" -version = "1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "jinja2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736 }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, -] - [[package]] name = "anyio" version = "4.13.0" @@ -230,95 +110,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, -] - [[package]] name = "h11" version = "0.16.0" @@ -419,18 +210,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -518,262 +297,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572 }, ] -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878 }, -] - -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, -] - -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663 }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, -] - -[[package]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939 }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064 }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131 }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556 }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920 }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013 }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096 }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708 }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119 }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212 }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315 }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721 }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657 }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668 }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040 }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037 }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631 }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118 }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127 }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981 }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885 }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658 }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290 }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234 }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391 }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787 }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453 }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264 }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076 }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242 }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509 }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957 }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910 }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197 }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772 }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868 }, -] - -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190 }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486 }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219 }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132 }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420 }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510 }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094 }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786 }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483 }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403 }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315 }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528 }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784 }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980 }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602 }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930 }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074 }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471 }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401 }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143 }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507 }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358 }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884 }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878 }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542 }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403 }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889 }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982 }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415 }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337 }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788 }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842 }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237 }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008 }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542 }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719 }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, -] - [[package]] name = "mypy" version = "2.1.0" @@ -845,15 +368,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, ] -[[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 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348 }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -863,100 +377,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887 }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654 }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190 }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995 }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422 }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342 }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639 }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588 }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029 }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774 }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532 }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592 }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788 }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514 }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018 }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322 }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172 }, - { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457 }, - { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835 }, - { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545 }, - { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886 }, - { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261 }, - { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184 }, - { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534 }, - { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500 }, - { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994 }, - { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884 }, - { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464 }, - { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588 }, - { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667 }, - { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463 }, - { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621 }, - { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649 }, - { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636 }, - { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872 }, - { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257 }, - { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696 }, - { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378 }, - { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283 }, - { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616 }, - { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773 }, - { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664 }, - { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643 }, - { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595 }, - { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711 }, - { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247 }, - { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102 }, - { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964 }, - { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546 }, - { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330 }, - { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521 }, - { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662 }, - { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928 }, - { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650 }, - { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912 }, - { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300 }, - { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208 }, - { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633 }, - { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724 }, - { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069 }, - { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099 }, - { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391 }, - { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626 }, - { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781 }, - { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570 }, - { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436 }, - { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373 }, - { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554 }, - { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395 }, - { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653 }, - { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914 }, - { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567 }, - { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542 }, - { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845 }, - { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985 }, - { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999 }, - { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779 }, - { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796 }, - { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023 }, - { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448 }, - { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329 }, - { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172 }, - { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813 }, - { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764 }, - { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140 }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036 }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1052,12 +472,11 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.20.17" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, - { name = "textual" }, ] [package.optional-dependencies] @@ -1069,7 +488,6 @@ dev = [ { name = "respx" }, { name = "ruff" }, { name = "starlette" }, - { name = "textual-dev" }, { name = "uvicorn", extra = ["standard"] }, ] provider = [ @@ -1100,8 +518,6 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "sqlite-vec", marker = "extra == 'provider'", specifier = ">=0.1.6" }, { name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" }, - { name = "textual", specifier = ">=0.85" }, - { name = "textual-dev", marker = "extra == 'dev'", specifier = ">=1.5" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.30" }, ] provides-extras = ["web", "provider", "dev"] @@ -1132,19 +548,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557 }, ] -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, -] - [[package]] name = "rpds-py" version = "2026.5.1" @@ -1305,56 +708,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899 }, ] -[[package]] -name = "textual" -version = "8.2.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify"] }, - { name = "mdit-py-plugins" }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129 }, -] - -[[package]] -name = "textual-dev" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "click" }, - { name = "msgpack" }, - { name = "textual" }, - { name = "textual-serve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/fd/fd5ad9527b536c306a5a860a33a45e13983a59804b48b91ea52b42ba030a/textual_dev-1.8.0.tar.gz", hash = "sha256:7e56867b0341405a95e938cac0647e6d2763d38d0df08710469ad6b6a8db76df", size = 25026 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/c6/1dc08ceee6d0bdf219c500cb2fbd605b681b63507c4ff27a6477f12f8245/textual_dev-1.8.0-py3-none-any.whl", hash = "sha256:227b6d24a485fbbc77e302aa21f4fdf3083beb57eb45cd95bae082c81cbeddeb", size = 27541 }, -] - -[[package]] -name = "textual-serve" -version = "1.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "aiohttp-jinja2" }, - { name = "jinja2" }, - { name = "rich" }, - { name = "textual" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/7e/62fecc552853ec6a178cb1faa2d6f73b34d5512924770e7b08b58ff14148/textual_serve-1.1.3.tar.gz", hash = "sha256:f8f636ae2f5fd651b79d965473c3e9383d3521cdf896f9bc289709185da3f683", size = 448340 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/fe/108e7773349d500cf363328c3d0b7123e03feda51e310a3a5b136ac8ca71/textual_serve-1.1.3-py3-none-any.whl", hash = "sha256:207a472bc6604e725b1adab4ab8bf12f4c4dc25b04eea31e4d04731d8bf30f18", size = 447339 }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -1364,15 +717,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] -[[package]] -name = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383 }, -] - [[package]] name = "uvicorn" version = "0.48.0" @@ -1559,85 +903,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, ] - -[[package]] -name = "yarl" -version = "1.24.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957 }, - { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164 }, - { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688 }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902 }, - { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931 }, - { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030 }, - { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392 }, - { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612 }, - { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487 }, - { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333 }, - { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025 }, - { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507 }, - { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719 }, - { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438 }, - { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719 }, - { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901 }, - { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229 }, - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978 }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733 }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113 }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899 }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862 }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060 }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613 }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012 }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887 }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620 }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599 }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604 }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161 }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619 }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362 }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667 }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069 }, - { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670 }, - { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916 }, - { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625 }, - { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574 }, - { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534 }, - { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481 }, - { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529 }, - { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338 }, - { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147 }, - { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272 }, - { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962 }, - { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063 }, - { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438 }, - { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458 }, - { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589 }, - { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424 }, - { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690 }, - { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248 }, - { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084 }, - { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272 }, - { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497 }, - { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002 }, - { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524 }, - { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165 }, - { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010 }, - { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128 }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382 }, - { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964 }, - { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204 }, - { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510 }, - { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584 }, - { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410 }, - { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980 }, - { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219 }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576 }, -]