From 3e2b95e2e30438069fa89452228e28fef9c734df Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 07:19:10 -0700 Subject: [PATCH 01/87] Add AssemblyAI CLI (aai) Initial implementation of the `aai` onboarding CLI for AssemblyAI. Commands: - login: store the API key via keyring - transcribe: file/sample transcription with a runnable code template - transcripts: list/get past transcripts - samples: scaffold key-injected sample scripts - stream: real-time transcription from a file or microphone - agent: live two-way voice conversation with an AssemblyAI voice agent - claude: wire Claude Code to AssemblyAI's docs MCP server + skill Built on typer + rich; packaged with hatchling. Includes a full pytest suite (157 tests) plus ruff and pre-commit configuration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 11 + .pre-commit-config.yaml | 27 ++ DEMO.md | 90 ++++++ README.md | 59 +++- assemblyai_cli/__init__.py | 1 + assemblyai_cli/__main__.py | 4 + assemblyai_cli/agent/__init__.py | 0 assemblyai_cli/agent/audio.py | 133 +++++++++ assemblyai_cli/agent/render.py | 84 ++++++ assemblyai_cli/agent/session.py | 155 ++++++++++ assemblyai_cli/agent/voices.py | 49 +++ assemblyai_cli/client.py | 116 +++++++ assemblyai_cli/commands/__init__.py | 0 assemblyai_cli/commands/agent.py | 79 +++++ assemblyai_cli/commands/claude.py | 198 ++++++++++++ assemblyai_cli/commands/login.py | 90 ++++++ assemblyai_cli/commands/samples.py | 87 ++++++ assemblyai_cli/commands/stream.py | 58 ++++ assemblyai_cli/commands/transcribe.py | 52 ++++ assemblyai_cli/commands/transcripts.py | 65 ++++ assemblyai_cli/config.py | 102 +++++++ assemblyai_cli/context.py | 32 ++ assemblyai_cli/errors.py | 40 +++ assemblyai_cli/main.py | 37 +++ assemblyai_cli/output.py | 46 +++ assemblyai_cli/streaming/__init__.py | 0 assemblyai_cli/streaming/render.py | 58 ++++ assemblyai_cli/streaming/sources.py | 138 +++++++++ assemblyai_cli/templates/__init__.py | 0 assemblyai_cli/templates/stream.py.tmpl | 38 +++ assemblyai_cli/templates/transcribe.py.tmpl | 11 + pyproject.toml | 45 +++ tests/conftest.py | 44 +++ tests/test_agent_audio.py | 85 ++++++ tests/test_agent_command.py | 124 ++++++++ tests/test_agent_render.py | 83 ++++++ tests/test_agent_session.py | 134 +++++++++ tests/test_agent_voices.py | 26 ++ tests/test_claude.py | 315 ++++++++++++++++++++ tests/test_client.py | 231 ++++++++++++++ tests/test_config.py | 77 +++++ tests/test_context.py | 40 +++ tests/test_errors.py | 22 ++ tests/test_login.py | 54 ++++ tests/test_output.py | 50 ++++ tests/test_samples.py | 86 ++++++ tests/test_smoke.py | 30 ++ tests/test_stream_command.py | 121 ++++++++ tests/test_streaming_render.py | 83 ++++++ tests/test_streaming_sources.py | 188 ++++++++++++ tests/test_transcribe.py | 110 +++++++ tests/test_transcripts.py | 47 +++ 52 files changed, 3853 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 DEMO.md create mode 100644 assemblyai_cli/__init__.py create mode 100644 assemblyai_cli/__main__.py create mode 100644 assemblyai_cli/agent/__init__.py create mode 100644 assemblyai_cli/agent/audio.py create mode 100644 assemblyai_cli/agent/render.py create mode 100644 assemblyai_cli/agent/session.py create mode 100644 assemblyai_cli/agent/voices.py create mode 100644 assemblyai_cli/client.py create mode 100644 assemblyai_cli/commands/__init__.py create mode 100644 assemblyai_cli/commands/agent.py create mode 100644 assemblyai_cli/commands/claude.py create mode 100644 assemblyai_cli/commands/login.py create mode 100644 assemblyai_cli/commands/samples.py create mode 100644 assemblyai_cli/commands/stream.py create mode 100644 assemblyai_cli/commands/transcribe.py create mode 100644 assemblyai_cli/commands/transcripts.py create mode 100644 assemblyai_cli/config.py create mode 100644 assemblyai_cli/context.py create mode 100644 assemblyai_cli/errors.py create mode 100644 assemblyai_cli/main.py create mode 100644 assemblyai_cli/output.py create mode 100644 assemblyai_cli/streaming/__init__.py create mode 100644 assemblyai_cli/streaming/render.py create mode 100644 assemblyai_cli/streaming/sources.py create mode 100644 assemblyai_cli/templates/__init__.py create mode 100644 assemblyai_cli/templates/stream.py.tmpl create mode 100644 assemblyai_cli/templates/transcribe.py.tmpl create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_agent_audio.py create mode 100644 tests/test_agent_command.py create mode 100644 tests/test_agent_render.py create mode 100644 tests/test_agent_session.py create mode 100644 tests/test_agent_voices.py create mode 100644 tests/test_claude.py create mode 100644 tests/test_client.py create mode 100644 tests/test_config.py create mode 100644 tests/test_context.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_login.py create mode 100644 tests/test_output.py create mode 100644 tests/test_samples.py create mode 100644 tests/test_smoke.py create mode 100644 tests/test_stream_command.py create mode 100644 tests/test_streaming_render.py create mode 100644 tests/test_streaming_sources.py create mode 100644 tests/test_transcribe.py create mode 100644 tests/test_transcripts.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d1575b58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ + +# Local scratch scripts (often contain live keys) +transcribe/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b8e194ea --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.13 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: pytest + name: pytest + entry: python -m pytest -q + language: system + types: [python] + pass_filenames: false + always_run: true diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 00000000..8af62ac0 --- /dev/null +++ b/DEMO.md @@ -0,0 +1,90 @@ +# AssemblyAI CLI (`aai`) — Demo + +The story: **signup → first successful transcript in two commands**, then show it's built for how developers and agents actually work. Paste the blocks in order. + +## Setup + +Point the CLI at your key. (In a real first-run you'd run `aai login` and paste the key from the browser; this is the repeatable, no-browser path.) + +```bash +export ASSEMBLYAI_API_KEY=... # your key +aai login --api-key "$ASSEMBLYAI_API_KEY" +aai whoami +``` + +`login` validates the key and stores it in your OS keyring (not a plaintext file); `whoami` confirms which profile is active and that the key reaches the API. + +## 1. Zero to transcript in one command + +```bash +aai transcribe --sample +``` + +This transcribes AssemblyAI's hosted sample (`wildfires.mp3`). The submit-and-poll loop is handled for you — one command returns the finished text in a few seconds. + +## 2. Built for scripts and agents + +```bash +aai transcribe --sample --json | jq +``` + +The same command emits clean JSON. The CLI auto-detects when it isn't talking to a human (piped, CI, or an AI agent) and switches to JSON automatically — so in your terminal you get readable text, and in a pipeline you get something parseable. (`jq` just pretty-prints; you can drop it.) + +## 3. Captions, straight out of the box + +```bash +aai transcribe --sample --srt | head -n 8 +``` + +Add `--srt` (or `--vtt`) to get timestamped subtitles from the same command — no extra steps. + +## 4. Everything you've run is queryable + +```bash +aai list --limit 5 +aai get # fetch any past transcript by id +``` + +`list` shows recent transcripts; `get` retrieves one by id (handy for re-fetching a long job later). + +## 5. Get into your own code instantly + +```bash +aai samples create transcribe +cat transcribe/transcribe.py +python transcribe/transcribe.py +``` + +`samples create` scaffolds a runnable starter script with your key already wired in — zero edits — and it just runs. (The generated file contains your key, so don't commit it.) + +## 6. Real-time streaming (file or microphone) + +Stream a file and watch the transcript build in real time — no microphone, no extra dependency (16 kHz mono WAV streams directly; other formats use `ffmpeg`): + +```bash +aai stream recording.wav +``` + +Or transcribe live from your microphone. The mic backend is an optional extra so the base install (and CI) stays lightweight: + +```bash +pip install "assemblyai-cli[mic]" +aai stream # start talking; Ctrl-C to stop +``` + +Partial words appear and refine as you speak, finalizing at the end of each turn. Add `--json` for newline-delimited JSON events (one per turn) — ideal for piping into another process or an agent: + +```bash +aai stream recording.wav --json +``` + +Streaming uses AssemblyAI's v3 realtime API under the hood; the CLI just hands it your microphone or file and renders the turns. + +--- + +**The whole onboarding is `aai login` → `aai transcribe`.** Everything else is a convenience on top. + +### Tips +- Have `jq` installed for the prettiest JSON output. +- For a *first-time-user* story, use plain `aai login` to show the browser-assisted paste flow instead of `--api-key`. +- `aai logout` clears the stored key when you're done. diff --git a/README.md b/README.md index a3c02a31..5b864032 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ -# cli -AssemblyAI CLI +# AssemblyAI CLI (`aai`) + +Onboarding CLI for AssemblyAI: `aai login` then `aai transcribe --sample`. + +## Install (dev) + + pip install -e ".[dev]" + +## Usage + + aai login + aai transcribe --sample + +## Streaming + +Real-time transcription from a file (no extra dependency): + + aai stream path/to/audio.wav # 16 kHz mono WAV streams directly + aai stream path/to/audio.mp3 # other formats require ffmpeg on PATH + +From the microphone (install the optional extra first): + + pip install "assemblyai-cli[mic]" + aai stream # Ctrl-C to stop + +Add `--json` for newline-delimited JSON events (also the default when piped or run by an agent). + +## AI coding agents + +Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI +skill so your agent writes current, correct integration code: + + aai claude install # installs the docs MCP server + skill (user scope) + aai claude status # show what's wired up + aai claude remove # unwind both + +`install` shells out to `claude mcp add` for the docs MCP server and to +`npx skills add` for the skill. Pass `--scope project` to scope the MCP server +to the current project instead of the whole machine. A missing `claude` or +`npx` is reported and skipped (with the manual command to run), not treated as +an error. + +## Voice agent + +Have a live, two-way voice conversation with an AssemblyAI voice agent (requires the +`[mic]` extra for microphone + speaker audio): + + pip install "assemblyai-cli[mic]" + aai agent # talk; the agent talks back. Ctrl-C to stop. + aai agent --voice james --greeting "Hi there" + aai agent --prompt-file persona.txt # load the system prompt from a file + aai agent --list-voices # see available voices + +By default the agent runs **half-duplex**: your mic mutes while the agent is speaking, +so it can't hear itself on your speakers. With headphones, add `--full-duplex` for +true barge-in (interrupt the agent mid-sentence). Add `--json` for newline-delimited +JSON events. diff --git a/assemblyai_cli/__init__.py b/assemblyai_cli/__init__.py new file mode 100644 index 00000000..3dc1f76b --- /dev/null +++ b/assemblyai_cli/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/assemblyai_cli/__main__.py b/assemblyai_cli/__main__.py new file mode 100644 index 00000000..39baeb62 --- /dev/null +++ b/assemblyai_cli/__main__.py @@ -0,0 +1,4 @@ +from assemblyai_cli.main import app + +if __name__ == "__main__": + app(prog_name="aai") diff --git a/assemblyai_cli/agent/__init__.py b/assemblyai_cli/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py new file mode 100644 index 00000000..87ab7517 --- /dev/null +++ b/assemblyai_cli/agent/audio.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import contextlib +import queue +import threading +from collections.abc import Callable, Iterator + +from assemblyai_cli.errors import CLIError + +SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate + +_MIC_MISSING_MSG = "Audio support isn't installed. Run: pip install 'assemblyai-cli[mic]'" + + +def _default_output_stream(rate: int): + """Open a PyAudio PCM16 mono output stream (lazy import; needs the [mic] extra).""" + try: + import pyaudio + except ImportError as exc: + raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc + try: + pa = pyaudio.PyAudio() + stream = pa.open(format=pyaudio.paInt16, channels=1, rate=rate, output=True) + except Exception as exc: # noqa: BLE001 - surface device errors cleanly + raise CLIError( + f"Could not open the audio output device: {exc}", + error_type="audio_output_error", + exit_code=1, + ) from exc + stream._pa = pa # retain so PyAudio isn't GC'd before the stream; terminated in Player.close() + return stream + + +class Player: + """Plays queued PCM16 audio chunks through a speaker output stream.""" + + def __init__( + self, + *, + sample_rate: int = SAMPLE_RATE, + stream_factory: Callable[[int], object] | None = None, + ) -> None: + self._rate = sample_rate + self._factory = stream_factory or _default_output_stream + self._queue: queue.Queue[bytes | None] = queue.Queue() + self._stream = None + self._thread: threading.Thread | None = None + + def start(self) -> None: + self._stream = self._factory(self._rate) + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + while True: + chunk = self._queue.get() + if chunk is None: + return + try: + self._stream.write(chunk) + except Exception: # noqa: BLE001 - stream may be torn down mid-write + return + + def enqueue(self, pcm: bytes) -> None: + self._queue.put(pcm) + + def flush(self) -> None: + """Discard pending audio (barge-in / interruption).""" + try: + while True: + self._queue.get_nowait() + except queue.Empty: + pass + + def pending(self) -> int: + return self._queue.qsize() + + def close(self) -> None: + self._queue.put(None) + # Stop the stream first so any in-flight write() raises and the worker + # thread returns promptly, avoiding a teardown race with the join below. + if self._stream is not None: + with contextlib.suppress(Exception): + self._stream.stop_stream() + if self._thread is not None: + self._thread.join(timeout=2) + if self._stream is not None: + pa = getattr(self._stream, "_pa", None) + with contextlib.suppress(Exception): + self._stream.close() + if pa is not None: + with contextlib.suppress(Exception): + pa.terminate() + + +def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[bytes]: + """SDK PyAudio-backed mic stream (lazy import so the base install stays light).""" + from assemblyai.extras import MicrophoneStream + + return MicrophoneStream(sample_rate=sample_rate, device_index=device) + + +class MicCapture: + """Iterates PCM16 chunks from the microphone (requires the [mic] extra).""" + + def __init__( + self, + *, + sample_rate: int = SAMPLE_RATE, + device: int | None = None, + stream_factory: Callable[..., Iterator[bytes]] | None = None, + ) -> None: + self._rate = sample_rate + self._device = device + self._factory = stream_factory or _default_mic_stream + + def __iter__(self) -> Iterator[bytes]: + try: + stream = self._factory(sample_rate=self._rate, device=self._device) + except ImportError as exc: + raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc + except Exception as exc: # noqa: BLE001 - surface device errors cleanly + raise CLIError( + f"Could not open the microphone (device {self._device}): {exc}", + error_type="mic_error", + exit_code=1, + ) from exc + close = getattr(stream, "close", None) + try: + yield from stream + finally: + if callable(close): + close() diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py new file mode 100644 index 00000000..e1106a28 --- /dev/null +++ b/assemblyai_cli/agent/render.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import sys + + +class AgentRenderer: + """Renders Voice Agent events: human transcript lines, or NDJSON for agents. + + Audio payloads are never written; only text/state events are surfaced. + """ + + def __init__(self, *, json_mode: bool, out=None) -> None: + self.json_mode = json_mode + self.out = out if out is not None else sys.stdout + self._partial_open = False + + # --- lifecycle --------------------------------------------------------- + def connected(self) -> None: + if self.json_mode: + self._emit({"type": "session.ready"}) + else: + self._write("Connected — start talking. (Ctrl-C to stop)\n") + + def stopped(self) -> None: + if not self.json_mode: + self._write("Stopped.\n") + + def notice(self, text: str) -> None: + """Write a human-facing notice line (no-op semantics in JSON mode are the caller's choice).""" + self._write(text) + + # --- user -------------------------------------------------------------- + def user_partial(self, text: str) -> None: + if self.json_mode: + self._emit({"type": "transcript.user.delta", "text": text}) + return + self._write("\r\x1b[Kyou: " + text) + self._partial_open = True + + def user_final(self, text: str) -> None: + if self.json_mode: + self._emit({"type": "transcript.user", "text": text}) + return + self._write("\r\x1b[Kyou: " + text + "\n") + self._partial_open = False + + # --- agent ------------------------------------------------------------- + def reply_started(self) -> None: + if self.json_mode: + self._emit({"type": "reply.started"}) + + def agent_transcript(self, text: str, *, interrupted: bool) -> None: + if self.json_mode: + self._emit({"type": "transcript.agent", "text": text, "interrupted": interrupted}) + return + self._finish_partial() + self._write("agent: " + text + "\n") + + def reply_done(self, *, interrupted: bool) -> None: + if self.json_mode: + self._emit({"type": "reply.done", "interrupted": interrupted}) + + # --- teardown ---------------------------------------------------------- + def close(self) -> None: + if self.json_mode: + return + self._finish_partial() + + # --- internals --------------------------------------------------------- + def _finish_partial(self) -> None: + if self._partial_open: + self._partial_open = False + self._write("\n") + + def _emit(self, obj) -> None: + self._write(json.dumps(obj) + "\n") + + def _write(self, text: str) -> None: + try: + self.out.write(text) + self.out.flush() + except Exception: # noqa: BLE001 - downstream pipe may be closed + pass diff --git a/assemblyai_cli/agent/session.py b/assemblyai_cli/agent/session.py new file mode 100644 index 00000000..4bc7e83c --- /dev/null +++ b/assemblyai_cli/agent/session.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import base64 +import json +import threading + +from assemblyai_cli.errors import APIError, CLIError + +WS_URL = "wss://agents.assemblyai.com/v1/ws" + +DEFAULT_PROMPT = ( + "You are a friendly voice assistant having a casual conversation. Keep replies " + "short and natural, usually one or two sentences. Speak the way a person would " + "in real conversation: relaxed, low-key, no exclamation marks." +) +DEFAULT_GREETING = "Hey, what's on your mind?" + +# session.error codes that mean the connection is unauthorized -> exit 2. +_AUTH_ERROR_CODES = {"UNAUTHORIZED", "FORBIDDEN"} + + +class VoiceAgentSession: + """Routes Voice Agent server events to the renderer, player, and duplex state.""" + + def __init__(self, *, renderer, player, full_duplex: bool = False) -> None: + self.renderer = renderer + self.player = player + self.full_duplex = full_duplex + self.ready = False + self.muted = False + + def should_send_audio(self) -> bool: + """True when captured mic frames should be forwarded to the server.""" + return self.ready and not self.muted + + def dispatch(self, event: dict) -> None: + etype = event.get("type") + + if etype == "session.ready": + self.ready = True + self.renderer.connected() + elif etype == "input.speech.started": + if self.full_duplex: + self.player.flush() + elif etype == "input.speech.stopped": + pass + elif etype == "transcript.user.delta": + self.renderer.user_partial(event.get("text", "")) + elif etype == "transcript.user": + self.renderer.user_final(event.get("text", "")) + elif etype == "reply.started": + if not self.full_duplex: + self.muted = True + self.renderer.reply_started() + elif etype == "reply.audio": + data = event.get("data") + if data: + self.player.enqueue(base64.b64decode(data)) + elif etype == "transcript.agent": + self.renderer.agent_transcript( + event.get("text", ""), interrupted=bool(event.get("interrupted", False)) + ) + elif etype == "reply.done": + if not self.full_duplex: + self.muted = False + interrupted = event.get("status") == "interrupted" + if interrupted: + self.player.flush() + self.renderer.reply_done(interrupted=interrupted) + elif etype == "session.error": + self._raise_error(event) + # tool.call and unknown event types: intentionally ignored. + + def _raise_error(self, event: dict) -> None: + code = event.get("code", "") + message = event.get("message") or code or "Voice agent error." + if code in _AUTH_ERROR_CODES: + raise CLIError( + f"Voice agent rejected the connection: {message}", + error_type="unauthorized", + exit_code=2, + ) + raise APIError(f"Voice agent error ({code}): {message}") + + +def _send_audio_loop(ws, session: VoiceAgentSession, mic) -> None: + """Forward mic PCM as input.audio while the session gate allows it.""" + for chunk in mic: + if not session.should_send_audio(): + continue # half-duplex: drop frames while the agent is speaking + payload = base64.b64encode(chunk).decode("ascii") + try: + ws.send(json.dumps({"type": "input.audio", "audio": payload})) + except Exception: # noqa: BLE001 - socket closed; capture thread ends + return + + +def run_session( + api_key: str, + *, + renderer, + player, + mic, + voice: str, + system_prompt: str, + greeting: str, + full_duplex: bool = False, + connect=None, +) -> None: + """Open the Voice Agent WebSocket and run the bidirectional loop until close. + + `connect` defaults to websockets' synchronous client; injectable for tests. + """ + _connect = connect + if _connect is None: + from websockets.sync.client import connect as _connect # noqa: PLC0415 + + session = VoiceAgentSession(renderer=renderer, player=player, full_duplex=full_duplex) + + try: + ws = _connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) + except Exception as exc: # noqa: BLE001 - connect/auth/network failures + raise APIError(f"Could not connect to the voice agent: {exc}") from exc + + player_started = False + try: + player.start() # opens the speaker stream; CLIError here if [mic] is missing + player_started = True + capture = threading.Thread(target=_send_audio_loop, args=(ws, session, mic), daemon=True) + capture.start() + ws.send( + json.dumps( + { + "type": "session.update", + "session": { + "system_prompt": system_prompt, + "greeting": greeting, + "output": {"voice": voice}, + }, + } + ) + ) + for raw in ws: + session.dispatch(json.loads(raw)) + except (CLIError, KeyboardInterrupt): + raise # auth/protocol errors and user Ctrl-C handled upstream + except Exception as exc: # noqa: BLE001 - mid-stream socket/JSON failures + raise APIError(f"Voice agent session failed: {exc}") from exc + finally: + try: + ws.close() + except Exception: # noqa: BLE001 + pass + if player_started: + player.close() diff --git a/assemblyai_cli/agent/voices.py b/assemblyai_cli/agent/voices.py new file mode 100644 index 00000000..11fd3e21 --- /dev/null +++ b/assemblyai_cli/agent/voices.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +# Known Voice Agent voice IDs (from the Voice Agent quickstart). The server is +# the source of truth; this list backs --list-voices and catches obvious typos. +VOICES: list[str] = [ + # English + "ivy", + "james", + "tyler", + "winter", + "sam", + "mia", + "bella", + "david", + "jack", + "kyle", + "helen", + "martha", + "river", + "emma", + "victor", + "eleanor", + "sophie", + "oliver", + # Multilingual + "arjun", + "ethan", + "dmitri", + "lukas", + "lena", + "pierre", + "mina", + "ren", + "mei", + "joon", + "giulia", + "luca", + "lucia", + "hana", + "mateo", + "diego", +] + +DEFAULT_VOICE = "ivy" + + +def format_voice_list() -> str: + """Human-readable, newline-separated voice IDs for --list-voices.""" + return "\n".join(VOICES) diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py new file mode 100644 index 00000000..87b9f17b --- /dev/null +++ b/assemblyai_cli/client.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import assemblyai as aai +from assemblyai.streaming.v3 import ( + SpeechModel, + StreamingClient, + StreamingClientOptions, + StreamingEvents, + StreamingParameters, +) + +from assemblyai_cli.errors import APIError, CLIError + +SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" + + +def _configure(api_key: str) -> None: + aai.settings.api_key = api_key + + +def validate_key(api_key: str) -> bool: + """True if the key authenticates, False on an auth failure. Raises APIError otherwise.""" + _configure(api_key) + try: + aai.Transcriber().list_transcripts(aai.ListTranscriptParameters(limit=1)) + return True + except aai.types.AssemblyAIError as exc: + msg = str(exc).lower() + if "auth" in msg or "token" in msg: + return False + raise APIError(f"Could not validate key: {exc}") from exc + except Exception as exc: # noqa: BLE001 - surface network/SDK failures cleanly + raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc + + +def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object]]: + _configure(api_key) + try: + resp = aai.Transcriber().list_transcripts(aai.ListTranscriptParameters(limit=limit)) + except aai.types.AssemblyAIError as exc: + raise APIError(f"Could not list transcripts: {exc}") from exc + except Exception as exc: # noqa: BLE001 + raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc + return [item.model_dump(mode="json") for item in resp.transcripts] + + +def transcribe(api_key: str, audio: str, *, speaker_labels: bool) -> aai.Transcript: + _configure(api_key) + config = aai.TranscriptionConfig(speaker_labels=speaker_labels) + try: + transcript = aai.Transcriber().transcribe(audio, config=config) + except APIError: + raise + except Exception as exc: # noqa: BLE001 - surface SDK/network failures cleanly + raise APIError(f"Transcription request failed: {exc}") from exc + if transcript.status == aai.TranscriptStatus.error: + raise APIError(transcript.error or "Transcription failed.", transcript_id=transcript.id) + return transcript + + +def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: + _configure(api_key) + try: + return aai.Transcript.get_by_id(transcript_id) + except Exception as exc: # noqa: BLE001 + raise APIError(f"Could not fetch transcript {transcript_id}: {exc}") from exc + + +def stream_audio( + api_key: str, + source, + *, + sample_rate: int, + on_begin=None, + on_turn=None, + on_termination=None, + speech_model: SpeechModel = SpeechModel.universal_streaming_multilingual, +) -> None: + """Stream `source` (an iterable of PCM bytes) through the v3 realtime API. + + Forwards Begin/Turn/Termination events to the callbacks; raises APIError on a stream error. + """ + sc = StreamingClient( + StreamingClientOptions(api_key=api_key, api_host="streaming.assemblyai.com") + ) + errors: list[object] = [] + if on_begin is not None: + sc.on(StreamingEvents.Begin, lambda _client, event: on_begin(event)) + if on_turn is not None: + sc.on(StreamingEvents.Turn, lambda _client, event: on_turn(event)) + if on_termination is not None: + sc.on(StreamingEvents.Termination, lambda _client, event: on_termination(event)) + sc.on(StreamingEvents.Error, lambda _client, error: errors.append(error)) + + try: + sc.connect( + StreamingParameters( + sample_rate=sample_rate, format_turns=True, speech_model=speech_model + ) + ) + except CLIError: + raise + except Exception as exc: # noqa: BLE001 - surface connect/auth/network failures cleanly + raise APIError(f"Could not start streaming session: {exc}") from exc + + try: + sc.stream(source) + except (CLIError, KeyboardInterrupt, BrokenPipeError): + raise # clean CLI errors, user Ctrl-C, and closed-pipe are handled upstream + except Exception as exc: # noqa: BLE001 - surface mid-stream SDK/network failures cleanly + raise APIError(f"Streaming failed: {exc}") from exc + finally: + sc.disconnect(terminate=True) + + if errors: + raise APIError(f"Streaming error: {errors[0]}") diff --git a/assemblyai_cli/commands/__init__.py b/assemblyai_cli/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py new file mode 100644 index 00000000..4390df97 --- /dev/null +++ b/assemblyai_cli/commands/agent.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path + +import typer + +from assemblyai_cli import config +from assemblyai_cli.agent.audio import SAMPLE_RATE, MicCapture, Player +from assemblyai_cli.agent.render import AgentRenderer +from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session +from assemblyai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import CLIError, UsageError + +app = typer.Typer() + + +@app.command() +def agent( + ctx: typer.Context, + voice: str = typer.Option(DEFAULT_VOICE, "--voice", help="Agent voice. See --list-voices."), + prompt: str = typer.Option(DEFAULT_PROMPT, "--prompt", help="System prompt."), + prompt_file: Path = typer.Option( + None, "--prompt-file", help="Read the system prompt from a file (overrides --prompt)." + ), + greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting."), + full_duplex: bool = typer.Option( + False, "--full-duplex", help="Keep the mic open while the agent speaks (needs headphones)." + ), + device: int | None = typer.Option(None, "--device", help="Microphone device index."), + list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), + json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), +) -> None: + """Have a live two-way voice conversation with an AssemblyAI voice agent.""" + + if list_voices: + typer.echo(format_voice_list()) + raise typer.Exit(code=0) + + def body(state, json_mode: bool) -> None: + api_key = config.resolve_api_key(profile=state.profile) + if voice not in VOICES: + raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") + system_prompt = prompt + if prompt_file is not None: + try: + system_prompt = prompt_file.read_text(encoding="utf-8") + except OSError as exc: + raise CLIError( + f"Could not read --prompt-file {prompt_file}: {exc}", + error_type="file_not_found", + exit_code=2, + ) from exc + + renderer = AgentRenderer(json_mode=json_mode) + player = Player(sample_rate=SAMPLE_RATE) + mic = MicCapture(sample_rate=SAMPLE_RATE, device=device) + if not json_mode and not full_duplex: + renderer.notice( + "Half-duplex: mic mutes while the agent talks. " + "Use --full-duplex (with headphones) for barge-in.\n" + ) + try: + run_session( + api_key, + renderer=renderer, + player=player, + mic=mic, + voice=voice, + system_prompt=system_prompt, + greeting=greeting, + full_duplex=full_duplex, + ) + except KeyboardInterrupt: + renderer.stopped() + finally: + renderer.close() + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py new file mode 100644 index 00000000..7590d023 --- /dev/null +++ b/assemblyai_cli/commands/claude.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import typer +from rich.markup import escape + +from assemblyai_cli import output +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import UsageError + +app = typer.Typer(help="Wire up Claude Code for AssemblyAI (docs MCP + skill).") + +MCP_NAME = "assemblyai-docs" +MCP_URL = "https://mcp.assemblyai.com/docs" +SKILL_REPO = "AssemblyAI/assemblyai-skill" +_VALID_SCOPES = ("user", "project", "local") + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True) + + +def _mcp_present() -> bool: + return _run(["claude", "mcp", "get", MCP_NAME]).returncode == 0 + + +def _install_mcp(scope: str, force: bool) -> dict: + if shutil.which("claude") is None: + return { + "name": "mcp", + "status": "skipped", + "detail": ( + "Claude Code not found. Install it (https://claude.com/claude-code), " + f"then run: claude mcp add --transport http --scope {scope} " + f"{MCP_NAME} {MCP_URL}" + ), + } + if _mcp_present(): + if not force: + return {"name": "mcp", "status": "already", "detail": f"{MCP_NAME} already registered"} + removed = _run(["claude", "mcp", "remove", MCP_NAME]) + if removed.returncode != 0: + return { + "name": "mcp", + "status": "failed", + "detail": f"could not remove existing {MCP_NAME}: " + + (removed.stderr or removed.stdout).strip(), + } + proc = _run( + ["claude", "mcp", "add", "--transport", "http", "--scope", scope, MCP_NAME, MCP_URL] + ) + if proc.returncode != 0: + return {"name": "mcp", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} + return {"name": "mcp", "status": "installed", "detail": f"{MCP_NAME} @ {scope} scope"} + + +def _install_skill() -> dict: + if shutil.which("npx") is None: + return { + "name": "skill", + "status": "skipped", + "detail": ( + f"Node.js/npx not found. Install Node.js, then run: npx skills add {SKILL_REPO}" + ), + } + proc = _run(["npx", "skills", "add", SKILL_REPO]) + if proc.returncode != 0: + return {"name": "skill", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} + return {"name": "skill", "status": "installed", "detail": SKILL_REPO} + + +def _skill_dir() -> Path: + return Path.home() / ".claude" / "skills" / "assemblyai" + + +def _mcp_status() -> dict: + if shutil.which("claude") is None: + return {"name": "mcp", "status": "unknown", "detail": "Claude Code not found"} + present = _mcp_present() + return { + "name": "mcp", + "status": "installed" if present else "not_installed", + "detail": MCP_NAME, + } + + +def _skill_status() -> dict: + present = (_skill_dir() / "SKILL.md").exists() + return { + "name": "skill", + "status": "installed" if present else "not_installed", + "detail": str(_skill_dir()), + } + + +def _remove_mcp(scope: str | None) -> dict: + if shutil.which("claude") is None: + return {"name": "mcp", "status": "skipped", "detail": "Claude Code not found"} + if not _mcp_present(): + return {"name": "mcp", "status": "not_installed", "detail": MCP_NAME} + cmd = ["claude", "mcp", "remove", MCP_NAME] + if scope is not None: + cmd += ["--scope", scope] + proc = _run(cmd) + if proc.returncode != 0: + return {"name": "mcp", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} + return {"name": "mcp", "status": "removed", "detail": MCP_NAME} + + +def _remove_skill() -> dict: + target = _skill_dir() + if not target.exists(): + return {"name": "skill", "status": "not_installed", "detail": str(target)} + try: + shutil.rmtree(target) + except OSError as err: + return {"name": "skill", "status": "failed", "detail": str(err)} + return {"name": "skill", "status": "removed", "detail": str(target)} + + +def _render_steps(data: object) -> str: + steps = data["steps"] # type: ignore[index] + lines = [f" {s['name']}: {s['status']} — {escape(str(s['detail']))}" for s in steps] + return "AssemblyAI coding-agent setup:\n" + "\n".join(lines) + + +@app.command() +def install( + ctx: typer.Context, + scope: str = typer.Option( + "user", + "--scope", + help=( + "Config scope to register the MCP under: user, project, or local. " + "Presence is detected across all scopes." + ), + ), + force: bool = typer.Option(False, "--force", help="Reinstall even if already present."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Install the AssemblyAI docs MCP server and skill into Claude Code.""" + + def body(_state, json_mode: bool) -> None: + if scope not in _VALID_SCOPES: + raise UsageError( + f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." + ) + steps = [_install_mcp(scope, force), _install_skill()] + output.emit({"steps": steps}, _render_steps, json_mode=json_mode) + if any(s["status"] == "failed" for s in steps): + raise typer.Exit(code=1) + + run_command(ctx, body, json=json_out) + + +@app.command() +def status( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show whether the AssemblyAI MCP server and skill are wired into Claude Code.""" + + def body(_state, json_mode: bool) -> None: + steps = [_mcp_status(), _skill_status()] + output.emit({"steps": steps}, _render_steps, json_mode=json_mode) + + run_command(ctx, body, json=json_out) + + +@app.command() +def remove( + ctx: typer.Context, + scope: str | None = typer.Option( + None, + "--scope", + help=( + "Only remove the MCP from this scope (user, project, or local). " + "Default: remove from whichever scope it exists in." + ), + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Remove the AssemblyAI MCP server and skill from Claude Code.""" + + def body(_state, json_mode: bool) -> None: + if scope is not None and scope not in _VALID_SCOPES: + raise UsageError( + f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." + ) + steps = [_remove_mcp(scope), _remove_skill()] + output.emit({"steps": steps}, _render_steps, json_mode=json_mode) + if any(s["status"] == "failed" for s in steps): + raise typer.Exit(code=1) + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/login.py b/assemblyai_cli/commands/login.py new file mode 100644 index 00000000..865c65cc --- /dev/null +++ b/assemblyai_cli/commands/login.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import webbrowser + +import typer +from rich.markup import escape + +from assemblyai_cli import client, config, output +from assemblyai_cli.context import resolve_profile, run_command +from assemblyai_cli.errors import APIError, NotAuthenticated + +app = typer.Typer() + +DASHBOARD_KEYS_URL = "https://www.assemblyai.com/dashboard/api-keys" + + +@app.command() +def login( + ctx: typer.Context, + api_key: str = typer.Option(None, "--api-key", help="Provide key non-interactively."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Authenticate by storing an API key (browser-assisted on a terminal).""" + + def body(state, json_mode: bool) -> None: + profile = resolve_profile(state) + key = api_key + if not key: + output.console.print( + f"Opening the AssemblyAI dashboard to get your API key:\n {DASHBOARD_KEYS_URL}" + ) + try: + webbrowser.open(DASHBOARD_KEYS_URL) + except Exception: + output.console.print( + "[dim]Could not open a browser; open the URL above manually.[/dim]" + ) + key = typer.prompt("Paste your API key", hide_input=True) + if not client.validate_key(key): + raise APIError("That API key was rejected (HTTP 401). Check it and retry.") + config.set_api_key(profile, key) + output.emit( + {"authenticated": True, "profile": profile}, + lambda d: f"[green]Authenticated[/green] on profile '{escape(d['profile'])}'.", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command() +def logout( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Clear stored credentials for the active profile.""" + + def body(state, json_mode: bool) -> None: + profile = resolve_profile(state) + config.clear_api_key(profile) + output.emit( + {"logged_out": True, "profile": profile}, + lambda d: f"Logged out of profile '{escape(d['profile'])}'.", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command() +def whoami( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show the active profile and whether its key is usable.""" + + def body(state, json_mode: bool) -> None: + profile = resolve_profile(state) + key = config.get_api_key(profile) + if not key: + raise NotAuthenticated() + masked = f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" + reachable = client.validate_key(key) + output.emit( + {"profile": profile, "api_key": masked, "reachable": reachable}, + lambda d: f"profile={escape(d['profile'])} key={escape(d['api_key'])} reachable={d['reachable']}", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/samples.py b/assemblyai_cli/commands/samples.py new file mode 100644 index 00000000..9fa3678e --- /dev/null +++ b/assemblyai_cli/commands/samples.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import os +from importlib import resources +from pathlib import Path + +import typer +from rich.markup import escape + +from assemblyai_cli import config, output +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import CLIError + +app = typer.Typer(help="Scaffold runnable AssemblyAI starter scripts.") + +# template name -> (template resource filename, output filename) +TEMPLATES = { + "transcribe": ("transcribe.py.tmpl", "transcribe.py"), + "stream": ("stream.py.tmpl", "stream.py"), +} + + +@app.command(name="list") +def list_( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List available sample templates.""" + + def body(_state, json_mode: bool) -> None: + names = sorted(TEMPLATES) + output.emit( + names, + lambda d: "Available samples:\n" + "\n".join(f" - {n}" for n in d), + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command() +def create( + ctx: typer.Context, + name: str = typer.Argument(..., help="Sample name."), + force: bool = typer.Option(False, "--force", help="Overwrite an existing sample file."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Scaffold a runnable starter script with your API key injected.""" + + def body(state, json_mode: bool) -> None: + if name not in TEMPLATES: + raise CLIError( + f"Unknown sample '{name}'. Try: {', '.join(sorted(TEMPLATES))}.", + error_type="unknown_sample", + exit_code=1, + ) + api_key = config.resolve_api_key(profile=state.profile) + tmpl_file, out_file = TEMPLATES[name] + template = resources.files("assemblyai_cli.templates").joinpath(tmpl_file).read_text() + rendered = template.replace("{{API_KEY}}", api_key) + + target_dir = Path.cwd() / name + target_dir.mkdir(parents=True, exist_ok=True) + os.chmod(target_dir, 0o700) + target = target_dir / out_file + if target.exists() and not force: + raise CLIError( + f"{target} already exists. Delete it or pass --force to overwrite.", + error_type="file_exists", + exit_code=1, + ) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(rendered) + os.chmod(target, 0o600) + + output.emit( + {"created": str(target)}, + lambda d: ( + f"Created {escape(d['created'])}\n" + f"[yellow]Note:[/yellow] this file contains your API key — do not commit it.\n" + f"Run it with: python {escape(d['created'])}" + ), + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py new file mode 100644 index 00000000..6dac9247 --- /dev/null +++ b/assemblyai_cli/commands/stream.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import typer + +from assemblyai_cli import client, config +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import UsageError +from assemblyai_cli.streaming.render import StreamRenderer +from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource, MicSource + +app = typer.Typer() + + +@app.command() +def stream( + ctx: typer.Context, + source: str = typer.Argument(None, help="Audio file to stream. Omit to use the microphone."), + sample_rate: int = typer.Option( + TARGET_RATE, "--sample-rate", help="Microphone sample rate in Hz." + ), + device: int | None = typer.Option(None, "--device", help="Microphone device index."), + json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), +) -> None: + """Transcribe live audio from the microphone or a file in real time.""" + + def body(state, json_mode: bool) -> None: + api_key = config.resolve_api_key(profile=state.profile) + if source and (sample_rate != TARGET_RATE or device is not None): + raise UsageError("--sample-rate and --device apply only to microphone input.") + if source: + audio = FileSource(source) + rate = audio.sample_rate + else: + audio = MicSource(sample_rate=sample_rate, device=device) + rate = sample_rate + renderer = StreamRenderer(json_mode=json_mode) + try: + client.stream_audio( + api_key, + audio, + sample_rate=rate, + on_begin=renderer.begin, + on_turn=renderer.turn, + on_termination=renderer.termination, + ) + except KeyboardInterrupt: + # Ctrl-C is a normal "user stopped" signal -> exit 0. + renderer.close() + if not json_mode: + renderer.out.write("Stopped.\n") + renderer.out.flush() + except BrokenPipeError: + # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. + raise typer.Exit(code=0) from None + finally: + renderer.close() + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py new file mode 100644 index 00000000..d97b9047 --- /dev/null +++ b/assemblyai_cli/commands/transcribe.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import typer +from rich.markup import escape + +from assemblyai_cli import client, config, output +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import UsageError + +app = typer.Typer() + + +@app.command() +def transcribe( + ctx: typer.Context, + source: str = typer.Argument(None, help="Audio file path or public URL."), + sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), + speaker_labels: bool = typer.Option(False, "--speaker-labels", help="Enable diarization."), + srt: bool = typer.Option(False, "--srt", help="Output SRT subtitles."), + vtt: bool = typer.Option(False, "--vtt", help="Output VTT subtitles."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Transcribe an audio file or URL and print the result.""" + + def body(state, json_mode: bool) -> None: + if srt and vtt: + raise UsageError("--srt and --vtt are mutually exclusive.") + audio = client.SAMPLE_AUDIO_URL if sample else source + if not audio: + raise UsageError("Provide an audio path/URL or use --sample.") + api_key = config.resolve_api_key(profile=state.profile) + transcript = client.transcribe(api_key, audio, speaker_labels=speaker_labels) + + # Subtitle formats are inherently plain text; --json does not apply here. + if srt: + output.console.print(transcript.export_subtitles_srt(), markup=False) + return + if vtt: + output.console.print(transcript.export_subtitles_vtt(), markup=False) + return + + output.emit( + { + "id": transcript.id, + "status": getattr(transcript.status, "value", transcript.status), + "text": transcript.text, + }, + lambda d: escape(str(d["text"])), + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/transcripts.py b/assemblyai_cli/commands/transcripts.py new file mode 100644 index 00000000..ef7647c7 --- /dev/null +++ b/assemblyai_cli/commands/transcripts.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table + +from assemblyai_cli import client, config, output +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import APIError + +app = typer.Typer() + + +@app.command() +def get( + ctx: typer.Context, + transcript_id: str = typer.Argument(..., help="Transcript id."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Fetch a past transcript by id and print its text.""" + + def body(state, json_mode: bool) -> None: + api_key = config.resolve_api_key(profile=state.profile) + transcript = client.get_transcript(api_key, transcript_id) + if getattr(transcript.status, "value", transcript.status) == "error": + raise APIError( + getattr(transcript, "error", None) or "Transcript failed.", + transcript_id=transcript_id, + ) + output.emit( + { + "id": transcript.id, + "status": getattr(transcript.status, "value", transcript.status), + "text": transcript.text, + }, + lambda d: escape(str(d["text"])), + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command(name="list") +def list_( + ctx: typer.Context, + limit: int = typer.Option(10, "--limit", help="How many transcripts to show."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List recent transcripts.""" + + def body(state, json_mode: bool) -> None: + api_key = config.resolve_api_key(profile=state.profile) + rows = client.list_transcripts(api_key, limit=limit) + + def render(data): + table = Table("id", "status", "created") + for row in data: + table.add_row( + escape(row["id"]), escape(row["status"]), escape(str(row.get("created", ""))) + ) + return table + + output.emit(rows, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/config.py b/assemblyai_cli/config.py new file mode 100644 index 00000000..5d9b8a1a --- /dev/null +++ b/assemblyai_cli/config.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +import re +from pathlib import Path + +import keyring +import keyring.errors # keyring.errors is not re-exported by keyring/__init__ +import platformdirs +import tomli_w +import tomllib + +from assemblyai_cli.errors import NotAuthenticated + +KEYRING_SERVICE = "assemblyai-cli" +ENV_API_KEY = "ASSEMBLYAI_API_KEY" +DEFAULT_PROFILE = "default" + +_PROFILE_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _validate_profile(name: str) -> None: + if not _PROFILE_RE.match(name): + from assemblyai_cli.errors import CLIError + + raise CLIError( + f"Invalid profile name {name!r}: use letters, digits, '-' or '_' only.", + error_type="invalid_profile", + exit_code=2, + ) + + +def config_dir() -> Path: + return Path(platformdirs.user_config_dir("assemblyai")) + + +def _config_file() -> Path: + return config_dir() / "config.toml" + + +def _load() -> dict: + path = _config_file() + if not path.exists(): + return {} + with path.open("rb") as fh: + return tomllib.load(fh) + + +def _dump(data: dict) -> None: + path = _config_file() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as fh: + tomli_w.dump(data, fh) + + +def get_active_profile() -> str: + return _load().get("active_profile", DEFAULT_PROFILE) + + +def set_active_profile(name: str) -> None: + _validate_profile(name) + data = _load() + data["active_profile"] = name + data.setdefault("profiles", {}).setdefault(name, {}) + _dump(data) + + +def set_api_key(profile: str, api_key: str) -> None: + _validate_profile(profile) + keyring.set_password(KEYRING_SERVICE, profile, api_key) + data = _load() + data.setdefault("profiles", {}).setdefault(profile, {}) + data.setdefault("active_profile", profile) + _dump(data) + + +def get_api_key(profile: str) -> str | None: + return keyring.get_password(KEYRING_SERVICE, profile) + + +def clear_api_key(profile: str) -> None: + try: + keyring.delete_password(KEYRING_SERVICE, profile) + except keyring.errors.PasswordDeleteError: + pass + + +def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str: + if api_key_flag is not None: + if not api_key_flag: + from assemblyai_cli.errors import CLIError + + raise CLIError("Empty --api-key provided.", error_type="invalid_key", exit_code=2) + return api_key_flag + env_key = os.environ.get(ENV_API_KEY) + if env_key: + return env_key + profile = profile or get_active_profile() + stored = get_api_key(profile) + if stored: + return stored + raise NotAuthenticated() diff --git a/assemblyai_cli/context.py b/assemblyai_cli/context.py new file mode 100644 index 00000000..2d41ab77 --- /dev/null +++ b/assemblyai_cli/context.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +import typer + +from assemblyai_cli import config, output +from assemblyai_cli.errors import CLIError + + +@dataclass +class AppState: + profile: str | None = None + + +def resolve_profile(state: AppState) -> str: + """The profile to act on: explicit --profile, else the active profile.""" + return state.profile or config.get_active_profile() + + +def run_command( + ctx: typer.Context, fn: Callable[[AppState, bool], None], *, json: bool = False +) -> None: + """Execute a command body, mapping CLIError to clean output + exit code.""" + state: AppState = ctx.obj + json_mode = output.resolve_json(explicit=json) + try: + fn(state, json_mode) + except CLIError as err: + output.emit_error(err, json_mode=json_mode) + raise typer.Exit(code=err.exit_code) from None diff --git a/assemblyai_cli/errors.py b/assemblyai_cli/errors.py new file mode 100644 index 00000000..f4497145 --- /dev/null +++ b/assemblyai_cli/errors.py @@ -0,0 +1,40 @@ +from __future__ import annotations + + +class CLIError(Exception): + """Base error carrying an exit code and a machine-readable type.""" + + def __init__( + self, + message: str, + *, + error_type: str = "error", + exit_code: int = 1, + transcript_id: str | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.error_type = error_type + self.exit_code = exit_code + self.transcript_id = transcript_id + + def to_dict(self) -> dict[str, object]: + body: dict[str, object] = {"type": self.error_type, "message": self.message} + if self.transcript_id is not None: + body["transcript_id"] = self.transcript_id + return {"error": body} + + +class NotAuthenticated(CLIError): + def __init__(self, message: str = "Not authenticated. Run 'aai login'.") -> None: + super().__init__(message, error_type="not_authenticated", exit_code=2) + + +class APIError(CLIError): + def __init__(self, message: str, *, transcript_id: str | None = None) -> None: + super().__init__(message, error_type="api_error", exit_code=1, transcript_id=transcript_id) + + +class UsageError(CLIError): + def __init__(self, message: str) -> None: + super().__init__(message, error_type="usage_error", exit_code=2) diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py new file mode 100644 index 00000000..0c019829 --- /dev/null +++ b/assemblyai_cli/main.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import typer + +from assemblyai_cli import __version__ +from assemblyai_cli.commands import agent, claude, login, samples, stream, transcribe, transcripts +from assemblyai_cli.context import AppState + +app = typer.Typer( + name="aai", + help="Command-line interface for AssemblyAI.", + no_args_is_help=True, + add_completion=False, +) + + +@app.callback() +def main( + ctx: typer.Context, + profile: str = typer.Option(None, "--profile", "-p", help="Named credential profile."), +) -> None: + ctx.obj = AppState(profile=profile) + + +@app.command() +def version() -> None: + """Show the CLI version.""" + typer.echo(__version__) + + +app.add_typer(agent.app) +app.add_typer(login.app) +app.add_typer(stream.app) +app.add_typer(transcribe.app) +app.add_typer(transcripts.app) +app.add_typer(samples.app, name="samples") +app.add_typer(claude.app, name="claude") diff --git a/assemblyai_cli/output.py b/assemblyai_cli/output.py new file mode 100644 index 00000000..66212a32 --- /dev/null +++ b/assemblyai_cli/output.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from assemblyai_cli.errors import CLIError + +from rich.console import Console +from rich.markup import escape + +console = Console() + +_AGENT_ENV_VARS = ("CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT") + + +def _stdout_is_tty() -> bool: + return sys.stdout.isatty() + + +def _is_agentic() -> bool: + if not _stdout_is_tty(): + return True + return any(os.environ.get(var) for var in _AGENT_ENV_VARS) + + +def resolve_json(*, explicit: bool) -> bool: + """JSON output when asked for, or when not attached to an interactive human.""" + return explicit or _is_agentic() + + +def emit(data: object, human_renderer: Callable[[object], object], *, json_mode: bool) -> None: + if json_mode: + print(json.dumps(data, default=str)) + else: + console.print(human_renderer(data)) + + +def emit_error(err: CLIError, *, json_mode: bool) -> None: + if json_mode: + print(json.dumps(err.to_dict(), default=str)) + else: + console.print(f"[red]Error:[/red] {escape(err.message)}") diff --git a/assemblyai_cli/streaming/__init__.py b/assemblyai_cli/streaming/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py new file mode 100644 index 00000000..3aa2bdcd --- /dev/null +++ b/assemblyai_cli/streaming/render.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import sys + + +class StreamRenderer: + """Renders streaming events: a live-updating line for humans, NDJSON for agents.""" + + def __init__(self, *, json_mode: bool, out=None) -> None: + self.json_mode = json_mode + self.out = out if out is not None else sys.stdout + self._line_open = False + + def begin(self, event) -> None: + if self.json_mode: + self._emit({"type": "begin", "id": getattr(event, "id", None)}) + else: + self.out.write("Listening… (Ctrl-C to stop)\n") + self.out.flush() + + def turn(self, event) -> None: + text = getattr(event, "transcript", "") or "" + end = bool(getattr(event, "end_of_turn", False)) + if self.json_mode: + self._emit({"type": "turn", "transcript": text, "end_of_turn": end}) + return + self.out.write("\r\x1b[K" + text) # clear the line, then write the current turn + if end: + self.out.write("\n") + self._line_open = False + else: + self._line_open = True + self.out.flush() + + def termination(self, event) -> None: + if self.json_mode: + self._emit( + { + "type": "termination", + "audio_duration_seconds": getattr(event, "audio_duration_seconds", None), + } + ) + + def close(self) -> None: + """Finalize an in-progress (no-newline) human line, so later output starts clean.""" + if self.json_mode or not self._line_open: + return + self._line_open = False + try: + self.out.write("\n") + self.out.flush() + except Exception: # noqa: BLE001 - the downstream pipe may already be closed + pass + + def _emit(self, obj) -> None: + self.out.write(json.dumps(obj) + "\n") + self.out.flush() diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py new file mode 100644 index 00000000..77956dd3 --- /dev/null +++ b/assemblyai_cli/streaming/sources.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import contextlib +import shutil +import subprocess +import time +import wave +from collections.abc import Iterator +from pathlib import Path + +from assemblyai_cli.errors import APIError, CLIError + +TARGET_RATE = 16000 +CHUNK_BYTES = TARGET_RATE * 2 // 10 # 100 ms of 16-bit mono PCM + + +def _is_streamable_wav(path: Path) -> bool: + try: + with wave.open(str(path), "rb") as w: + return ( + w.getnchannels() == 1 and w.getsampwidth() == 2 and w.getframerate() == TARGET_RATE + ) + except (wave.Error, EOFError, OSError): + return False + + +class FileSource: + """Yields real-time-paced 16 kHz mono PCM chunks from an audio file.""" + + def __init__(self, path: str, *, sleep=time.sleep) -> None: + self.path = Path(path) + self._sleep = sleep + self.sample_rate = TARGET_RATE + if not self.path.is_file(): + raise CLIError(f"No such file: {self.path}", error_type="file_not_found", exit_code=2) + self._wav = _is_streamable_wav(self.path) + if not self._wav and shutil.which("ffmpeg") is None: + raise CLIError( + "This audio format needs ffmpeg. Install ffmpeg, or pass a 16 kHz mono 16-bit WAV.", + error_type="ffmpeg_missing", + exit_code=2, + ) + + def __iter__(self) -> Iterator[bytes]: + chunks = self._wav_chunks() if self._wav else self._ffmpeg_chunks() + produced = 0 + for chunk in chunks: + produced += len(chunk) + yield chunk + self._sleep(len(chunk) / (TARGET_RATE * 2)) # ~real-time pacing + if produced == 0: + raise CLIError(f"No audio data in {self.path}.", error_type="empty_audio", exit_code=2) + + def _wav_chunks(self) -> Iterator[bytes]: + frames_per_chunk = CHUNK_BYTES // 2 + with wave.open(str(self.path), "rb") as w: + while True: + data = w.readframes(frames_per_chunk) + if not data: + return + yield data + + def _ffmpeg_chunks(self) -> Iterator[bytes]: + proc = subprocess.Popen( + [ + "ffmpeg", + "-nostdin", + "-loglevel", + "error", + "-i", + str(self.path), + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-ac", + "1", + "-ar", + str(TARGET_RATE), + "-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + while True: + data = proc.stdout.read(CHUNK_BYTES) + if not data: + break + yield data + finally: + proc.terminate() + with contextlib.suppress(Exception): + proc.stdout.close() + proc.wait() + # Reached only on natural EOF (not early generator close): surface a + # decode failure instead of silently streaming nothing. + if proc.returncode: + detail = proc.stderr.read().decode("utf-8", "replace").strip() if proc.stderr else "" + raise APIError( + f"ffmpeg could not decode {self.path}: {detail or f'exit {proc.returncode}'}" + ) + + +def _load_microphone_stream(): + """Import the SDK's PyAudio-backed mic stream (isolated for testing/patching).""" + from assemblyai.extras import MicrophoneStream + + return MicrophoneStream + + +class MicSource: + """Yields PCM chunks from the default microphone (requires the [mic] extra).""" + + def __init__(self, *, sample_rate: int, device: int | None = None) -> None: + self.sample_rate = sample_rate + self.device = device + + def __iter__(self) -> Iterator[bytes]: + try: + microphone_stream_cls = _load_microphone_stream() + stream = microphone_stream_cls(sample_rate=self.sample_rate, device_index=self.device) + except ImportError as exc: + raise CLIError( + "Microphone support isn't installed. Run: pip install 'assemblyai-cli[mic]'", + error_type="mic_missing", + exit_code=2, + ) from exc + except Exception as exc: # noqa: BLE001 - surface device errors cleanly + raise CLIError( + f"Could not open the microphone (device {self.device}): {exc}", + error_type="mic_error", + exit_code=1, + ) from exc + try: + yield from stream + finally: + stream.close() diff --git a/assemblyai_cli/templates/__init__.py b/assemblyai_cli/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assemblyai_cli/templates/stream.py.tmpl b/assemblyai_cli/templates/stream.py.tmpl new file mode 100644 index 00000000..9dcfed36 --- /dev/null +++ b/assemblyai_cli/templates/stream.py.tmpl @@ -0,0 +1,38 @@ +# Real-time microphone transcription. +# Requires microphone support: pip install "assemblyai[extras]" +import assemblyai as aai +from assemblyai.streaming.v3 import ( + SpeechModel, + StreamingClient, + StreamingClientOptions, + StreamingEvents, + StreamingParameters, + TurnEvent, +) + +aai.settings.api_key = "{{API_KEY}}" + + +def on_turn(client: StreamingClient, event: TurnEvent) -> None: + print(event.transcript, end="\r", flush=True) + if event.end_of_turn: + print() + + +client = StreamingClient( + StreamingClientOptions(api_key="{{API_KEY}}", api_host="streaming.assemblyai.com") +) +client.on(StreamingEvents.Turn, on_turn) +client.connect( + StreamingParameters( + sample_rate=16000, + format_turns=True, + speech_model=SpeechModel.universal_streaming_multilingual, + ) +) + +print("Listening… press Ctrl-C to stop.") +try: + client.stream(aai.extras.MicrophoneStream(sample_rate=16000)) +finally: + client.disconnect(terminate=True) diff --git a/assemblyai_cli/templates/transcribe.py.tmpl b/assemblyai_cli/templates/transcribe.py.tmpl new file mode 100644 index 00000000..521206d9 --- /dev/null +++ b/assemblyai_cli/templates/transcribe.py.tmpl @@ -0,0 +1,11 @@ +import assemblyai as aai + +aai.settings.api_key = "{{API_KEY}}" + +transcriber = aai.Transcriber() +transcript = transcriber.transcribe("https://assembly.ai/wildfires.mp3") + +if transcript.status == aai.TranscriptStatus.error: + raise RuntimeError(transcript.error) + +print(transcript.text) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..c00fce12 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "assemblyai-cli" +version = "0.1.0" +description = "Command-line interface for AssemblyAI" +requires-python = ">=3.10" +dependencies = [ + "typer>=0.12", + "assemblyai>=0.34", + "rich>=13.0", + "keyring>=24.0", + "platformdirs>=4.0", + "tomli-w>=1.0", + "websockets>=13", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.11", "pre-commit>=4.0"] +mic = ["pyaudio>=0.2.11"] + +[project.scripts] +aai = "assemblyai_cli.main:app" + +[tool.hatch.build.targets.wheel] +packages = ["assemblyai_cli"] + +[tool.hatch.build.targets.wheel.force-include] +"assemblyai_cli/templates/transcribe.py.tmpl" = "assemblyai_cli/templates/transcribe.py.tmpl" +"assemblyai_cli/templates/stream.py.tmpl" = "assemblyai_cli/templates/stream.py.tmpl" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +# E501: line length is owned by the formatter. +# B008: Typer uses function calls (typer.Option/Argument) as parameter defaults. +ignore = ["E501", "B008"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..57bf0330 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +import keyring +import pytest +from keyring.backend import KeyringBackend + + +class MemoryKeyring(KeyringBackend): + priority = 1 + + def __init__(self): + self._store = {} + + def get_password(self, service, username): + return self._store.get((service, username)) + + def set_password(self, service, username, password): + self._store[(service, username)] = password + + def delete_password(self, service, username): + if (service, username) not in self._store: + import keyring.errors + + raise keyring.errors.PasswordDeleteError("not found") + del self._store[(service, username)] + + +@pytest.fixture(autouse=True) +def isolate_env(monkeypatch): + for var in ("ASSEMBLYAI_API_KEY", "CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NO_COLOR"): + monkeypatch.delenv(var, raising=False) + + +@pytest.fixture(autouse=True) +def memory_keyring(): + backend = MemoryKeyring() + keyring.set_keyring(backend) + return backend + + +@pytest.fixture(autouse=True) +def tmp_config(monkeypatch, tmp_path): + cfg_dir = tmp_path / "config" + cfg_dir.mkdir() + monkeypatch.setattr("assemblyai_cli.config.config_dir", lambda: cfg_dir) + return cfg_dir diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py new file mode 100644 index 00000000..f3fde3e8 --- /dev/null +++ b/tests/test_agent_audio.py @@ -0,0 +1,85 @@ +import pytest + +from assemblyai_cli.agent.audio import MicCapture, Player +from assemblyai_cli.errors import CLIError + + +class FakeStream: + def __init__(self): + self.writes = [] + self.stopped = False + self.closed = False + + def write(self, data): + self.writes.append(data) + + def stop_stream(self): + self.stopped = True + + def close(self): + self.closed = True + + +def test_player_writes_enqueued_audio(): + fake = FakeStream() + p = Player(sample_rate=24000, stream_factory=lambda rate: fake) + p.start() + p.enqueue(b"\x01\x02") + p.enqueue(b"\x03\x04") + p.close() # drains the queue, then tears down + assert b"\x01\x02" in fake.writes + assert b"\x03\x04" in fake.writes + assert fake.stopped + assert fake.closed + + +def test_player_flush_discards_pending_audio(): + fake = FakeStream() + p = Player(sample_rate=24000, stream_factory=lambda rate: fake) + # Do NOT start the worker; queue items directly so flush is deterministic. + p.enqueue(b"stale-1") + p.enqueue(b"stale-2") + p.flush() + assert p.pending() == 0 + + +def test_miccapture_yields_chunks_from_factory(): + def fake_factory(*, sample_rate, device): + assert sample_rate == 24000 + return iter([b"aa", b"bb"]) + + mic = MicCapture(sample_rate=24000, device=None, stream_factory=fake_factory) + assert list(mic) == [b"aa", b"bb"] + + +def test_miccapture_missing_dependency_raises_cli_error(): + def boom(*, sample_rate, device): + raise ImportError("no pyaudio") + + mic = MicCapture(sample_rate=24000, device=None, stream_factory=boom) + with pytest.raises(CLIError) as excinfo: + list(mic) + assert excinfo.value.exit_code == 2 + assert "assemblyai-cli[mic]" in excinfo.value.message + + +def test_player_worker_survives_write_error(): + class BoomStream(FakeStream): + def write(self, data): + raise RuntimeError("device gone") + + p = Player(sample_rate=24000, stream_factory=lambda rate: BoomStream()) + p.start() + p.enqueue(b"\x01\x02") + p.close() # must return (join has a timeout); thread must not be alive + assert p._thread is not None and not p._thread.is_alive() + + +def test_miccapture_device_error_raises_cli_error_exit_1(): + def boom(*, sample_rate, device): + raise RuntimeError("bad device") + + mic = MicCapture(sample_rate=24000, device=None, stream_factory=boom) + with pytest.raises(CLIError) as excinfo: + list(mic) + assert excinfo.value.exit_code == 1 diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py new file mode 100644 index 00000000..ed5bed6b --- /dev/null +++ b/tests/test_agent_command.py @@ -0,0 +1,124 @@ +import json + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def test_agent_help_lists_command(): + result = runner.invoke(app, ["agent", "--help"]) + assert result.exit_code == 0 + assert "voice" in result.output.lower() + + +def test_list_voices_prints_and_exits_without_connecting(monkeypatch): + called = {"ran": False} + + def fake_run_session(*a, **k): + called["ran"] = True + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + result = runner.invoke(app, ["agent", "--list-voices"]) + assert result.exit_code == 0 + assert "ivy" in result.output + assert called["ran"] is False + + +def test_agent_unauthenticated_exits_2(): + result = runner.invoke(app, ["agent"]) + assert result.exit_code == 2 + + +def test_agent_drives_renderer_json(monkeypatch): + config.set_api_key("default", "sk_live") + + def fake_run_session( + api_key, + *, + renderer, + player, + mic, + voice, + system_prompt, + greeting, + full_duplex=False, + ): + renderer.connected() + renderer.user_final("hello agent") + renderer.agent_transcript("hello human", interrupted=False) + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + result = runner.invoke(app, ["agent", "--json"]) + assert result.exit_code == 0 + lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] + assert {"type": "transcript.user", "text": "hello agent"} in lines + assert {"type": "transcript.agent", "text": "hello human", "interrupted": False} in lines + + +def test_agent_passes_voice_and_prompt_file(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + seen = {} + + def fake_run_session( + api_key, + *, + renderer, + player, + mic, + voice, + system_prompt, + greeting, + full_duplex=False, + ): + seen["voice"] = voice + seen["prompt"] = system_prompt + seen["full_duplex"] = full_duplex + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + prompt_file = tmp_path / "p.txt" + prompt_file.write_text("be a pirate") + result = runner.invoke( + app, + [ + "agent", + "--voice", + "james", + "--prompt-file", + str(prompt_file), + "--prompt", + "ignored", + "--full-duplex", + ], + ) + assert result.exit_code == 0 + assert seen["voice"] == "james" + assert seen["prompt"] == "be a pirate" # --prompt-file overrides --prompt + assert seen["full_duplex"] is True + + +def test_agent_ctrl_c_exits_cleanly(monkeypatch): + config.set_api_key("default", "sk_live") + + def raise_kbd(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", raise_kbd) + result = runner.invoke(app, ["agent"]) + assert result.exit_code == 0 + + +def test_agent_unknown_voice_exits_2(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + result = runner.invoke(app, ["agent", "--voice", "not-a-voice"]) + assert result.exit_code == 2 + + +def test_agent_prompt_file_not_found_exits_2(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + result = runner.invoke(app, ["agent", "--prompt-file", "/tmp/no_such_file_xyz_voiceagent.txt"]) + assert result.exit_code == 2 diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py new file mode 100644 index 00000000..c9665933 --- /dev/null +++ b/tests/test_agent_render.py @@ -0,0 +1,83 @@ +import io +import json + +from assemblyai_cli.agent.render import AgentRenderer + + +def _json_lines(buf: io.StringIO): + return [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()] + + +def test_json_emits_user_and_agent_events(): + buf = io.StringIO() + r = AgentRenderer(json_mode=True, out=buf) + r.connected() + r.user_final("hello there") + r.agent_transcript("hi back", interrupted=False) + lines = _json_lines(buf) + assert {"type": "session.ready"} in lines + assert {"type": "transcript.user", "text": "hello there"} in lines + assert { + "type": "transcript.agent", + "text": "hi back", + "interrupted": False, + } in lines + + +def test_json_never_emits_audio_bytes(): + buf = io.StringIO() + r = AgentRenderer(json_mode=True, out=buf) + r.reply_started() + r.reply_done(interrupted=True) + text = buf.getvalue() + assert "data" not in text # no base64 audio leaks + lines = _json_lines(buf) + assert {"type": "reply.started"} in lines + assert {"type": "reply.done", "interrupted": True} in lines + + +def test_human_partial_updates_in_place_then_finalizes(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.user_partial("what is") + r.user_final("what is the time") + out = buf.getvalue() + assert "\r\x1b[K" in out # cleared the line for the partial + assert "what is the time" in out # finalized text present + assert out.endswith("\n") # finalized line ends clean + + +def test_human_agent_line_labeled(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.agent_transcript("the time is noon", interrupted=False) + out = buf.getvalue() + assert out.startswith("agent: ") + assert "the time is noon" in out + + +def test_close_finalizes_open_partial_line(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.user_partial("half a sen") + r.close() + assert buf.getvalue().endswith("\n") + + +def test_json_stopped_is_silent(): + buf = io.StringIO() + AgentRenderer(json_mode=True, out=buf).stopped() + assert buf.getvalue() == "" + + +def test_json_close_is_silent(): + buf = io.StringIO() + AgentRenderer(json_mode=True, out=buf).close() + assert buf.getvalue() == "" + + +def test_notice_writes_to_buffer_in_human_mode(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.notice("hi") + assert buf.getvalue() == "hi" diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py new file mode 100644 index 00000000..e7ad9062 --- /dev/null +++ b/tests/test_agent_session.py @@ -0,0 +1,134 @@ +import pytest + +from assemblyai_cli.agent.session import VoiceAgentSession +from assemblyai_cli.errors import APIError, CLIError + + +class FakeRenderer: + def __init__(self): + self.calls = [] + + def connected(self): + self.calls.append(("connected",)) + + def user_partial(self, text): + self.calls.append(("user_partial", text)) + + def user_final(self, text): + self.calls.append(("user_final", text)) + + def reply_started(self): + self.calls.append(("reply_started",)) + + def agent_transcript(self, text, *, interrupted): + self.calls.append(("agent_transcript", text, interrupted)) + + def reply_done(self, *, interrupted): + self.calls.append(("reply_done", interrupted)) + + +class FakePlayer: + def __init__(self): + self.enqueued = [] + self.flushed = 0 + + def enqueue(self, pcm): + self.enqueued.append(pcm) + + def flush(self): + self.flushed += 1 + + +def _session(*, full_duplex=False): + return VoiceAgentSession( + renderer=FakeRenderer(), + player=FakePlayer(), + full_duplex=full_duplex, + ) + + +def test_ready_opens_gate_and_announces(): + s = _session() + assert s.ready is False + s.dispatch({"type": "session.ready", "session_id": "sess_1"}) + assert s.ready is True + assert ("connected",) in s.renderer.calls + + +def test_half_duplex_mutes_during_reply(): + s = _session(full_duplex=False) + s.dispatch({"type": "session.ready"}) + s.dispatch({"type": "reply.started"}) + assert s.muted is True + s.dispatch({"type": "reply.done"}) + assert s.muted is False + + +def test_full_duplex_never_mutes_and_flushes_on_speech_start(): + s = _session(full_duplex=True) + s.dispatch({"type": "session.ready"}) + s.dispatch({"type": "reply.started"}) + assert s.muted is False + s.dispatch({"type": "input.speech.started"}) + assert s.player.flushed == 1 + + +def test_reply_audio_is_decoded_and_enqueued(): + import base64 + + s = _session() + payload = base64.b64encode(b"\x10\x20").decode() + s.dispatch({"type": "reply.audio", "data": payload}) + assert s.player.enqueued == [b"\x10\x20"] + + +def test_interrupted_reply_done_flushes_playback(): + s = _session() + s.dispatch({"type": "reply.done", "status": "interrupted"}) + assert s.player.flushed == 1 + assert ("reply_done", True) in s.renderer.calls + + +def test_transcripts_routed_to_renderer(): + s = _session() + s.dispatch({"type": "transcript.user.delta", "text": "what"}) + s.dispatch({"type": "transcript.user", "text": "what time"}) + s.dispatch({"type": "transcript.agent", "text": "noon", "interrupted": False}) + assert ("user_partial", "what") in s.renderer.calls + assert ("user_final", "what time") in s.renderer.calls + assert ("agent_transcript", "noon", False) in s.renderer.calls + + +def test_unauthorized_error_raises_cli_error_exit_2(): + s = _session() + with pytest.raises(CLIError) as excinfo: + s.dispatch({"type": "session.error", "code": "UNAUTHORIZED", "message": "bad key"}) + assert excinfo.value.exit_code == 2 + + +def test_other_session_error_raises_api_error(): + s = _session() + with pytest.raises(APIError): + s.dispatch({"type": "session.error", "code": "invalid_value", "message": "bad voice"}) + + +def test_unknown_and_tool_events_are_ignored(): + s = _session() + s.dispatch({"type": "tool.call", "call_id": "c1", "name": "x", "arguments": {}}) + s.dispatch({"type": "something.new"}) + assert s.renderer.calls == [] # nothing surfaced, no exception + + +def test_reply_audio_without_data_is_ignored(): + s = _session() + s.dispatch({"type": "reply.audio"}) # no data key + s.dispatch({"type": "reply.audio", "data": ""}) # empty data + assert s.player.enqueued == [] + + +def test_full_duplex_reply_started_announces_without_muting(): + s = _session(full_duplex=True) + s.dispatch({"type": "session.ready"}) + s.dispatch({"type": "reply.started"}) + assert s.muted is False + assert ("reply_started",) in s.renderer.calls diff --git a/tests/test_agent_voices.py b/tests/test_agent_voices.py new file mode 100644 index 00000000..6e5b3b34 --- /dev/null +++ b/tests/test_agent_voices.py @@ -0,0 +1,26 @@ +from assemblyai_cli.agent import voices + + +def test_voices_includes_default(): + assert "ivy" in voices.VOICES + + +def test_voices_are_unique_and_nonempty(): + assert voices.VOICES + assert len(voices.VOICES) == len(set(voices.VOICES)) + + +def test_format_voice_list_mentions_voices(): + out = voices.format_voice_list() + assert "ivy" in out + assert "james" in out + + +def test_default_voice_is_in_voices(): + assert voices.DEFAULT_VOICE in voices.VOICES + + +def test_format_voice_list_contains_all_voices(): + out = voices.format_voice_list() + for v in voices.VOICES: + assert v in out diff --git a/tests/test_claude.py b/tests/test_claude.py new file mode 100644 index 00000000..401d0689 --- /dev/null +++ b/tests/test_claude.py @@ -0,0 +1,315 @@ +import json +import subprocess + +from typer.testing import CliRunner + +from assemblyai_cli.main import app + +runner = CliRunner() + + +class FakeRun: + """Records subprocess calls and returns canned CompletedProcess results. + + `returncodes` maps a command prefix tuple (the first N argv tokens) to a + return code; the longest matching prefix wins, default 0. + """ + + def __init__(self, returncodes=None): + self.calls = [] + self.returncodes = returncodes or {} + + def __call__(self, cmd, *args, **kwargs): + self.calls.append(cmd) + rc = 0 + best = -1 + for prefix, code in self.returncodes.items(): + n = len(prefix) + if tuple(cmd[:n]) == prefix and n > best: + rc, best = code, n + return subprocess.CompletedProcess(args=cmd, returncode=rc, stdout="", stderr="boom") + + +def _all_tools_present(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: f"/usr/bin/{tool}", + ) + + +def test_install_happy_path_runs_both_steps(monkeypatch): + _all_tools_present(monkeypatch) + # MCP not yet present -> `mcp get` returns non-zero. + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 + + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses == {"mcp": "installed", "skill": "installed"} + + assert [ + "claude", + "mcp", + "add", + "--transport", + "http", + "--scope", + "user", + "assemblyai-docs", + "https://mcp.assemblyai.com/docs", + ] in fake.calls + assert ["npx", "skills", "add", "AssemblyAI/assemblyai-skill"] in fake.calls + + +def test_install_scope_passthrough(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install", "--scope", "project"]) + assert result.exit_code == 0 + assert [ + "claude", + "mcp", + "add", + "--transport", + "http", + "--scope", + "project", + "assemblyai-docs", + "https://mcp.assemblyai.com/docs", + ] in fake.calls + + +def test_install_invalid_scope_exits_2(monkeypatch): + _all_tools_present(monkeypatch) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", FakeRun()) + result = runner.invoke(app, ["claude", "install", "--scope", "bogus"]) + assert result.exit_code == 2 + + +def test_install_idempotent_when_mcp_present(monkeypatch): + _all_tools_present(monkeypatch) + # `mcp get` returns 0 -> already registered. + fake = FakeRun({("claude", "mcp", "get"): 0}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["mcp"] == "already" + # No `mcp add` should have run. + assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_install_force_removes_then_adds(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 0}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install", "--force"]) + assert result.exit_code == 0 + assert ["claude", "mcp", "remove", "assemblyai-docs"] in fake.calls + assert any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_install_skips_mcp_when_claude_missing(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", + ) + fake = FakeRun() + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 # skip is not a failure + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["mcp"] == "skipped" + assert statuses["skill"] == "installed" + assert not any(c[0] == "claude" for c in fake.calls) + + +def test_install_skips_skill_when_npx_missing(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", + ) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["skill"] == "skipped" + assert statuses["mcp"] == "installed" + assert not any(c[0] == "npx" for c in fake.calls) + + +def test_install_failure_exits_nonzero(monkeypatch): + _all_tools_present(monkeypatch) + # mcp not present, but `mcp add` fails. + fake = FakeRun({("claude", "mcp", "get"): 1, ("claude", "mcp", "add"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 1 + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["mcp"] == "failed" + + +def test_install_force_remove_failure_reports_failed(monkeypatch): + _all_tools_present(monkeypatch) + # present, but the forced remove fails + fake = FakeRun({("claude", "mcp", "get"): 0, ("claude", "mcp", "remove"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install", "--force"]) + assert result.exit_code == 1 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses["mcp"] == "failed" + assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_status_reports_both_installed(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + skill = tmp_path / ".claude" / "skills" / "assemblyai" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + # `mcp get` returns 0 -> present. + monkeypatch.setattr( + "assemblyai_cli.commands.claude.subprocess.run", + FakeRun({("claude", "mcp", "get"): 0}), + ) + + result = runner.invoke(app, ["claude", "status"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "installed", "skill": "installed"} + + +def test_status_reports_not_installed(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir created + monkeypatch.setattr( + "assemblyai_cli.commands.claude.subprocess.run", + FakeRun({("claude", "mcp", "get"): 1}), + ) + + result = runner.invoke(app, ["claude", "status"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "not_installed", "skill": "not_installed"} + + +def test_status_mcp_unknown_when_claude_missing(monkeypatch, tmp_path): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", + ) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", FakeRun()) + + result = runner.invoke(app, ["claude", "status"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses["mcp"] == "unknown" + + +def test_remove_unwinds_both(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + skill = tmp_path / ".claude" / "skills" / "assemblyai" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + fake = FakeRun({("claude", "mcp", "get"): 0}) # present -> removable + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "remove"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "removed", "skill": "removed"} + assert ["claude", "mcp", "remove", "assemblyai-docs"] in fake.calls + assert not skill.exists() + + +def test_remove_when_absent_is_not_an_error(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir + fake = FakeRun({("claude", "mcp", "get"): 1}) # absent + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "remove"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "not_installed", "skill": "not_installed"} + assert not any(c[:3] == ["claude", "mcp", "remove"] for c in fake.calls) + + +def test_remove_skill_failure_reports_failed(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + skill = tmp_path / ".claude" / "skills" / "assemblyai" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + # MCP absent so only the skill step can fail. + monkeypatch.setattr( + "assemblyai_cli.commands.claude.subprocess.run", + FakeRun({("claude", "mcp", "get"): 1}), + ) + + def boom(_path): + raise PermissionError("locked") + + monkeypatch.setattr("assemblyai_cli.commands.claude.shutil.rmtree", boom) + + result = runner.invoke(app, ["claude", "remove"]) + assert result.exit_code == 1 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses["skill"] == "failed" + + +def test_install_scope_local_passthrough(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install", "--scope", "local"]) + assert result.exit_code == 0 + assert [ + "claude", + "mcp", + "add", + "--transport", + "http", + "--scope", + "local", + "assemblyai-docs", + "https://mcp.assemblyai.com/docs", + ] in fake.calls + + +def test_remove_scope_passthrough(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + fake = FakeRun({("claude", "mcp", "get"): 0}) # present + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "remove", "--scope", "project"]) + assert result.exit_code == 0 + assert ["claude", "mcp", "remove", "assemblyai-docs", "--scope", "project"] in fake.calls + + +def test_claude_help_lists_all_subcommands(): + result = runner.invoke(app, ["claude", "--help"]) + assert result.exit_code == 0 + assert "install" in result.output + assert "status" in result.output + assert "remove" in result.output diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..a9a83b38 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,231 @@ +import types as _types +from unittest.mock import MagicMock, patch + +import assemblyai as aai +import pytest + +from assemblyai_cli import client +from assemblyai_cli.errors import APIError + + +def test_validate_key_true_on_success(): + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.return_value = MagicMock() + assert client.validate_key("sk_good") is True + + +def test_validate_key_false_on_auth_error(): + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( + "Authentication error, API token missing/invalid" + ) + assert client.validate_key("sk_bad") is False + + +def test_validate_key_raises_on_other_sdk_error(): + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError("server exploded") + with pytest.raises(APIError): + client.validate_key("sk") + + +def test_validate_key_raises_on_network_error(): + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.side_effect = ConnectionError("boom") + with pytest.raises(APIError): + client.validate_key("sk") + + +def test_list_transcripts_returns_dict_rows(): + item = MagicMock() + item.model_dump.return_value = {"id": "t1", "status": "completed", "created": "2026-01-01"} + resp = MagicMock() + resp.transcripts = [item] + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.return_value = resp + rows = client.list_transcripts("sk", limit=5) + assert rows == [{"id": "t1", "status": "completed", "created": "2026-01-01"}] + item.model_dump.assert_called_once_with(mode="json") + + +def test_list_transcripts_auth_error_becomes_apierror(): + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError("nope") + with pytest.raises(APIError): + client.list_transcripts("sk") + + +def test_transcribe_blocks_and_returns_transcript(): + fake_transcript = MagicMock() + fake_transcript.status = client.aai.TranscriptStatus.completed + fake_transcriber = MagicMock() + fake_transcriber.transcribe.return_value = fake_transcript + + with ( + patch.object(client.aai, "Transcriber", return_value=fake_transcriber), + patch.object(client.aai, "TranscriptionConfig") as cfg, + ): + result = client.transcribe("sk", "audio.mp3", speaker_labels=True) + + cfg.assert_called_once_with(speaker_labels=True) + fake_transcriber.transcribe.assert_called_once() + assert result is fake_transcript + + +def test_transcribe_raises_on_error_status(): + fake_transcript = MagicMock() + fake_transcript.status = client.aai.TranscriptStatus.error + fake_transcript.error = "decode failed" + fake_transcript.id = "t_err" + fake_transcriber = MagicMock() + fake_transcriber.transcribe.return_value = fake_transcript + + with ( + patch.object(client.aai, "Transcriber", return_value=fake_transcriber), + patch.object(client.aai, "TranscriptionConfig"), + ): + with pytest.raises(APIError) as exc: + client.transcribe("sk", "audio.mp3", speaker_labels=False) + assert exc.value.transcript_id == "t_err" + + +def test_get_transcript_calls_sdk(): + fake = MagicMock() + with patch.object(client.aai.Transcript, "get_by_id", return_value=fake) as g: + result = client.get_transcript("sk", "t_123") + g.assert_called_once_with("t_123") + assert result is fake + + +class _FakeStreamingClient: + last = None + + def __init__(self, options): + self.handlers = {} + self.connected = False + self.disconnected = False + _FakeStreamingClient.last = self + + def on(self, event, handler): + self.handlers[event] = handler + + def connect(self, params): + self.connected = True + self.params = params + + def stream(self, source): + from assemblyai.streaming.v3 import StreamingEvents + + self.handlers[StreamingEvents.Turn]( + self, _types.SimpleNamespace(transcript="hi", end_of_turn=True) + ) + + def disconnect(self, terminate=False): + self.disconnected = True + self.terminate = terminate + + +def test_stream_audio_wires_handlers_and_streams(monkeypatch): + monkeypatch.setattr(client, "StreamingClient", _FakeStreamingClient) + turns = [] + client.stream_audio( + "sk", [b"\x00"], sample_rate=16000, on_turn=lambda e: turns.append(e.transcript) + ) + assert turns == ["hi"] + assert _FakeStreamingClient.last.connected + assert _FakeStreamingClient.last.disconnected # disconnected in finally + assert _FakeStreamingClient.last.params.sample_rate == 16000 + assert _FakeStreamingClient.last.params.format_turns is True + assert _FakeStreamingClient.last.terminate is True # graceful flush requested + + +def test_stream_audio_raises_on_error_event(monkeypatch): + class ErrClient(_FakeStreamingClient): + def stream(self, source): + from assemblyai.streaming.v3 import StreamingEvents + + self.handlers[StreamingEvents.Error](self, "boom") + + monkeypatch.setattr(client, "StreamingClient", ErrClient) + with pytest.raises(APIError): + client.stream_audio("sk", [b"\x00"], sample_rate=16000) + + +def test_stream_audio_forwards_termination(monkeypatch): + class TermClient(_FakeStreamingClient): + def stream(self, source): + from assemblyai.streaming.v3 import StreamingEvents + + self.handlers[StreamingEvents.Termination]( + self, _types.SimpleNamespace(audio_duration_seconds=3.0) + ) + + monkeypatch.setattr(client, "StreamingClient", TermClient) + seen = [] + client.stream_audio( + "sk", + [b"\x00"], + sample_rate=16000, + on_termination=lambda e: seen.append(e.audio_duration_seconds), + ) + assert seen == [3.0] + + +def test_stream_audio_connect_error_becomes_apierror(monkeypatch): + class ConnectFails(_FakeStreamingClient): + def connect(self, params): + raise RuntimeError("handshake refused") + + monkeypatch.setattr(client, "StreamingClient", ConnectFails) + with pytest.raises(APIError): + client.stream_audio("sk", [b"\x00"], sample_rate=16000) + + +def test_stream_audio_mid_stream_error_becomes_apierror(monkeypatch): + class StreamFails(_FakeStreamingClient): + def stream(self, source): + raise RuntimeError("socket dropped") + + monkeypatch.setattr(client, "StreamingClient", StreamFails) + with pytest.raises(APIError): + client.stream_audio("sk", [b"\x00"], sample_rate=16000) + assert StreamFails.last.disconnected # still disconnected in finally + + +def test_stream_audio_passes_through_clierror(monkeypatch): + from assemblyai_cli.errors import CLIError + + class StreamRaisesCLIError(_FakeStreamingClient): + def stream(self, source): + raise CLIError("boom", error_type="x", exit_code=2) + + monkeypatch.setattr(client, "StreamingClient", StreamRaisesCLIError) + with pytest.raises(CLIError) as exc: + client.stream_audio("sk", [b"\x00"], sample_rate=16000) + assert exc.value.exit_code == 2 # not rewrapped into APIError + + +def test_stream_audio_flushes_termination_on_disconnect(monkeypatch): + class DeferredTermClient(_FakeStreamingClient): + def stream(self, source): + pass # nothing dispatched during stream; the server flushes on terminate + + def disconnect(self, terminate=False): + self.disconnected = True + self.terminate = terminate + if terminate: + from assemblyai.streaming.v3 import StreamingEvents + + self.handlers[StreamingEvents.Termination]( + self, _types.SimpleNamespace(audio_duration_seconds=5.0) + ) + + monkeypatch.setattr(client, "StreamingClient", DeferredTermClient) + seen = [] + client.stream_audio( + "sk", + [b"\x00"], + sample_rate=16000, + on_termination=lambda e: seen.append(e.audio_duration_seconds), + ) + assert seen == [5.0] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..d718bfa3 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,77 @@ +import pytest + +from assemblyai_cli import config +from assemblyai_cli.errors import NotAuthenticated + + +def test_set_and_get_api_key_roundtrip(): + config.set_api_key("default", "sk_abc") + assert config.get_api_key("default") == "sk_abc" + + +def test_resolve_prefers_flag(monkeypatch): + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "from_env") + config.set_api_key("default", "from_keyring") + assert config.resolve_api_key(api_key_flag="from_flag") == "from_flag" + + +def test_resolve_prefers_env_over_keyring(monkeypatch): + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "from_env") + config.set_api_key("default", "from_keyring") + assert config.resolve_api_key() == "from_env" + + +def test_resolve_falls_back_to_keyring(): + config.set_api_key("default", "from_keyring") + assert config.resolve_api_key() == "from_keyring" + + +def test_resolve_raises_when_missing(): + with pytest.raises(NotAuthenticated): + config.resolve_api_key() + + +def test_active_profile_defaults_to_default(): + assert config.get_active_profile() == "default" + + +def test_set_active_profile_persists(): + config.set_active_profile("staging") + assert config.get_active_profile() == "staging" + + +def test_logout_clears_key(): + config.set_api_key("default", "sk_abc") + config.clear_api_key("default") + assert config.get_api_key("default") is None + + +def test_clear_api_key_missing_is_silent(): + # Should not raise even though nothing was stored for this profile. + config.clear_api_key("never_set") + assert config.get_api_key("never_set") is None + + +def test_invalid_profile_name_rejected(): + import pytest + + from assemblyai_cli.errors import CLIError + + with pytest.raises(CLIError): + config.set_api_key("bad name!", "sk_x") + + +def test_empty_api_key_flag_rejected(): + import pytest + + from assemblyai_cli.errors import CLIError + + with pytest.raises(CLIError): + config.resolve_api_key(api_key_flag="") + + +def test_config_roundtrips_after_special_value(tmp_path, monkeypatch): + # active profile name is validated; this checks tomli_w writes valid TOML for normal data + config.set_api_key("default", "sk_x") + config.set_active_profile("staging") + assert config.get_active_profile() == "staging" diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 00000000..e1373f5c --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,40 @@ +import typer +from typer.testing import CliRunner + +from assemblyai_cli.context import AppState, run_command +from assemblyai_cli.errors import NotAuthenticated + +runner = CliRunner() + + +def _make_app(body): + app = typer.Typer() + + @app.callback() + def cb(ctx: typer.Context): + ctx.obj = AppState() + + @app.command() + def go(ctx: typer.Context): + run_command(ctx, body) + + return app + + +def test_run_command_maps_cli_error_to_exit_code(): + def body(state, json_mode): + raise NotAuthenticated() + + result = runner.invoke(_make_app(body), ["go"]) + assert result.exit_code == 2 + + +def test_run_command_runs_body_on_success(): + seen = {} + + def body(state, json_mode): + seen["ran"] = True + + result = runner.invoke(_make_app(body), ["go"]) + assert result.exit_code == 0 + assert seen.get("ran") is True diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..71fca8db --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,22 @@ +from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated + + +def test_not_authenticated_defaults(): + err = NotAuthenticated() + assert err.exit_code == 2 + assert err.error_type == "not_authenticated" + assert "aai login" in str(err) + + +def test_api_error_carries_fields(): + err = APIError("boom", transcript_id="t_123") + assert err.exit_code == 1 + assert err.error_type == "api_error" + assert err.to_dict() == { + "error": {"type": "api_error", "message": "boom", "transcript_id": "t_123"} + } + + +def test_to_dict_omits_none_transcript_id(): + err = CLIError("nope", error_type="generic", exit_code=1) + assert err.to_dict() == {"error": {"type": "generic", "message": "nope"}} diff --git a/tests/test_login.py b/tests/test_login.py new file mode 100644 index 00000000..f79155bb --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,54 @@ +from unittest.mock import patch + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def test_login_with_api_key_flag_stores_key(): + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_flag" + + +def test_login_rejects_invalid_key(): + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=False): + result = runner.invoke(app, ["login", "--api-key", "sk_bad"]) + assert result.exit_code != 0 + assert config.get_api_key("default") is None + + +def test_login_stores_under_named_profile(): + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["--profile", "staging", "login", "--api-key", "sk_s"]) + assert result.exit_code == 0 + assert config.get_api_key("staging") == "sk_s" + + +def test_whoami_reports_authenticated(): + import json + + config.set_api_key("default", "sk_1234567890") + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["whoami", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["profile"] == "default" + assert data["reachable"] is True + assert data["api_key"].startswith("sk_") and "…" in data["api_key"] + + +def test_whoami_unauthenticated_exits_2(): + result = runner.invoke(app, ["whoami"]) + assert result.exit_code == 2 + + +def test_logout_clears_key(): + config.set_api_key("default", "sk_1234567890") + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0 + assert config.get_api_key("default") is None diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..311c73f4 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,50 @@ +import json + +from assemblyai_cli import output + + +def test_resolve_json_true_when_explicit(monkeypatch): + monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) + assert output.resolve_json(explicit=True) is True + + +def test_resolve_json_true_when_not_tty(monkeypatch): + monkeypatch.setattr(output, "_stdout_is_tty", lambda: False) + assert output.resolve_json(explicit=False) is True + + +def test_resolve_json_true_in_ci(monkeypatch): + monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) + monkeypatch.setenv("CI", "true") + assert output.resolve_json(explicit=False) is True + + +def test_resolve_json_true_for_agent(monkeypatch): + monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) + monkeypatch.setenv("CLAUDECODE", "1") + assert output.resolve_json(explicit=False) is True + + +def test_resolve_json_false_for_human(monkeypatch): + monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) + assert output.resolve_json(explicit=False) is False + + +def test_emit_json_serializes(capsys): + output.emit({"a": 1}, lambda d: "human", json_mode=True) + out = capsys.readouterr().out + assert json.loads(out) == {"a": 1} + + +def test_emit_human_uses_renderer(capsys): + output.emit({"a": 1}, lambda d: f"value={d['a']}", json_mode=False) + assert "value=1" in capsys.readouterr().out + + +def test_emit_error_escapes_markup(capsys): + import types + + err = types.SimpleNamespace(message="bad [tag] here", to_dict=lambda: {"error": {}}) + output.emit_error(err, json_mode=False) + out = capsys.readouterr().out + assert "[tag]" in out # not stripped as markup diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 00000000..760b1f8a --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,86 @@ +from pathlib import Path + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def test_samples_list_shows_transcribe(): + result = runner.invoke(app, ["samples", "list"]) + assert result.exit_code == 0 + assert "transcribe" in result.output + + +def test_samples_list_shows_templates(): + result = runner.invoke(app, ["samples", "list"]) + assert result.exit_code == 0 + assert "transcribe" in result.output + assert "stream" in result.output + + +def test_samples_create_stream_writes_script_with_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + config.set_api_key("default", "sk_injected") + result = runner.invoke(app, ["samples", "create", "stream"]) + assert result.exit_code == 0 + script = Path(tmp_path, "stream", "stream.py") + assert script.exists() + body = script.read_text() + assert "sk_injected" in body + assert "{{API_KEY}}" not in body + assert "MicrophoneStream" in body + + +def test_samples_create_writes_script_with_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + config.set_api_key("default", "sk_injected") + result = runner.invoke(app, ["samples", "create", "transcribe"]) + assert result.exit_code == 0 + script = Path(tmp_path, "transcribe", "transcribe.py") + assert script.exists() + body = script.read_text() + assert "sk_injected" in body + assert "{{API_KEY}}" not in body + + +def test_samples_create_unknown_name_errors(): + result = runner.invoke(app, ["samples", "create", "nope"]) + assert result.exit_code == 1 + + +def test_samples_create_unauthenticated_exits_2(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["samples", "create", "transcribe"]) + assert result.exit_code == 2 + + +def test_samples_create_refuses_existing_without_force(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + config.set_api_key("default", "sk_injected") + assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 + # Second run without --force must refuse. + result = runner.invoke(app, ["samples", "create", "transcribe"]) + assert result.exit_code == 1 + + +def test_samples_create_force_overwrites(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + config.set_api_key("default", "sk_injected") + assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 + result = runner.invoke(app, ["samples", "create", "transcribe", "--force"]) + assert result.exit_code == 0 + + +def test_samples_create_file_is_owner_only(tmp_path, monkeypatch): + import stat + + monkeypatch.chdir(tmp_path) + config.set_api_key("default", "sk_injected") + assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 + from pathlib import Path + + mode = stat.S_IMODE(Path(tmp_path, "transcribe", "transcribe.py").stat().st_mode) + assert mode == 0o600 diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 00000000..94284282 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,30 @@ +from typer.testing import CliRunner + +from assemblyai_cli.main import app + +runner = CliRunner() + + +def test_help_runs(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "AssemblyAI" in result.output + + +def test_version_command(): + from assemblyai_cli import __version__ + + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert result.output.strip() == __version__ + + +def test_global_flags_parse(): + # --profile is a global option accepted before a subcommand + assert runner.invoke(app, ["--profile", "staging", "version"]).exit_code == 0 + + +def test_stream_registered_top_level(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "stream" in result.output diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py new file mode 100644 index 00000000..d3708a95 --- /dev/null +++ b/tests/test_stream_command.py @@ -0,0 +1,121 @@ +import json +import types + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def _drive_turns(api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None): + # Simulate the streaming client driving the renderer callbacks. + if on_begin: + on_begin(types.SimpleNamespace(id="sess")) + if on_turn: + on_turn(types.SimpleNamespace(transcript="hello world", end_of_turn=True)) + + +def test_stream_help_lists_command(): + result = runner.invoke(app, ["stream", "--help"]) + assert result.exit_code == 0 + assert "microphone" in result.output.lower() + + +def test_stream_mic_renders_turns(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _drive_turns) + result = runner.invoke(app, ["stream", "--json"]) + assert result.exit_code == 0 + lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] + assert {"type": "turn", "transcript": "hello world", "end_of_turn": True} in lines + + +def test_stream_file_uses_filesource(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + seen = {} + + def fake_stream_audio( + api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None + ): + seen["source_type"] = type(source).__name__ + seen["rate"] = sample_rate + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + import wave + + p = tmp_path / "a.wav" + with wave.open(str(p), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + result = runner.invoke(app, ["stream", str(p)]) + assert result.exit_code == 0 + assert seen["source_type"] == "FileSource" + assert seen["rate"] == 16000 + + +def test_stream_unauthenticated_exits_2(): + result = runner.invoke(app, ["stream"]) + assert result.exit_code == 2 + + +def test_stream_ctrl_c_exits_cleanly(monkeypatch): + config.set_api_key("default", "sk_live") + + def raise_kbd(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", raise_kbd) + result = runner.invoke(app, ["stream"]) + assert result.exit_code == 0 + + +def test_stream_file_with_sample_rate_flag_rejected(tmp_path): + config.set_api_key("default", "sk_live") + import wave + + p = tmp_path / "a.wav" + with wave.open(str(p), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + result = runner.invoke(app, ["stream", str(p), "--sample-rate", "44100"]) + assert result.exit_code == 2 + + +def test_stream_broken_pipe_exits_zero(monkeypatch): + config.set_api_key("default", "sk_live") + + def raise_broken_pipe(*a, **k): + raise BrokenPipeError + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", raise_broken_pipe) + result = runner.invoke(app, ["stream"]) + assert result.exit_code == 0 + + +def test_stream_file_json_output(monkeypatch, tmp_path): + import json as _json + import wave + + config.set_api_key("default", "sk_live") + + def fake(api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None): + if on_turn: + on_turn(types.SimpleNamespace(transcript="from file", end_of_turn=True)) + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + p = tmp_path / "a.wav" + with wave.open(str(p), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + result = runner.invoke(app, ["stream", str(p), "--json"]) + assert result.exit_code == 0 + lines = [_json.loads(x) for x in result.output.splitlines() if x.strip()] + assert {"type": "turn", "transcript": "from file", "end_of_turn": True} in lines diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py new file mode 100644 index 00000000..c28d0359 --- /dev/null +++ b/tests/test_streaming_render.py @@ -0,0 +1,83 @@ +import io +import json +import types + +from assemblyai_cli.streaming.render import StreamRenderer + + +def _turn(transcript, end_of_turn): + return types.SimpleNamespace(transcript=transcript, end_of_turn=end_of_turn) + + +def test_human_turn_finalizes_on_end_of_turn(): + out = io.StringIO() + r = StreamRenderer(json_mode=False, out=out) + r.turn(_turn("hello", False)) + r.turn(_turn("hello world", True)) + text = out.getvalue() + assert "hello world" in text + assert text.endswith("\n") + + +def test_json_mode_emits_ndjson_events(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.begin(types.SimpleNamespace(id="sess_1")) + r.turn(_turn("hi", True)) + lines = [json.loads(line) for line in out.getvalue().splitlines()] + assert lines[0] == {"type": "begin", "id": "sess_1"} + assert lines[1] == {"type": "turn", "transcript": "hi", "end_of_turn": True} + + +def test_human_begin_prints_notice(): + out = io.StringIO() + StreamRenderer(json_mode=False, out=out).begin(types.SimpleNamespace(id="x")) + assert "Ctrl-C" in out.getvalue() + + +def test_human_shorter_turn_leaves_no_trailing_padding(): + out = io.StringIO() + r = StreamRenderer(json_mode=False, out=out) + r.turn(_turn("hello world", False)) # long partial + r.turn(_turn("hi", True)) # shorter, finalized + # No leftover characters from the longer partial; finalized line ends clean. + assert out.getvalue().endswith("hi\n") + assert "hello world\n" not in out.getvalue() + + +def test_termination_json_emits_duration(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.termination(types.SimpleNamespace(audio_duration_seconds=12.5)) + import json as _json + + assert _json.loads(out.getvalue()) == { + "type": "termination", + "audio_duration_seconds": 12.5, + } + + +def test_close_finalizes_open_partial_line(): + out = io.StringIO() + r = StreamRenderer(json_mode=False, out=out) + r.turn(_turn("partial", False)) # no end_of_turn -> line left open + r.close() + assert out.getvalue().endswith("\n") + + +def test_close_is_noop_when_line_already_finalized(): + out = io.StringIO() + r = StreamRenderer(json_mode=False, out=out) + r.turn(_turn("done", True)) # finalized with newline + before = out.getvalue() + r.close() + assert out.getvalue() == before # no extra newline + + +def test_close_is_noop_in_json_mode(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.turn(_turn("hi", False)) + before = out.getvalue() + r.close() + assert out.getvalue() == before diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py new file mode 100644 index 00000000..0f01b594 --- /dev/null +++ b/tests/test_streaming_sources.py @@ -0,0 +1,188 @@ +import io +import wave + +import pytest + +from assemblyai_cli.errors import CLIError +from assemblyai_cli.streaming import sources +from assemblyai_cli.streaming.sources import FileSource + + +def _write_wav(path, *, seconds=0.5, rate=16000): + frames = int(rate * seconds) + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(b"\x00\x01" * frames) # 2 bytes/frame, mono 16-bit + + +def test_filesource_streams_wav_chunks(tmp_path): + p = tmp_path / "clip.wav" + _write_wav(p, seconds=0.55) # 0.55s @16k mono 16-bit = 17600 bytes + src = FileSource(str(p), sleep=lambda _s: None) + chunks = list(src) + assert sum(len(c) for c in chunks) == 17600 + assert all(len(c) <= sources.CHUNK_BYTES for c in chunks) + assert len(chunks) == 6 # 5 full 3200-byte chunks + one 1600-byte tail + assert len(chunks[-1]) == 1600 + + +def test_filesource_missing_file_raises(): + with pytest.raises(CLIError) as exc: + FileSource("/no/such/file.wav") + assert exc.value.exit_code == 2 + + +def test_filesource_non_wav_without_ffmpeg_raises(tmp_path, monkeypatch): + p = tmp_path / "clip.mp3" + p.write_bytes(b"not really audio") + monkeypatch.setattr(sources.shutil, "which", lambda _name: None) + with pytest.raises(CLIError) as exc: + FileSource(str(p)) + assert exc.value.error_type == "ffmpeg_missing" + + +def test_filesource_uses_ffmpeg_for_non_wav(tmp_path, monkeypatch): + p = tmp_path / "clip.mp3" + p.write_bytes(b"not really audio") + monkeypatch.setattr(sources.shutil, "which", lambda _name: "/usr/bin/ffmpeg") + + class FakeProc: + def __init__(self): + self.stdout = io.BytesIO(b"\x00" * 3200 + b"\x01" * 100) + self.stderr = io.BytesIO(b"") + self.returncode = 0 + + def terminate(self): + pass + + def wait(self): + pass + + monkeypatch.setattr(sources.subprocess, "Popen", lambda *a, **k: FakeProc()) + chunks = list(FileSource(str(p), sleep=lambda _s: None)) + assert chunks == [b"\x00" * 3200, b"\x01" * 100] + + +def test_filesource_ffmpeg_cleanup_on_early_stop(tmp_path, monkeypatch): + p = tmp_path / "clip.mp3" + p.write_bytes(b"x") + monkeypatch.setattr(sources.shutil, "which", lambda _name: "/usr/bin/ffmpeg") + calls = {"terminated": False, "waited": False, "closed": False} + + class FakeProc: + def __init__(self): + self.stdout = self + self.stderr = io.BytesIO(b"") + self.returncode = 0 + + def read(self, _n): + return b"\x00" * 3200 # endless + + def close(self): + calls["closed"] = True + + def terminate(self): + calls["terminated"] = True + + def wait(self): + calls["waited"] = True + + monkeypatch.setattr(sources.subprocess, "Popen", lambda *a, **k: FakeProc()) + gen = iter(FileSource(str(p), sleep=lambda _s: None)) + next(gen) # pull one chunk + gen.close() # stop early -> generator cleanup runs the finally + assert calls["terminated"] and calls["waited"] and calls["closed"] + + +def test_filesource_ffmpeg_failure_raises(tmp_path, monkeypatch): + p = tmp_path / "bad.mp3" + p.write_bytes(b"x") + monkeypatch.setattr(sources.shutil, "which", lambda _n: "/usr/bin/ffmpeg") + + class FailProc: + def __init__(self): + self.stdout = io.BytesIO(b"") + self.stderr = io.BytesIO(b"Invalid data found") + self.returncode = 1 + + def terminate(self): + pass + + def wait(self): + pass + + monkeypatch.setattr(sources.subprocess, "Popen", lambda *a, **k: FailProc()) + from assemblyai_cli.errors import APIError + + with pytest.raises(APIError): + list(sources.FileSource(str(p), sleep=lambda _s: None)) + + +def test_filesource_empty_wav_raises(tmp_path): + p = tmp_path / "empty.wav" + with wave.open(str(p), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"") + with pytest.raises(CLIError) as exc: + list(FileSource(str(p), sleep=lambda _s: None)) + assert exc.value.error_type == "empty_audio" + + +def test_micsource_missing_dependency_raises(monkeypatch): + def boom(): + raise ImportError("No module named 'pyaudio'") + + monkeypatch.setattr(sources, "_load_microphone_stream", boom) + with pytest.raises(CLIError) as exc: + list(sources.MicSource(sample_rate=16000)) + assert exc.value.error_type == "mic_missing" + assert "assemblyai-cli[mic]" in exc.value.message + + +def test_micsource_yields_from_microphone_stream(monkeypatch): + captured = {} + + class FakeMic: + def __init__(self, sample_rate, device_index): + captured["rate"] = sample_rate + captured["device"] = device_index + + def __iter__(self): + return iter([b"\x00\x01", b"\x02\x03"]) + + def close(self): + captured["closed"] = True + + monkeypatch.setattr(sources, "_load_microphone_stream", lambda: FakeMic) + chunks = list(sources.MicSource(sample_rate=16000, device=2)) + assert chunks == [b"\x00\x01", b"\x02\x03"] + assert captured == {"rate": 16000, "device": 2, "closed": True} + + +def test_micsource_missing_dependency_at_construction(monkeypatch): + class ExtrasMissing(ImportError): + pass + + class FakeMic: + def __init__(self, sample_rate, device_index): + raise ExtrasMissing("You must install the extras") + + monkeypatch.setattr(sources, "_load_microphone_stream", lambda: FakeMic) + with pytest.raises(CLIError) as exc: + list(sources.MicSource(sample_rate=16000)) + assert exc.value.error_type == "mic_missing" + + +def test_micsource_device_error_becomes_clierror(monkeypatch): + class FakeMic: + def __init__(self, sample_rate, device_index): + raise OSError("Invalid device") + + monkeypatch.setattr(sources, "_load_microphone_stream", lambda: FakeMic) + with pytest.raises(CLIError) as exc: + list(sources.MicSource(sample_rate=16000, device=99)) + assert exc.value.error_type == "mic_error" diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py new file mode 100644 index 00000000..1fb6569c --- /dev/null +++ b/tests/test_transcribe.py @@ -0,0 +1,110 @@ +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_api_key("default", "sk_live") + + +def _fake_transcript(): + t = MagicMock() + t.id = "t_1" + t.text = "hello world" + t.status = "completed" + t.export_subtitles_srt.return_value = "1\n00:00\nhello" + t.export_subtitles_vtt.return_value = "WEBVTT\nhello" + return t + + +def test_transcribe_sample_prints_text(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + result = runner.invoke(app, ["transcribe", "--sample"]) + assert result.exit_code == 0 + assert "hello world" in result.output + audio_arg = tx.call_args.args[1] + assert audio_arg.endswith("wildfires.mp3") + + +def test_transcribe_requires_source(): + _auth() + result = runner.invoke(app, ["transcribe"]) + assert result.exit_code == 2 + + +def test_transcribe_passes_speaker_labels(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) + assert tx.call_args.kwargs["speaker_labels"] is True + + +def test_transcribe_srt_export(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--srt"]) + assert "00:00" in result.output + + +def test_transcribe_vtt_export(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--vtt"]) + assert "WEBVTT" in result.output + + +def test_transcribe_srt_vtt_mutually_exclusive(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--srt", "--vtt"]) + assert result.exit_code != 0 + + +def test_transcribe_json_output(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) + assert '"id": "t_1"' in result.output + + +def test_transcribe_unauthenticated_exits_2(): + result = runner.invoke(app, ["transcribe", "--sample"]) + assert result.exit_code == 2 + + +def test_transcribe_status_renders_enum_value(): + import assemblyai as aai + + _auth() + t = _fake_transcript() + t.status = aai.TranscriptStatus.completed + with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) + assert result.exit_code == 0 + assert '"status": "completed"' in result.output + + +def test_transcribe_srt_vtt_conflict_json_error(): + _auth() + result = runner.invoke(app, ["transcribe", "audio.mp3", "--srt", "--vtt", "--json"]) + assert result.exit_code == 2 + # In --json mode the error is a JSON envelope, not Typer usage text. + assert '"error"' in result.output diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py new file mode 100644 index 00000000..f147db6c --- /dev/null +++ b/tests/test_transcripts.py @@ -0,0 +1,47 @@ +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def test_get_prints_transcript_text(): + config.set_api_key("default", "sk_live") + fake = MagicMock() + fake.id = "t_42" + fake.text = "retrieved text" + fake.status = "completed" + with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + result = runner.invoke(app, ["get", "t_42"]) + assert result.exit_code == 0 + assert "retrieved text" in result.output + + +def test_list_renders_rows(): + config.set_api_key("default", "sk_live") + rows = [{"id": "t1", "status": "completed"}, {"id": "t2", "status": "processing"}] + with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + assert "t1" in result.output and "t2" in result.output + + +def test_list_unauthenticated_exits_2(): + result = runner.invoke(app, ["list"]) + assert result.exit_code == 2 + + +def test_get_errored_transcript_exits_nonzero(): + config.set_api_key("default", "sk_live") + from unittest.mock import MagicMock + + fake = MagicMock() + fake.id = "t_err" + fake.status = "error" + fake.error = "decode failed" + with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + result = runner.invoke(app, ["get", "t_err"]) + assert result.exit_code == 1 From 5183955f548ef5a77f52df1e825b2ceef772224a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 07:36:22 -0700 Subject: [PATCH 02/87] Add CI, install script, auth-error handling, and cleanup CI: - scripts/check.sh runs ruff (lint + format), mypy, and pytest - .github/workflows/ci.yml runs it on every PR and push to main Type safety: - generic output.emit(); typed audio stream, ffmpeg stdout, stream source - [tool.mypy] config (ignore_missing_imports); mypy is now green Robustness: - clean "not authenticated" errors across all commands: detect rejected keys (incl. the Voice Agent's 1008 policy-violation close) and raise NotAuthenticated instead of a raw protocol/APIError - `aai claude install` no longer hangs: detach child stdin, add a timeout, and pass `npx -y` so an invisible prompt can't block forever Install + docs: - install.sh for `curl -fsSL .../install.sh | sh` (pipx/pip, no clone) - README rewritten with the curl install as the top path - remove DEMO.md; gitignore .claude/, .mypy_cache/, docs/ Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 25 +++++++ .gitignore | 5 ++ DEMO.md | 90 ----------------------- README.md | 108 ++++++++++++++++++---------- assemblyai_cli/agent/audio.py | 4 +- assemblyai_cli/agent/session.py | 7 +- assemblyai_cli/client.py | 14 +++- assemblyai_cli/commands/claude.py | 24 ++++++- assemblyai_cli/commands/login.py | 6 +- assemblyai_cli/commands/stream.py | 1 + assemblyai_cli/errors.py | 33 +++++++++ assemblyai_cli/output.py | 10 +-- assemblyai_cli/streaming/sources.py | 1 + install.sh | 42 +++++++++++ pyproject.toml | 8 ++- scripts/check.sh | 19 +++++ tests/test_agent_session.py | 75 ++++++++++++++++++- tests/test_claude.py | 21 +++++- tests/test_client.py | 11 +++ tests/test_errors.py | 14 +++- 20 files changed, 373 insertions(+), 145 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 DEMO.md create mode 100755 install.sh create mode 100755 scripts/check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2524665b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + check: + name: lint + typecheck + tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: python -m pip install -e ".[dev]" + + - name: Lint, typecheck, test + run: ./scripts/check.sh diff --git a/.gitignore b/.gitignore index d1575b58..f5e90c54 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ dist/ build/ .pytest_cache/ .ruff_cache/ +.mypy_cache/ + +# Editor/agent and local planning artifacts +.claude/ +docs/ # Local scratch scripts (often contain live keys) transcribe/ diff --git a/DEMO.md b/DEMO.md deleted file mode 100644 index 8af62ac0..00000000 --- a/DEMO.md +++ /dev/null @@ -1,90 +0,0 @@ -# AssemblyAI CLI (`aai`) — Demo - -The story: **signup → first successful transcript in two commands**, then show it's built for how developers and agents actually work. Paste the blocks in order. - -## Setup - -Point the CLI at your key. (In a real first-run you'd run `aai login` and paste the key from the browser; this is the repeatable, no-browser path.) - -```bash -export ASSEMBLYAI_API_KEY=... # your key -aai login --api-key "$ASSEMBLYAI_API_KEY" -aai whoami -``` - -`login` validates the key and stores it in your OS keyring (not a plaintext file); `whoami` confirms which profile is active and that the key reaches the API. - -## 1. Zero to transcript in one command - -```bash -aai transcribe --sample -``` - -This transcribes AssemblyAI's hosted sample (`wildfires.mp3`). The submit-and-poll loop is handled for you — one command returns the finished text in a few seconds. - -## 2. Built for scripts and agents - -```bash -aai transcribe --sample --json | jq -``` - -The same command emits clean JSON. The CLI auto-detects when it isn't talking to a human (piped, CI, or an AI agent) and switches to JSON automatically — so in your terminal you get readable text, and in a pipeline you get something parseable. (`jq` just pretty-prints; you can drop it.) - -## 3. Captions, straight out of the box - -```bash -aai transcribe --sample --srt | head -n 8 -``` - -Add `--srt` (or `--vtt`) to get timestamped subtitles from the same command — no extra steps. - -## 4. Everything you've run is queryable - -```bash -aai list --limit 5 -aai get # fetch any past transcript by id -``` - -`list` shows recent transcripts; `get` retrieves one by id (handy for re-fetching a long job later). - -## 5. Get into your own code instantly - -```bash -aai samples create transcribe -cat transcribe/transcribe.py -python transcribe/transcribe.py -``` - -`samples create` scaffolds a runnable starter script with your key already wired in — zero edits — and it just runs. (The generated file contains your key, so don't commit it.) - -## 6. Real-time streaming (file or microphone) - -Stream a file and watch the transcript build in real time — no microphone, no extra dependency (16 kHz mono WAV streams directly; other formats use `ffmpeg`): - -```bash -aai stream recording.wav -``` - -Or transcribe live from your microphone. The mic backend is an optional extra so the base install (and CI) stays lightweight: - -```bash -pip install "assemblyai-cli[mic]" -aai stream # start talking; Ctrl-C to stop -``` - -Partial words appear and refine as you speak, finalizing at the end of each turn. Add `--json` for newline-delimited JSON events (one per turn) — ideal for piping into another process or an agent: - -```bash -aai stream recording.wav --json -``` - -Streaming uses AssemblyAI's v3 realtime API under the hood; the CLI just hands it your microphone or file and renders the turns. - ---- - -**The whole onboarding is `aai login` → `aai transcribe`.** Everything else is a convenience on top. - -### Tips -- Have `jq` installed for the prettiest JSON output. -- For a *first-time-user* story, use plain `aai login` to show the browser-assisted paste flow instead of `--api-key`. -- `aai logout` clears the stored key when you're done. diff --git a/README.md b/README.md index 5b864032..d8a7041e 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,91 @@ # AssemblyAI CLI (`aai`) -Onboarding CLI for AssemblyAI: `aai login` then `aai transcribe --sample`. +A command-line interface for [AssemblyAI](https://www.assemblyai.com): transcribe +files, stream live audio, and have two-way voice conversations — all from your terminal. -## Install (dev) +## Install - pip install -e ".[dev]" +```sh +curl -fsSL https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh +``` -## Usage +The installer uses [`pipx`](https://pipx.pypa.io) when available (falling back to +`pip --user`) and requires Python 3.10+. Prefer to do it yourself: - aai login - aai transcribe --sample +```sh +pipx install "git+https://github.com/AssemblyAI/cli.git" # or: pip install --user ... +``` -## Streaming - -Real-time transcription from a file (no extra dependency): +Microphone input (for `stream` and `agent`) needs the optional `[mic]` extra: - aai stream path/to/audio.wav # 16 kHz mono WAV streams directly - aai stream path/to/audio.mp3 # other formats require ffmpeg on PATH +```sh +pipx install "assemblyai-cli[mic] @ git+https://github.com/AssemblyAI/cli.git" +``` -From the microphone (install the optional extra first): +## Quick start - pip install "assemblyai-cli[mic]" - aai stream # Ctrl-C to stop +```sh +aai login # store your API key (browser-assisted) +aai transcribe --sample # transcribe the hosted wildfires.mp3 sample +``` -Add `--json` for newline-delimited JSON events (also the default when piped or run by an agent). +## Commands -## AI coding agents +| Command | What it does | +| --- | --- | +| `aai login` / `logout` / `whoami` | Manage the stored API key. | +| `aai transcribe ` | Transcribe an audio file or URL (`--sample` for a demo, `--srt`/`--vtt` for subtitles). | +| `aai transcripts list` / `get ` | Browse and fetch past transcripts. | +| `aai stream [file]` | Real-time transcription from a file or the microphone. | +| `aai agent` | Live two-way voice conversation with a voice agent. | +| `aai claude install` | Wire Claude Code up to AssemblyAI's docs + skill. | +| `aai samples create ` | Scaffold a runnable starter script with your key injected. | -Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI -skill so your agent writes current, correct integration code: +Add `--json` to any command for machine-readable output (it's also the default when +output is piped or run by an agent). Auth problems surface as a clean +"not authenticated" error across every command. - aai claude install # installs the docs MCP server + skill (user scope) - aai claude status # show what's wired up - aai claude remove # unwind both +## Streaming -`install` shells out to `claude mcp add` for the docs MCP server and to -`npx skills add` for the skill. Pass `--scope project` to scope the MCP server -to the current project instead of the whole machine. A missing `claude` or -`npx` is reported and skipped (with the manual command to run), not treated as -an error. +```sh +aai stream path/to/audio.wav # 16 kHz mono WAV streams directly +aai stream path/to/audio.mp3 # other formats need ffmpeg on PATH +aai stream # from the microphone (needs the [mic] extra); Ctrl-C to stop +``` ## Voice agent -Have a live, two-way voice conversation with an AssemblyAI voice agent (requires the -`[mic]` extra for microphone + speaker audio): +Have a live, two-way voice conversation (requires the `[mic]` extra): + +```sh +aai agent # talk; the agent talks back. Ctrl-C to stop. +aai agent --voice james --greeting "Hi" +aai agent --prompt-file persona.txt # load the system prompt from a file +aai agent --list-voices # see available voices +``` + +By default the agent runs **half-duplex**: your mic mutes while the agent speaks, so it +can't hear itself on your speakers. With headphones, add `--full-duplex` for true +barge-in (interrupt the agent mid-sentence). + +## AI coding agents + +Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI skill so +your agent writes current, correct integration code: + +```sh +aai claude install # installs the docs MCP server + skill (user scope) +aai claude status # show what's wired up +aai claude remove # unwind both +``` + +`install` shells out to `claude mcp add` and `npx skills add`. Pass `--scope project` to +scope the MCP server to the current project. A missing `claude` or `npx` is reported and +skipped (with the manual command to run), not treated as an error. - pip install "assemblyai-cli[mic]" - aai agent # talk; the agent talks back. Ctrl-C to stop. - aai agent --voice james --greeting "Hi there" - aai agent --prompt-file persona.txt # load the system prompt from a file - aai agent --list-voices # see available voices +## Development -By default the agent runs **half-duplex**: your mic mutes while the agent is speaking, -so it can't hear itself on your speakers. With headphones, add `--full-duplex` for -true barge-in (interrupt the agent mid-sentence). Add `--json` for newline-delimited -JSON events. +```sh +pip install -e ".[dev]" +./scripts/check.sh # ruff + mypy + pytest (the same checks CI runs on every PR) +``` diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index 87ab7517..da7b071c 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -4,6 +4,7 @@ import queue import threading from collections.abc import Callable, Iterator +from typing import Any from assemblyai_cli.errors import CLIError @@ -43,7 +44,8 @@ def __init__( self._rate = sample_rate self._factory = stream_factory or _default_output_stream self._queue: queue.Queue[bytes | None] = queue.Queue() - self._stream = None + # PyAudio stream (or a test double); typed Any since pyaudio ships no stubs. + self._stream: Any = None self._thread: threading.Thread | None = None def start(self) -> None: diff --git a/assemblyai_cli/agent/session.py b/assemblyai_cli/agent/session.py index 4bc7e83c..70fcb31d 100644 --- a/assemblyai_cli/agent/session.py +++ b/assemblyai_cli/agent/session.py @@ -4,7 +4,7 @@ import json import threading -from assemblyai_cli.errors import APIError, CLIError +from assemblyai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure WS_URL = "wss://agents.assemblyai.com/v1/ws" @@ -120,6 +120,8 @@ def run_session( try: ws = _connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) except Exception as exc: # noqa: BLE001 - connect/auth/network failures + if is_auth_failure(exc): + raise auth_failure() from exc raise APIError(f"Could not connect to the voice agent: {exc}") from exc player_started = False @@ -145,6 +147,9 @@ def run_session( except (CLIError, KeyboardInterrupt): raise # auth/protocol errors and user Ctrl-C handled upstream except Exception as exc: # noqa: BLE001 - mid-stream socket/JSON failures + if is_auth_failure(exc): + # The Voice Agent server closes with 1008 (policy violation) on a bad key. + raise auth_failure() from exc raise APIError(f"Voice agent session failed: {exc}") from exc finally: try: diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 87b9f17b..dbf6a23b 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -9,7 +9,7 @@ StreamingParameters, ) -from assemblyai_cli.errors import APIError, CLIError +from assemblyai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" @@ -38,6 +38,8 @@ def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object] try: resp = aai.Transcriber().list_transcripts(aai.ListTranscriptParameters(limit=limit)) except aai.types.AssemblyAIError as exc: + if is_auth_failure(exc): + raise auth_failure() from exc raise APIError(f"Could not list transcripts: {exc}") from exc except Exception as exc: # noqa: BLE001 raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc @@ -52,6 +54,8 @@ def transcribe(api_key: str, audio: str, *, speaker_labels: bool) -> aai.Transcr except APIError: raise except Exception as exc: # noqa: BLE001 - surface SDK/network failures cleanly + if is_auth_failure(exc): + raise auth_failure() from exc raise APIError(f"Transcription request failed: {exc}") from exc if transcript.status == aai.TranscriptStatus.error: raise APIError(transcript.error or "Transcription failed.", transcript_id=transcript.id) @@ -63,6 +67,8 @@ def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: try: return aai.Transcript.get_by_id(transcript_id) except Exception as exc: # noqa: BLE001 + if is_auth_failure(exc): + raise auth_failure() from exc raise APIError(f"Could not fetch transcript {transcript_id}: {exc}") from exc @@ -101,6 +107,8 @@ def stream_audio( except CLIError: raise except Exception as exc: # noqa: BLE001 - surface connect/auth/network failures cleanly + if is_auth_failure(exc): + raise auth_failure() from exc raise APIError(f"Could not start streaming session: {exc}") from exc try: @@ -108,9 +116,13 @@ def stream_audio( except (CLIError, KeyboardInterrupt, BrokenPipeError): raise # clean CLI errors, user Ctrl-C, and closed-pipe are handled upstream except Exception as exc: # noqa: BLE001 - surface mid-stream SDK/network failures cleanly + if is_auth_failure(exc): + raise auth_failure() from exc raise APIError(f"Streaming failed: {exc}") from exc finally: sc.disconnect(terminate=True) if errors: + if is_auth_failure(errors[0]): + raise auth_failure() raise APIError(f"Streaming error: {errors[0]}") diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py index 7590d023..0b4701d3 100644 --- a/assemblyai_cli/commands/claude.py +++ b/assemblyai_cli/commands/claude.py @@ -19,8 +19,25 @@ _VALID_SCOPES = ("user", "project", "local") -def _run(cmd: list[str]) -> subprocess.CompletedProcess: - return subprocess.run(cmd, capture_output=True, text=True) +def _run(cmd: list[str], *, timeout: float = 120) -> subprocess.CompletedProcess: + # stdin=DEVNULL so a child that would otherwise prompt (npx's "Ok to proceed?", + # a `claude` confirmation) gets EOF and fails fast instead of hanging forever on + # input the user can't see (its stdout is captured). timeout is a final backstop. + try: + return subprocess.run( + cmd, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess( + args=cmd, + returncode=124, + stdout="", + stderr=f"timed out after {timeout:.0f}s: {' '.join(cmd)}", + ) def _mcp_present() -> bool: @@ -66,7 +83,8 @@ def _install_skill() -> dict: f"Node.js/npx not found. Install Node.js, then run: npx skills add {SKILL_REPO}" ), } - proc = _run(["npx", "skills", "add", SKILL_REPO]) + # -y: skip npx's interactive "Ok to proceed?" prompt; longer timeout covers the download. + proc = _run(["npx", "-y", "skills", "add", SKILL_REPO], timeout=300) if proc.returncode != 0: return {"name": "skill", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} return {"name": "skill", "status": "installed", "detail": SKILL_REPO} diff --git a/assemblyai_cli/commands/login.py b/assemblyai_cli/commands/login.py index 865c65cc..05246686 100644 --- a/assemblyai_cli/commands/login.py +++ b/assemblyai_cli/commands/login.py @@ -41,7 +41,7 @@ def body(state, json_mode: bool) -> None: config.set_api_key(profile, key) output.emit( {"authenticated": True, "profile": profile}, - lambda d: f"[green]Authenticated[/green] on profile '{escape(d['profile'])}'.", + lambda _d: f"[green]Authenticated[/green] on profile '{escape(profile)}'.", json_mode=json_mode, ) @@ -60,7 +60,7 @@ def body(state, json_mode: bool) -> None: config.clear_api_key(profile) output.emit( {"logged_out": True, "profile": profile}, - lambda d: f"Logged out of profile '{escape(d['profile'])}'.", + lambda _d: f"Logged out of profile '{escape(profile)}'.", json_mode=json_mode, ) @@ -83,7 +83,7 @@ def body(state, json_mode: bool) -> None: reachable = client.validate_key(key) output.emit( {"profile": profile, "api_key": masked, "reachable": reachable}, - lambda d: f"profile={escape(d['profile'])} key={escape(d['api_key'])} reachable={d['reachable']}", + lambda _d: f"profile={escape(profile)} key={escape(masked)} reachable={reachable}", json_mode=json_mode, ) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 6dac9247..79b8828f 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -27,6 +27,7 @@ def body(state, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) if source and (sample_rate != TARGET_RATE or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") + audio: FileSource | MicSource if source: audio = FileSource(source) rate = audio.sample_rate diff --git a/assemblyai_cli/errors.py b/assemblyai_cli/errors.py index f4497145..a91b0511 100644 --- a/assemblyai_cli/errors.py +++ b/assemblyai_cli/errors.py @@ -38,3 +38,36 @@ def __init__(self, message: str, *, transcript_id: str | None = None) -> None: class UsageError(CLIError): def __init__(self, message: str) -> None: super().__init__(message, error_type="usage_error", exit_code=2) + + +# Substrings that mark a failure as "the credentials were rejected" rather than a +# generic network/protocol error. Matched case-insensitively against str(exc). +# Includes WebSocket close 1008 (policy violation), which is how the Voice Agent +# server signals a bad API key. +_AUTH_FAILURE_HINTS = ( + "unauthorized", + "forbidden", + "authentication", + "api token", + "invalid api key", + "invalid key", + "401", + "403", + "1008", + "policy violation", +) + +REJECTED_KEY_MESSAGE = ( + "Your API key was rejected. Run 'aai login' with a valid key (or set ASSEMBLYAI_API_KEY)." +) + + +def is_auth_failure(exc: object) -> bool: + """Heuristic: does this exception/error indicate rejected/invalid credentials?""" + text = str(exc).lower() + return any(hint in text for hint in _AUTH_FAILURE_HINTS) + + +def auth_failure() -> NotAuthenticated: + """A NotAuthenticated for the 'key present but rejected by the server' case.""" + return NotAuthenticated(REJECTED_KEY_MESSAGE) diff --git a/assemblyai_cli/output.py b/assemblyai_cli/output.py index 66212a32..db7e0365 100644 --- a/assemblyai_cli/output.py +++ b/assemblyai_cli/output.py @@ -4,13 +4,15 @@ import os import sys from collections.abc import Callable -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar + +from rich.console import Console +from rich.markup import escape if TYPE_CHECKING: from assemblyai_cli.errors import CLIError -from rich.console import Console -from rich.markup import escape +T = TypeVar("T") console = Console() @@ -32,7 +34,7 @@ def resolve_json(*, explicit: bool) -> bool: return explicit or _is_agentic() -def emit(data: object, human_renderer: Callable[[object], object], *, json_mode: bool) -> None: +def emit(data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> None: if json_mode: print(json.dumps(data, default=str)) else: diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py index 77956dd3..b59b0df1 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/assemblyai_cli/streaming/sources.py @@ -82,6 +82,7 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + assert proc.stdout is not None # stdout=PIPE guarantees a pipe try: while True: data = proc.stdout.read(CHUNK_BYTES) diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..3a1b193a --- /dev/null +++ b/install.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Install the AssemblyAI CLI (`aai`) without cloning the repo: +# +# curl -fsSL https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh +# +# Overridable via env: AAI_REPO (owner/name), AAI_REF (branch/tag/sha). +set -eu + +REPO="${AAI_REPO:-AssemblyAI/cli}" +REF="${AAI_REF:-main}" +SPEC="git+https://github.com/${REPO}.git@${REF}" + +info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } +err() { printf '\033[1;31merror:\033[0m %s\n' "$1" >&2; } + +# --- Require Python 3.10+ ------------------------------------------------- +PY="$(command -v python3 || command -v python || true)" +if [ -z "$PY" ]; then + err "Python 3.10+ is required, but no python3 was found on PATH." + exit 1 +fi +if ! "$PY" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then + err "Python 3.10+ is required (found $("$PY" -V 2>&1))." + exit 1 +fi + +# --- Install (prefer pipx for an isolated env; fall back to pip --user) ---- +if command -v pipx >/dev/null 2>&1; then + info "Installing aai with pipx from ${REPO}@${REF}..." + pipx install --force "$SPEC" +else + info "pipx not found; installing with pip --user from ${REPO}@${REF}..." + "$PY" -m pip install --user --upgrade "$SPEC" +fi + +# --- Next steps ----------------------------------------------------------- +if command -v aai >/dev/null 2>&1; then + info "Installed. Next: run 'aai login', then 'aai transcribe --sample'." +else + info "Installed, but 'aai' isn't on your PATH yet." + info "Run 'pipx ensurepath' (or add ~/.local/bin to PATH), then restart your shell." +fi diff --git a/pyproject.toml b/pyproject.toml index c00fce12..9162e921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest>=8.0", "ruff>=0.11", "pre-commit>=4.0"] +dev = ["pytest>=8.0", "ruff>=0.11", "mypy>=1.10", "pre-commit>=4.0"] mic = ["pyaudio>=0.2.11"] [project.scripts] @@ -34,6 +34,12 @@ packages = ["assemblyai_cli"] [tool.pytest.ini_options] testpaths = ["tests"] +[tool.mypy] +python_version = "3.10" +files = ["assemblyai_cli"] +# Third-party deps (assemblyai, pyaudio) ship no type stubs. +ignore_missing_imports = true + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 00000000..81ee23bc --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Lint, typecheck, and test. Run locally before pushing; CI runs this on every PR. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> ruff check" +ruff check . + +echo "==> ruff format --check" +ruff format --check . + +echo "==> mypy" +mypy + +echo "==> pytest" +pytest -q + +echo "All checks passed." diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index e7ad9062..b8df600e 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -1,7 +1,7 @@ import pytest -from assemblyai_cli.agent.session import VoiceAgentSession -from assemblyai_cli.errors import APIError, CLIError +from assemblyai_cli.agent.session import VoiceAgentSession, run_session +from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated class FakeRenderer: @@ -31,6 +31,8 @@ class FakePlayer: def __init__(self): self.enqueued = [] self.flushed = 0 + self.started = False + self.closed = False def enqueue(self, pcm): self.enqueued.append(pcm) @@ -38,6 +40,12 @@ def enqueue(self, pcm): def flush(self): self.flushed += 1 + def start(self): + self.started = True + + def close(self): + self.closed = True + def _session(*, full_duplex=False): return VoiceAgentSession( @@ -126,6 +134,69 @@ def test_reply_audio_without_data_is_ignored(): assert s.player.enqueued == [] +_POLICY_VIOLATION = "received 1008 (policy violation); then sent 1008 (policy violation)" + + +def test_run_session_connect_auth_failure_raises_not_authenticated(): + def bad_connect(url, **kwargs): + raise RuntimeError(_POLICY_VIOLATION) + + with pytest.raises(NotAuthenticated): + run_session( + "sk_bad", + renderer=FakeRenderer(), + player=FakePlayer(), + mic=[], + voice="ivy", + system_prompt="x", + greeting="hi", + connect=bad_connect, + ) + + +def test_run_session_mid_stream_1008_raises_not_authenticated(): + class FakeWS: + def send(self, _msg): + pass + + def __iter__(self): + raise RuntimeError(_POLICY_VIOLATION) + + def close(self): + pass + + player = FakePlayer() + with pytest.raises(NotAuthenticated): + run_session( + "sk_bad", + renderer=FakeRenderer(), + player=player, + mic=[], + voice="ivy", + system_prompt="x", + greeting="hi", + connect=lambda url, **kwargs: FakeWS(), + ) + assert player.closed is True # speaker stream still torn down + + +def test_run_session_non_auth_failure_stays_api_error(): + def boom(url, **kwargs): + raise RuntimeError("network unreachable") + + with pytest.raises(APIError): + run_session( + "sk", + renderer=FakeRenderer(), + player=FakePlayer(), + mic=[], + voice="ivy", + system_prompt="x", + greeting="hi", + connect=boom, + ) + + def test_full_duplex_reply_started_announces_without_muting(): s = _session(full_duplex=True) s.dispatch({"type": "session.ready"}) diff --git a/tests/test_claude.py b/tests/test_claude.py index 401d0689..6b6d5ec5 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -61,7 +61,26 @@ def test_install_happy_path_runs_both_steps(monkeypatch): "assemblyai-docs", "https://mcp.assemblyai.com/docs", ] in fake.calls - assert ["npx", "skills", "add", "AssemblyAI/assemblyai-skill"] in fake.calls + assert ["npx", "-y", "skills", "add", "AssemblyAI/assemblyai-skill"] in fake.calls + + +def test_install_detaches_stdin_and_sets_timeout(monkeypatch): + """Regression: subprocess children must not inherit stdin, or an interactive + prompt (npx, claude) hangs the CLI forever. Each call must pass a timeout too.""" + _all_tools_present(monkeypatch) + seen = [] + + def record(cmd, *args, **kwargs): + seen.append(kwargs) + return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="") + + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", record) + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code in (0, 1) + assert seen, "expected subprocess.run to be called" + for kwargs in seen: + assert kwargs.get("stdin") is subprocess.DEVNULL + assert kwargs.get("timeout") def test_install_scope_passthrough(monkeypatch): diff --git a/tests/test_client.py b/tests/test_client.py index a9a83b38..6b0304d9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -55,6 +55,17 @@ def test_list_transcripts_auth_error_becomes_apierror(): client.list_transcripts("sk") +def test_list_transcripts_rejected_key_becomes_not_authenticated(): + from assemblyai_cli.errors import NotAuthenticated + + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( + "Authentication error, API token missing/invalid" + ) + with pytest.raises(NotAuthenticated): + client.list_transcripts("sk_bad") + + def test_transcribe_blocks_and_returns_transcript(): fake_transcript = MagicMock() fake_transcript.status = client.aai.TranscriptStatus.completed diff --git a/tests/test_errors.py b/tests/test_errors.py index 71fca8db..4e3e096c 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,4 +1,4 @@ -from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated +from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated, is_auth_failure def test_not_authenticated_defaults(): @@ -20,3 +20,15 @@ def test_api_error_carries_fields(): def test_to_dict_omits_none_transcript_id(): err = CLIError("nope", error_type="generic", exit_code=1) assert err.to_dict() == {"error": {"type": "generic", "message": "nope"}} + + +def test_is_auth_failure_matches_credential_signals(): + assert is_auth_failure(RuntimeError("received 1008 (policy violation)")) + assert is_auth_failure(Exception("HTTP 401 Unauthorized")) + assert is_auth_failure(Exception("Authentication error, API token missing/invalid")) + + +def test_is_auth_failure_ignores_generic_errors(): + assert not is_auth_failure(RuntimeError("network unreachable")) + assert not is_auth_failure(Exception("server exploded")) + assert not is_auth_failure(ConnectionError("handshake refused")) From 1b99cd13fdf6dc782afce7678a9b7aea5f8216f7 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 07:46:58 -0700 Subject: [PATCH 03/87] test: raise coverage to 96% (185 -> tests for real gaps) - python -m assemblyai_cli entrypoint - client.py auth/error branches (get/transcribe/stream -> NotAuthenticated vs APIError) - session.py should_send_audio gate and _send_audio_loop (forward/drop/stop-on-error) - renderer human-mode lines + broken-pipe write swallowing - human-mode command paths (transcripts table, agent half-duplex notice, stream Ctrl-C, interactive login with/without a working browser) - MicCapture closes a closeable stream Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_agent_audio.py | 15 ++++++++ tests/test_agent_command.py | 9 +++++ tests/test_agent_render.py | 29 +++++++++++++++ tests/test_agent_session.py | 52 ++++++++++++++++++++++++++- tests/test_client.py | 66 ++++++++++++++++++++++++++++++++++ tests/test_login.py | 21 +++++++++++ tests/test_main_module.py | 23 ++++++++++++ tests/test_stream_command.py | 13 +++++++ tests/test_streaming_render.py | 13 +++++++ tests/test_transcripts.py | 10 ++++++ 10 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 tests/test_main_module.py diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index f3fde3e8..88831de9 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -75,6 +75,21 @@ def write(self, data): assert p._thread is not None and not p._thread.is_alive() +def test_miccapture_closes_closeable_stream(): + closed = {"called": False} + + class CloseableStream: + def __iter__(self): + return iter([b"x"]) + + def close(self): + closed["called"] = True + + mic = MicCapture(stream_factory=lambda *, sample_rate, device: CloseableStream()) + assert list(mic) == [b"x"] + assert closed["called"] is True # stream.close() invoked in the finally + + def test_miccapture_device_error_raises_cli_error_exit_1(): def boom(*, sample_rate, device): raise RuntimeError("bad device") diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index ed5bed6b..57dde084 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -99,6 +99,15 @@ def fake_run_session( assert seen["full_duplex"] is True +def test_agent_half_duplex_notice_in_human_mode(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + result = runner.invoke(app, ["agent"]) + assert result.exit_code == 0 + assert "Half-duplex" in result.output + + def test_agent_ctrl_c_exits_cleanly(monkeypatch): config.set_api_key("default", "sk_live") diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index c9665933..504ded37 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -81,3 +81,32 @@ def test_notice_writes_to_buffer_in_human_mode(): r = AgentRenderer(json_mode=False, out=buf) r.notice("hi") assert buf.getvalue() == "hi" + + +def test_human_connected_and_stopped_announce(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.connected() + r.stopped() + out = buf.getvalue() + assert "start talking" in out.lower() + assert "Stopped." in out + + +def test_json_user_partial_emits_delta(): + buf = io.StringIO() + r = AgentRenderer(json_mode=True, out=buf) + r.user_partial("typing…") + assert _json_lines(buf) == [{"type": "transcript.user.delta", "text": "typing…"}] + + +def test_write_swallows_broken_pipe(): + class BrokenOut: + def write(self, _text): + raise BrokenPipeError("downstream closed") + + def flush(self): + pass + + # Must not raise even though the underlying stream is broken. + AgentRenderer(json_mode=False, out=BrokenOut()).notice("anything") diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index b8df600e..3a89ca75 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -1,6 +1,9 @@ +import base64 +import json + import pytest -from assemblyai_cli.agent.session import VoiceAgentSession, run_session +from assemblyai_cli.agent.session import VoiceAgentSession, _send_audio_loop, run_session from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated @@ -134,6 +137,53 @@ def test_reply_audio_without_data_is_ignored(): assert s.player.enqueued == [] +def test_should_send_audio_only_when_ready_and_unmuted(): + s = _session() + assert s.should_send_audio() is False # not ready yet + s.ready = True + assert s.should_send_audio() is True + s.muted = True + assert s.should_send_audio() is False # gated while the agent speaks + + +class _RecordingWS: + def __init__(self, fail_on_send=False): + self.sent = [] + self.fail_on_send = fail_on_send + + def send(self, msg): + if self.fail_on_send: + raise RuntimeError("socket closed") + self.sent.append(msg) + + +def test_send_audio_loop_forwards_frames_when_gate_open(): + s = _session() + s.ready = True # gate open, not muted + ws = _RecordingWS() + _send_audio_loop(ws, s, [b"\x01\x02", b"\x03\x04"]) + payloads = [json.loads(m) for m in ws.sent] + assert [p["type"] for p in payloads] == ["input.audio", "input.audio"] + assert base64.b64decode(payloads[0]["audio"]) == b"\x01\x02" + + +def test_send_audio_loop_drops_frames_while_muted(): + s = _session() + s.ready = True + s.muted = True # gate closed -> frames dropped, nothing sent + ws = _RecordingWS() + _send_audio_loop(ws, s, [b"\x01\x02"]) + assert ws.sent == [] + + +def test_send_audio_loop_stops_on_send_error(): + s = _session() + s.ready = True + ws = _RecordingWS(fail_on_send=True) + # Must return (not raise) when the socket is gone mid-send. + _send_audio_loop(ws, s, [b"\x01\x02", b"\x03\x04"]) + + _POLICY_VIOLATION = "received 1008 (policy violation); then sent 1008 (policy violation)" diff --git a/tests/test_client.py b/tests/test_client.py index 6b0304d9..761e61e3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -108,6 +108,46 @@ def test_get_transcript_calls_sdk(): assert result is fake +def test_get_transcript_generic_error_becomes_apierror(): + with patch.object(client.aai.Transcript, "get_by_id", side_effect=RuntimeError("boom")): + with pytest.raises(APIError): + client.get_transcript("sk", "t_x") + + +def test_get_transcript_auth_error_becomes_not_authenticated(): + from assemblyai_cli.errors import NotAuthenticated + + with patch.object( + client.aai.Transcript, "get_by_id", side_effect=RuntimeError("HTTP 401 Unauthorized") + ): + with pytest.raises(NotAuthenticated): + client.get_transcript("sk_bad", "t_x") + + +def test_transcribe_network_error_becomes_apierror(): + fake_transcriber = MagicMock() + fake_transcriber.transcribe.side_effect = RuntimeError("connection reset") + with ( + patch.object(client.aai, "Transcriber", return_value=fake_transcriber), + patch.object(client.aai, "TranscriptionConfig"), + ): + with pytest.raises(APIError): + client.transcribe("sk", "audio.mp3", speaker_labels=False) + + +def test_transcribe_auth_error_becomes_not_authenticated(): + from assemblyai_cli.errors import NotAuthenticated + + fake_transcriber = MagicMock() + fake_transcriber.transcribe.side_effect = RuntimeError("Invalid API key") + with ( + patch.object(client.aai, "Transcriber", return_value=fake_transcriber), + patch.object(client.aai, "TranscriptionConfig"), + ): + with pytest.raises(NotAuthenticated): + client.transcribe("sk_bad", "audio.mp3", speaker_labels=False) + + class _FakeStreamingClient: last = None @@ -192,6 +232,32 @@ def connect(self, params): client.stream_audio("sk", [b"\x00"], sample_rate=16000) +def test_stream_audio_connect_auth_error_becomes_not_authenticated(monkeypatch): + from assemblyai_cli.errors import NotAuthenticated + + class ConnectUnauthorized(_FakeStreamingClient): + def connect(self, params): + raise RuntimeError("401 Unauthorized: bad token") + + monkeypatch.setattr(client, "StreamingClient", ConnectUnauthorized) + with pytest.raises(NotAuthenticated): + client.stream_audio("sk_bad", [b"\x00"], sample_rate=16000) + + +def test_stream_audio_auth_error_event_becomes_not_authenticated(monkeypatch): + from assemblyai_cli.errors import NotAuthenticated + + class AuthErrClient(_FakeStreamingClient): + def stream(self, source): + from assemblyai.streaming.v3 import StreamingEvents + + self.handlers[StreamingEvents.Error](self, "Unauthorized: invalid api key") + + monkeypatch.setattr(client, "StreamingClient", AuthErrClient) + with pytest.raises(NotAuthenticated): + client.stream_audio("sk_bad", [b"\x00"], sample_rate=16000) + + def test_stream_audio_mid_stream_error_becomes_apierror(monkeypatch): class StreamFails(_FakeStreamingClient): def stream(self, source): diff --git a/tests/test_login.py b/tests/test_login.py index f79155bb..b1c6cfa6 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -22,6 +22,27 @@ def test_login_rejects_invalid_key(): assert config.get_api_key("default") is None +def test_login_interactive_prompts_when_no_flag(monkeypatch): + monkeypatch.setattr("assemblyai_cli.commands.login.webbrowser.open", lambda url: True) + monkeypatch.setattr("assemblyai_cli.commands.login.typer.prompt", lambda *a, **k: "sk_prompted") + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["login"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_prompted" + + +def test_login_interactive_survives_browser_failure(monkeypatch): + def boom(_url): + raise RuntimeError("no display") + + monkeypatch.setattr("assemblyai_cli.commands.login.webbrowser.open", boom) + monkeypatch.setattr("assemblyai_cli.commands.login.typer.prompt", lambda *a, **k: "sk_typed") + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["login"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_typed" + + def test_login_stores_under_named_profile(): with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["--profile", "staging", "login", "--api-key", "sk_s"]) diff --git a/tests/test_main_module.py b/tests/test_main_module.py new file mode 100644 index 00000000..ed53cea2 --- /dev/null +++ b/tests/test_main_module.py @@ -0,0 +1,23 @@ +import subprocess +import sys + + +def test_python_dash_m_entrypoint_runs(): + """`python -m assemblyai_cli` wires up the Typer app (exercises __main__.py).""" + result = subprocess.run( + [sys.executable, "-m", "assemblyai_cli", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "aai" in result.stdout + + +def test_python_dash_m_version(): + result = subprocess.run( + [sys.executable, "-m", "assemblyai_cli", "version"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert result.stdout.strip() # prints something (the version) diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index d3708a95..1bc14fd3 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -73,6 +73,19 @@ def raise_kbd(*a, **k): assert result.exit_code == 0 +def test_stream_ctrl_c_human_mode_prints_stopped(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + + def raise_kbd(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", raise_kbd) + result = runner.invoke(app, ["stream"]) + assert result.exit_code == 0 + assert "Stopped." in result.output + + def test_stream_file_with_sample_rate_flag_rejected(tmp_path): config.set_api_key("default", "sk_live") import wave diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index c28d0359..809eb855 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -81,3 +81,16 @@ def test_close_is_noop_in_json_mode(): before = out.getvalue() r.close() assert out.getvalue() == before + + +def test_close_swallows_broken_pipe(): + class BrokenOut: + def write(self, _text): + raise BrokenPipeError("downstream closed") + + def flush(self): + pass + + r = StreamRenderer(json_mode=False, out=BrokenOut()) + r._line_open = True # force the finalize path + r.close() # must not raise diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index f147db6c..796c42cc 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -34,6 +34,16 @@ def test_list_unauthenticated_exits_2(): assert result.exit_code == 2 +def test_list_human_mode_renders_table(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + rows = [{"id": "t1", "status": "completed", "created": "2026-01-01"}] + with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "t1" in result.output # rendered through the Rich table path + + def test_get_errored_transcript_exits_nonzero(): config.set_api_key("default", "sk_live") from unittest.mock import MagicMock From 04cc49d5a4a72ecd1b194ae10e8383c6b0e846ad Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 08:07:53 -0700 Subject: [PATCH 04/87] Tighten quality gates, add agent sample, bundle mic, polish UX Quality gates (CI runs all of these on every PR): - coverage gate: pytest-cov with --cov-fail-under=90 (currently 96%) - broader ruff rules: BLE, C4, SIM, RET, PTH, ARG, S, RUF (+ fixes) - stricter mypy: disallow_untyped_defs, warn_unused_ignores, warn_return_any, no_implicit_optional (fully annotated) - new CI jobs: `pre-commit run --all-files` (hooks can't drift) and `python -m build` + `twine check` (package always builds) Features / UX: - `aai samples create agent` scaffolds a runnable voice-agent script - bundle PyAudio as a core dependency (mic/speaker work out of the box; drop the [mic] extra); CI installs portaudio19-dev for the build - groups (`aai claude`, `aai samples`) print their subcommands instead of "Missing command" (no_args_is_help) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 42 +++++++++++++++++ .gitignore | 2 + README.md | 13 +++--- assemblyai_cli/agent/audio.py | 18 ++++---- assemblyai_cli/agent/render.py | 10 ++-- assemblyai_cli/agent/session.py | 26 +++++------ assemblyai_cli/client.py | 23 ++++++---- assemblyai_cli/commands/agent.py | 4 +- assemblyai_cli/commands/claude.py | 13 ++++-- assemblyai_cli/commands/login.py | 10 ++-- assemblyai_cli/commands/samples.py | 16 ++++--- assemblyai_cli/commands/stream.py | 4 +- assemblyai_cli/commands/transcribe.py | 4 +- assemblyai_cli/commands/transcripts.py | 12 +++-- assemblyai_cli/config.py | 13 +++--- assemblyai_cli/streaming/render.py | 16 +++---- assemblyai_cli/streaming/sources.py | 22 +++++---- assemblyai_cli/templates/agent.py.tmpl | 63 ++++++++++++++++++++++++++ pyproject.toml | 18 ++++++-- scripts/check.sh | 4 +- tests/test_agent_audio.py | 2 +- tests/test_claude.py | 8 ++++ tests/test_samples.py | 20 ++++++++ tests/test_streaming_sources.py | 2 +- 24 files changed, 264 insertions(+), 101 deletions(-) create mode 100644 assemblyai_cli/templates/agent.py.tmpl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2524665b..b8f2803f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,50 @@ jobs: python-version: "3.12" cache: pip + - name: System audio deps (PyAudio) + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + - name: Install run: python -m pip install -e ".[dev]" - name: Lint, typecheck, test run: ./scripts/check.sh + + pre-commit: + name: pre-commit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: System audio deps (PyAudio) + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + + # The local pytest hook runs `python -m pytest`, so the package must be importable. + - name: Install + run: python -m pip install -e ".[dev]" + + - uses: pre-commit/action@v3.0.1 + + build: + name: build + twine check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Build wheel + sdist + run: | + python -m pip install build twine + python -m build + + - name: Validate metadata + run: twine check dist/* diff --git a/.gitignore b/.gitignore index f5e90c54..9b9aaf19 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ build/ .pytest_cache/ .ruff_cache/ .mypy_cache/ +.coverage +htmlcov/ # Editor/agent and local planning artifacts .claude/ diff --git a/README.md b/README.md index d8a7041e..e8216c53 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,10 @@ The installer uses [`pipx`](https://pipx.pypa.io) when available (falling back t pipx install "git+https://github.com/AssemblyAI/cli.git" # or: pip install --user ... ``` -Microphone input (for `stream` and `agent`) needs the optional `[mic]` extra: - -```sh -pipx install "assemblyai-cli[mic] @ git+https://github.com/AssemblyAI/cli.git" -``` +Microphone and speaker support (for `stream` and `agent`) is **included by default** — +no extra install step. PyAudio ships prebuilt wheels for macOS, Windows, and Linux; on +Linux without a wheel you may need the PortAudio headers first (`sudo apt-get install +portaudio19-dev`). ## Quick start @@ -50,12 +49,12 @@ output is piped or run by an agent). Auth problems surface as a clean ```sh aai stream path/to/audio.wav # 16 kHz mono WAV streams directly aai stream path/to/audio.mp3 # other formats need ffmpeg on PATH -aai stream # from the microphone (needs the [mic] extra); Ctrl-C to stop +aai stream # from the microphone; Ctrl-C to stop ``` ## Voice agent -Have a live, two-way voice conversation (requires the `[mic]` extra): +Have a live, two-way voice conversation: ```sh aai agent # talk; the agent talks back. Ctrl-C to stop. diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index da7b071c..e2ae91f8 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -4,17 +4,19 @@ import queue import threading from collections.abc import Callable, Iterator -from typing import Any +from typing import Any, cast from assemblyai_cli.errors import CLIError SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate -_MIC_MISSING_MSG = "Audio support isn't installed. Run: pip install 'assemblyai-cli[mic]'" +_MIC_MISSING_MSG = ( + "Audio support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio" +) -def _default_output_stream(rate: int): - """Open a PyAudio PCM16 mono output stream (lazy import; needs the [mic] extra).""" +def _default_output_stream(rate: int) -> Any: + """Open a PyAudio PCM16 mono output stream (imported lazily to keep startup fast).""" try: import pyaudio except ImportError as exc: @@ -22,7 +24,7 @@ def _default_output_stream(rate: int): try: pa = pyaudio.PyAudio() stream = pa.open(format=pyaudio.paInt16, channels=1, rate=rate, output=True) - except Exception as exc: # noqa: BLE001 - surface device errors cleanly + except Exception as exc: raise CLIError( f"Could not open the audio output device: {exc}", error_type="audio_output_error", @@ -99,11 +101,11 @@ def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[byt """SDK PyAudio-backed mic stream (lazy import so the base install stays light).""" from assemblyai.extras import MicrophoneStream - return MicrophoneStream(sample_rate=sample_rate, device_index=device) + return cast(Iterator[bytes], MicrophoneStream(sample_rate=sample_rate, device_index=device)) class MicCapture: - """Iterates PCM16 chunks from the microphone (requires the [mic] extra).""" + """Iterates PCM16 chunks from the microphone.""" def __init__( self, @@ -121,7 +123,7 @@ def __iter__(self) -> Iterator[bytes]: stream = self._factory(sample_rate=self._rate, device=self._device) except ImportError as exc: raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc - except Exception as exc: # noqa: BLE001 - surface device errors cleanly + except Exception as exc: raise CLIError( f"Could not open the microphone (device {self._device}): {exc}", error_type="mic_error", diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index e1106a28..8e2568fc 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -1,7 +1,9 @@ from __future__ import annotations +import contextlib import json import sys +from typing import TextIO class AgentRenderer: @@ -10,7 +12,7 @@ class AgentRenderer: Audio payloads are never written; only text/state events are surfaced. """ - def __init__(self, *, json_mode: bool, out=None) -> None: + def __init__(self, *, json_mode: bool, out: TextIO | None = None) -> None: self.json_mode = json_mode self.out = out if out is not None else sys.stdout self._partial_open = False @@ -73,12 +75,10 @@ def _finish_partial(self) -> None: self._partial_open = False self._write("\n") - def _emit(self, obj) -> None: + def _emit(self, obj: object) -> None: self._write(json.dumps(obj) + "\n") def _write(self, text: str) -> None: - try: + with contextlib.suppress(Exception): # downstream pipe may be closed self.out.write(text) self.out.flush() - except Exception: # noqa: BLE001 - downstream pipe may be closed - pass diff --git a/assemblyai_cli/agent/session.py b/assemblyai_cli/agent/session.py index 70fcb31d..31f075a5 100644 --- a/assemblyai_cli/agent/session.py +++ b/assemblyai_cli/agent/session.py @@ -1,8 +1,10 @@ from __future__ import annotations import base64 +import contextlib import json import threading +from typing import Any from assemblyai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure @@ -22,7 +24,7 @@ class VoiceAgentSession: """Routes Voice Agent server events to the renderer, player, and duplex state.""" - def __init__(self, *, renderer, player, full_duplex: bool = False) -> None: + def __init__(self, *, renderer: Any, player: Any, full_duplex: bool = False) -> None: self.renderer = renderer self.player = player self.full_duplex = full_duplex @@ -83,7 +85,7 @@ def _raise_error(self, event: dict) -> None: raise APIError(f"Voice agent error ({code}): {message}") -def _send_audio_loop(ws, session: VoiceAgentSession, mic) -> None: +def _send_audio_loop(ws: Any, session: VoiceAgentSession, mic: Any) -> None: """Forward mic PCM as input.audio while the session gate allows it.""" for chunk in mic: if not session.should_send_audio(): @@ -98,14 +100,14 @@ def _send_audio_loop(ws, session: VoiceAgentSession, mic) -> None: def run_session( api_key: str, *, - renderer, - player, - mic, + renderer: Any, + player: Any, + mic: Any, voice: str, system_prompt: str, greeting: str, full_duplex: bool = False, - connect=None, + connect: Any = None, ) -> None: """Open the Voice Agent WebSocket and run the bidirectional loop until close. @@ -113,20 +115,20 @@ def run_session( """ _connect = connect if _connect is None: - from websockets.sync.client import connect as _connect # noqa: PLC0415 + from websockets.sync.client import connect as _connect session = VoiceAgentSession(renderer=renderer, player=player, full_duplex=full_duplex) try: ws = _connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) - except Exception as exc: # noqa: BLE001 - connect/auth/network failures + except Exception as exc: if is_auth_failure(exc): raise auth_failure() from exc raise APIError(f"Could not connect to the voice agent: {exc}") from exc player_started = False try: - player.start() # opens the speaker stream; CLIError here if [mic] is missing + player.start() # opens the speaker stream; CLIError here if PyAudio can't load player_started = True capture = threading.Thread(target=_send_audio_loop, args=(ws, session, mic), daemon=True) capture.start() @@ -146,15 +148,13 @@ def run_session( session.dispatch(json.loads(raw)) except (CLIError, KeyboardInterrupt): raise # auth/protocol errors and user Ctrl-C handled upstream - except Exception as exc: # noqa: BLE001 - mid-stream socket/JSON failures + except Exception as exc: if is_auth_failure(exc): # The Voice Agent server closes with 1008 (policy violation) on a bad key. raise auth_failure() from exc raise APIError(f"Voice agent session failed: {exc}") from exc finally: - try: + with contextlib.suppress(Exception): ws.close() - except Exception: # noqa: BLE001 - pass if player_started: player.close() diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index dbf6a23b..3959f436 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -1,5 +1,8 @@ from __future__ import annotations +from collections.abc import Callable, Iterable +from typing import Any + import assemblyai as aai from assemblyai.streaming.v3 import ( SpeechModel, @@ -29,7 +32,7 @@ def validate_key(api_key: str) -> bool: if "auth" in msg or "token" in msg: return False raise APIError(f"Could not validate key: {exc}") from exc - except Exception as exc: # noqa: BLE001 - surface network/SDK failures cleanly + except Exception as exc: raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc @@ -41,7 +44,7 @@ def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object] if is_auth_failure(exc): raise auth_failure() from exc raise APIError(f"Could not list transcripts: {exc}") from exc - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc return [item.model_dump(mode="json") for item in resp.transcripts] @@ -53,7 +56,7 @@ def transcribe(api_key: str, audio: str, *, speaker_labels: bool) -> aai.Transcr transcript = aai.Transcriber().transcribe(audio, config=config) except APIError: raise - except Exception as exc: # noqa: BLE001 - surface SDK/network failures cleanly + except Exception as exc: if is_auth_failure(exc): raise auth_failure() from exc raise APIError(f"Transcription request failed: {exc}") from exc @@ -66,7 +69,7 @@ def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: _configure(api_key) try: return aai.Transcript.get_by_id(transcript_id) - except Exception as exc: # noqa: BLE001 + except Exception as exc: if is_auth_failure(exc): raise auth_failure() from exc raise APIError(f"Could not fetch transcript {transcript_id}: {exc}") from exc @@ -74,12 +77,12 @@ def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: def stream_audio( api_key: str, - source, + source: Iterable[bytes], *, sample_rate: int, - on_begin=None, - on_turn=None, - on_termination=None, + on_begin: Callable[[Any], Any] | None = None, + on_turn: Callable[[Any], Any] | None = None, + on_termination: Callable[[Any], Any] | None = None, speech_model: SpeechModel = SpeechModel.universal_streaming_multilingual, ) -> None: """Stream `source` (an iterable of PCM bytes) through the v3 realtime API. @@ -106,7 +109,7 @@ def stream_audio( ) except CLIError: raise - except Exception as exc: # noqa: BLE001 - surface connect/auth/network failures cleanly + except Exception as exc: if is_auth_failure(exc): raise auth_failure() from exc raise APIError(f"Could not start streaming session: {exc}") from exc @@ -115,7 +118,7 @@ def stream_audio( sc.stream(source) except (CLIError, KeyboardInterrupt, BrokenPipeError): raise # clean CLI errors, user Ctrl-C, and closed-pipe are handled upstream - except Exception as exc: # noqa: BLE001 - surface mid-stream SDK/network failures cleanly + except Exception as exc: if is_auth_failure(exc): raise auth_failure() from exc raise APIError(f"Streaming failed: {exc}") from exc diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 4390df97..27cae7e7 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -9,7 +9,7 @@ from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session from assemblyai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list -from assemblyai_cli.context import run_command +from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import CLIError, UsageError app = typer.Typer() @@ -37,7 +37,7 @@ def agent( typer.echo(format_voice_list()) raise typer.Exit(code=0) - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) if voice not in VOICES: raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py index 0b4701d3..ad9af632 100644 --- a/assemblyai_cli/commands/claude.py +++ b/assemblyai_cli/commands/claude.py @@ -8,10 +8,13 @@ from rich.markup import escape from assemblyai_cli import output -from assemblyai_cli.context import run_command +from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError -app = typer.Typer(help="Wire up Claude Code for AssemblyAI (docs MCP + skill).") +app = typer.Typer( + help="Wire up Claude Code for AssemblyAI (docs MCP + skill).", + no_args_is_help=True, +) MCP_NAME = "assemblyai-docs" MCP_URL = "https://mcp.assemblyai.com/docs" @@ -161,7 +164,7 @@ def install( ) -> None: """Install the AssemblyAI docs MCP server and skill into Claude Code.""" - def body(_state, json_mode: bool) -> None: + def body(_state: AppState, json_mode: bool) -> None: if scope not in _VALID_SCOPES: raise UsageError( f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." @@ -181,7 +184,7 @@ def status( ) -> None: """Show whether the AssemblyAI MCP server and skill are wired into Claude Code.""" - def body(_state, json_mode: bool) -> None: + def body(_state: AppState, json_mode: bool) -> None: steps = [_mcp_status(), _skill_status()] output.emit({"steps": steps}, _render_steps, json_mode=json_mode) @@ -203,7 +206,7 @@ def remove( ) -> None: """Remove the AssemblyAI MCP server and skill from Claude Code.""" - def body(_state, json_mode: bool) -> None: + def body(_state: AppState, json_mode: bool) -> None: if scope is not None and scope not in _VALID_SCOPES: raise UsageError( f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." diff --git a/assemblyai_cli/commands/login.py b/assemblyai_cli/commands/login.py index 05246686..4876b48a 100644 --- a/assemblyai_cli/commands/login.py +++ b/assemblyai_cli/commands/login.py @@ -6,7 +6,7 @@ from rich.markup import escape from assemblyai_cli import client, config, output -from assemblyai_cli.context import resolve_profile, run_command +from assemblyai_cli.context import AppState, resolve_profile, run_command from assemblyai_cli.errors import APIError, NotAuthenticated app = typer.Typer() @@ -22,7 +22,7 @@ def login( ) -> None: """Authenticate by storing an API key (browser-assisted on a terminal).""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) key = api_key if not key: @@ -31,7 +31,7 @@ def body(state, json_mode: bool) -> None: ) try: webbrowser.open(DASHBOARD_KEYS_URL) - except Exception: + except Exception: # noqa: BLE001 - opening a browser is best-effort output.console.print( "[dim]Could not open a browser; open the URL above manually.[/dim]" ) @@ -55,7 +55,7 @@ def logout( ) -> None: """Clear stored credentials for the active profile.""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) config.clear_api_key(profile) output.emit( @@ -74,7 +74,7 @@ def whoami( ) -> None: """Show the active profile and whether its key is usable.""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) key = config.get_api_key(profile) if not key: diff --git a/assemblyai_cli/commands/samples.py b/assemblyai_cli/commands/samples.py index 9fa3678e..3733840b 100644 --- a/assemblyai_cli/commands/samples.py +++ b/assemblyai_cli/commands/samples.py @@ -8,15 +8,19 @@ from rich.markup import escape from assemblyai_cli import config, output -from assemblyai_cli.context import run_command +from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import CLIError -app = typer.Typer(help="Scaffold runnable AssemblyAI starter scripts.") +app = typer.Typer( + help="Scaffold runnable AssemblyAI starter scripts.", + no_args_is_help=True, +) # template name -> (template resource filename, output filename) TEMPLATES = { "transcribe": ("transcribe.py.tmpl", "transcribe.py"), "stream": ("stream.py.tmpl", "stream.py"), + "agent": ("agent.py.tmpl", "agent.py"), } @@ -27,7 +31,7 @@ def list_( ) -> None: """List available sample templates.""" - def body(_state, json_mode: bool) -> None: + def body(_state: AppState, json_mode: bool) -> None: names = sorted(TEMPLATES) output.emit( names, @@ -47,7 +51,7 @@ def create( ) -> None: """Scaffold a runnable starter script with your API key injected.""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: if name not in TEMPLATES: raise CLIError( f"Unknown sample '{name}'. Try: {', '.join(sorted(TEMPLATES))}.", @@ -61,7 +65,7 @@ def body(state, json_mode: bool) -> None: target_dir = Path.cwd() / name target_dir.mkdir(parents=True, exist_ok=True) - os.chmod(target_dir, 0o700) + target_dir.chmod(0o700) target = target_dir / out_file if target.exists() and not force: raise CLIError( @@ -72,7 +76,7 @@ def body(state, json_mode: bool) -> None: fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as fh: fh.write(rendered) - os.chmod(target, 0o600) + target.chmod(0o600) output.emit( {"created": str(target)}, diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 79b8828f..d077dd32 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -3,7 +3,7 @@ import typer from assemblyai_cli import client, config -from assemblyai_cli.context import run_command +from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError from assemblyai_cli.streaming.render import StreamRenderer from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource, MicSource @@ -23,7 +23,7 @@ def stream( ) -> None: """Transcribe live audio from the microphone or a file in real time.""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) if source and (sample_rate != TARGET_RATE or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index d97b9047..861f8578 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -4,7 +4,7 @@ from rich.markup import escape from assemblyai_cli import client, config, output -from assemblyai_cli.context import run_command +from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError app = typer.Typer() @@ -22,7 +22,7 @@ def transcribe( ) -> None: """Transcribe an audio file or URL and print the result.""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: if srt and vtt: raise UsageError("--srt and --vtt are mutually exclusive.") audio = client.SAMPLE_AUDIO_URL if sample else source diff --git a/assemblyai_cli/commands/transcripts.py b/assemblyai_cli/commands/transcripts.py index ef7647c7..830d43c6 100644 --- a/assemblyai_cli/commands/transcripts.py +++ b/assemblyai_cli/commands/transcripts.py @@ -5,7 +5,7 @@ from rich.table import Table from assemblyai_cli import client, config, output -from assemblyai_cli.context import run_command +from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import APIError app = typer.Typer() @@ -19,7 +19,7 @@ def get( ) -> None: """Fetch a past transcript by id and print its text.""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) transcript = client.get_transcript(api_key, transcript_id) if getattr(transcript.status, "value", transcript.status) == "error": @@ -48,15 +48,17 @@ def list_( ) -> None: """List recent transcripts.""" - def body(state, json_mode: bool) -> None: + def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) rows = client.list_transcripts(api_key, limit=limit) - def render(data): + def render(data: list[dict[str, object]]) -> Table: table = Table("id", "status", "created") for row in data: table.add_row( - escape(row["id"]), escape(row["status"]), escape(str(row.get("created", ""))) + escape(str(row["id"])), + escape(str(row["status"])), + escape(str(row.get("created", ""))), ) return table diff --git a/assemblyai_cli/config.py b/assemblyai_cli/config.py index 5d9b8a1a..0f1bda1c 100644 --- a/assemblyai_cli/config.py +++ b/assemblyai_cli/config.py @@ -1,8 +1,10 @@ from __future__ import annotations +import contextlib import os import re from pathlib import Path +from typing import Any import keyring import keyring.errors # keyring.errors is not re-exported by keyring/__init__ @@ -38,12 +40,13 @@ def _config_file() -> Path: return config_dir() / "config.toml" -def _load() -> dict: +def _load() -> dict[str, Any]: path = _config_file() if not path.exists(): return {} with path.open("rb") as fh: - return tomllib.load(fh) + data: dict[str, Any] = tomllib.load(fh) + return data def _dump(data: dict) -> None: @@ -54,7 +57,7 @@ def _dump(data: dict) -> None: def get_active_profile() -> str: - return _load().get("active_profile", DEFAULT_PROFILE) + return str(_load().get("active_profile", DEFAULT_PROFILE)) def set_active_profile(name: str) -> None: @@ -79,10 +82,8 @@ def get_api_key(profile: str) -> str | None: def clear_api_key(profile: str) -> None: - try: + with contextlib.suppress(keyring.errors.PasswordDeleteError): keyring.delete_password(KEYRING_SERVICE, profile) - except keyring.errors.PasswordDeleteError: - pass def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str: diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index 3aa2bdcd..c93b223b 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -1,25 +1,27 @@ from __future__ import annotations +import contextlib import json import sys +from typing import TextIO class StreamRenderer: """Renders streaming events: a live-updating line for humans, NDJSON for agents.""" - def __init__(self, *, json_mode: bool, out=None) -> None: + def __init__(self, *, json_mode: bool, out: TextIO | None = None) -> None: self.json_mode = json_mode self.out = out if out is not None else sys.stdout self._line_open = False - def begin(self, event) -> None: + def begin(self, event: object) -> None: if self.json_mode: self._emit({"type": "begin", "id": getattr(event, "id", None)}) else: self.out.write("Listening… (Ctrl-C to stop)\n") self.out.flush() - def turn(self, event) -> None: + def turn(self, event: object) -> None: text = getattr(event, "transcript", "") or "" end = bool(getattr(event, "end_of_turn", False)) if self.json_mode: @@ -33,7 +35,7 @@ def turn(self, event) -> None: self._line_open = True self.out.flush() - def termination(self, event) -> None: + def termination(self, event: object) -> None: if self.json_mode: self._emit( { @@ -47,12 +49,10 @@ def close(self) -> None: if self.json_mode or not self._line_open: return self._line_open = False - try: + with contextlib.suppress(Exception): # the downstream pipe may already be closed self.out.write("\n") self.out.flush() - except Exception: # noqa: BLE001 - the downstream pipe may already be closed - pass - def _emit(self, obj) -> None: + def _emit(self, obj: object) -> None: self.out.write(json.dumps(obj) + "\n") self.out.flush() diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py index b59b0df1..9189d792 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/assemblyai_cli/streaming/sources.py @@ -5,8 +5,9 @@ import subprocess import time import wave -from collections.abc import Iterator +from collections.abc import Callable, Iterator from pathlib import Path +from typing import Any from assemblyai_cli.errors import APIError, CLIError @@ -27,7 +28,7 @@ def _is_streamable_wav(path: Path) -> bool: class FileSource: """Yields real-time-paced 16 kHz mono PCM chunks from an audio file.""" - def __init__(self, path: str, *, sleep=time.sleep) -> None: + def __init__(self, path: str, *, sleep: Callable[[float], object] = time.sleep) -> None: self.path = Path(path) self._sleep = sleep self.sample_rate = TARGET_RATE @@ -82,17 +83,20 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - assert proc.stdout is not None # stdout=PIPE guarantees a pipe + # stdout=PIPE guarantees a pipe; bind a local so the type checker narrows it. + stdout = proc.stdout + if stdout is None: # pragma: no cover - defensive; PIPE always yields a stream + raise APIError("ffmpeg did not expose an output stream.") try: while True: - data = proc.stdout.read(CHUNK_BYTES) + data = stdout.read(CHUNK_BYTES) if not data: break yield data finally: proc.terminate() with contextlib.suppress(Exception): - proc.stdout.close() + stdout.close() proc.wait() # Reached only on natural EOF (not early generator close): surface a # decode failure instead of silently streaming nothing. @@ -103,7 +107,7 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: ) -def _load_microphone_stream(): +def _load_microphone_stream() -> Any: """Import the SDK's PyAudio-backed mic stream (isolated for testing/patching).""" from assemblyai.extras import MicrophoneStream @@ -111,7 +115,7 @@ def _load_microphone_stream(): class MicSource: - """Yields PCM chunks from the default microphone (requires the [mic] extra).""" + """Yields PCM chunks from the default microphone.""" def __init__(self, *, sample_rate: int, device: int | None = None) -> None: self.sample_rate = sample_rate @@ -123,11 +127,11 @@ def __iter__(self) -> Iterator[bytes]: stream = microphone_stream_cls(sample_rate=self.sample_rate, device_index=self.device) except ImportError as exc: raise CLIError( - "Microphone support isn't installed. Run: pip install 'assemblyai-cli[mic]'", + "Microphone support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio", error_type="mic_missing", exit_code=2, ) from exc - except Exception as exc: # noqa: BLE001 - surface device errors cleanly + except Exception as exc: raise CLIError( f"Could not open the microphone (device {self.device}): {exc}", error_type="mic_error", diff --git a/assemblyai_cli/templates/agent.py.tmpl b/assemblyai_cli/templates/agent.py.tmpl new file mode 100644 index 00000000..a960aa8d --- /dev/null +++ b/assemblyai_cli/templates/agent.py.tmpl @@ -0,0 +1,63 @@ +# Live two-way voice conversation with an AssemblyAI voice agent. +# Requires audio support: pip install "assemblyai[extras]" pyaudio +# +# Tip: use headphones. This sample keeps the mic open while the agent speaks, +# so without them the agent may hear (and respond to) its own voice. +import base64 +import json +import threading + +import pyaudio +from websockets.sync.client import connect + +API_KEY = "{{API_KEY}}" +WS_URL = "wss://agents.assemblyai.com/v1/ws" +RATE = 24000 # Voice Agent native PCM16 mono sample rate + +pa = pyaudio.PyAudio() +speaker = pa.open(format=pyaudio.paInt16, channels=1, rate=RATE, output=True) +mic = pa.open(format=pyaudio.paInt16, channels=1, rate=RATE, input=True, frames_per_buffer=1024) + +ready = threading.Event() + + +def send_mic(ws): + """Forward microphone audio to the agent once the session is ready.""" + while True: + try: + chunk = mic.read(1024, exception_on_overflow=False) + if ready.is_set(): + ws.send(json.dumps({"type": "input.audio", "audio": base64.b64encode(chunk).decode()})) + except Exception: + return + + +with connect(WS_URL, additional_headers={"Authorization": f"Bearer {API_KEY}"}) as ws: + ws.send(json.dumps({ + "type": "session.update", + "session": { + "system_prompt": "You are a friendly voice assistant. Keep replies short and natural.", + "greeting": "Hey, what's on your mind?", + "output": {"voice": "ivy"}, + }, + })) + threading.Thread(target=send_mic, args=(ws,), daemon=True).start() + print("Connected — start talking. (Ctrl-C to stop)") + try: + for raw in ws: + event = json.loads(raw) + etype = event.get("type") + if etype == "session.ready": + ready.set() + elif etype == "reply.audio" and event.get("data"): + speaker.write(base64.b64decode(event["data"])) + elif etype == "transcript.user": + print("you: ", event.get("text", "")) + elif etype == "transcript.agent": + print("agent:", event.get("text", "")) + except KeyboardInterrupt: + print("\nStopped.") + finally: + speaker.close() + mic.close() + pa.terminate() diff --git a/pyproject.toml b/pyproject.toml index 9162e921..5755ccf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,11 @@ dependencies = [ "platformdirs>=4.0", "tomli-w>=1.0", "websockets>=13", + "pyaudio>=0.2.14", ] [project.optional-dependencies] -dev = ["pytest>=8.0", "ruff>=0.11", "mypy>=1.10", "pre-commit>=4.0"] -mic = ["pyaudio>=0.2.11"] +dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.11", "mypy>=1.10", "pre-commit>=4.0"] [project.scripts] aai = "assemblyai_cli.main:app" @@ -30,6 +30,7 @@ packages = ["assemblyai_cli"] [tool.hatch.build.targets.wheel.force-include] "assemblyai_cli/templates/transcribe.py.tmpl" = "assemblyai_cli/templates/transcribe.py.tmpl" "assemblyai_cli/templates/stream.py.tmpl" = "assemblyai_cli/templates/stream.py.tmpl" +"assemblyai_cli/templates/agent.py.tmpl" = "assemblyai_cli/templates/agent.py.tmpl" [tool.pytest.ini_options] testpaths = ["tests"] @@ -39,13 +40,22 @@ python_version = "3.10" files = ["assemblyai_cli"] # Third-party deps (assemblyai, pyaudio) ship no type stubs. ignore_missing_imports = true +disallow_untyped_defs = true +warn_unused_ignores = true +warn_return_any = true +no_implicit_optional = true [tool.ruff] line-length = 100 target-version = "py310" [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B"] +select = ["E", "F", "I", "UP", "B", "BLE", "C4", "SIM", "RET", "PTH", "ARG", "S", "RUF"] # E501: line length is owned by the formatter. # B008: Typer uses function calls (typer.Option/Argument) as parameter defaults. -ignore = ["E501", "B008"] +# S603/S607: we intentionally shell out to `claude`/`npx` with controlled args. +ignore = ["E501", "B008", "S603", "S607"] + +[tool.ruff.lint.per-file-ignores] +# Tests assert freely, use throwaway args/temp paths, and don't need pathlib/security lints. +"tests/**" = ["S101", "S108", "ARG001", "ARG002", "ARG005", "PTH123", "SIM117"] diff --git a/scripts/check.sh b/scripts/check.sh index 81ee23bc..31c16adb 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -13,7 +13,7 @@ ruff format --check . echo "==> mypy" mypy -echo "==> pytest" -pytest -q +echo "==> pytest (with coverage gate)" +pytest -q --cov=assemblyai_cli --cov-report=term-missing --cov-fail-under=90 echo "All checks passed." diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index 88831de9..e28384b7 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -60,7 +60,7 @@ def boom(*, sample_rate, device): with pytest.raises(CLIError) as excinfo: list(mic) assert excinfo.value.exit_code == 2 - assert "assemblyai-cli[mic]" in excinfo.value.message + assert "pyaudio" in excinfo.value.message.lower() def test_player_worker_survives_write_error(): diff --git a/tests/test_claude.py b/tests/test_claude.py index 6b6d5ec5..249532e9 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -332,3 +332,11 @@ def test_claude_help_lists_all_subcommands(): assert "install" in result.output assert "status" in result.output assert "remove" in result.output + + +def test_claude_no_subcommand_lists_commands(): + # Bare `aai claude` should show its commands instead of "Missing command". + result = runner.invoke(app, ["claude"]) + assert "install" in result.output + assert "status" in result.output + assert "remove" in result.output diff --git a/tests/test_samples.py b/tests/test_samples.py index 760b1f8a..6f27e210 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -19,6 +19,26 @@ def test_samples_list_shows_templates(): assert result.exit_code == 0 assert "transcribe" in result.output assert "stream" in result.output + assert "agent" in result.output + + +def test_samples_create_agent_writes_script_with_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + config.set_api_key("default", "sk_injected") + result = runner.invoke(app, ["samples", "create", "agent"]) + assert result.exit_code == 0 + script = Path(tmp_path, "agent", "agent.py") + assert script.exists() + body = script.read_text() + assert "sk_injected" in body + assert "{{API_KEY}}" not in body + assert "session.update" in body # the voice-agent handshake + + +def test_samples_no_subcommand_lists_commands(): + # Bare `aai samples` should show its commands instead of erroring out. + result = runner.invoke(app, ["samples"]) + assert "list" in result.output and "create" in result.output def test_samples_create_stream_writes_script_with_key(tmp_path, monkeypatch): diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index 0f01b594..6bc08a85 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -140,7 +140,7 @@ def boom(): with pytest.raises(CLIError) as exc: list(sources.MicSource(sample_rate=16000)) assert exc.value.error_type == "mic_missing" - assert "assemblyai-cli[mic]" in exc.value.message + assert "pyaudio" in exc.value.message.lower() def test_micsource_yields_from_microphone_stream(monkeypatch): From df6d1d52b6e0d559145ce60913e8ac798b1e6b97 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 08:19:38 -0700 Subject: [PATCH 05/87] refactor: dedup stream/agent renderers and mic capture - extract BaseRenderer (assemblyai_cli/render.py) for the shared NDJSON + in-place-line plumbing; AgentRenderer/StreamRenderer now only map their own events. stopped() is shared, so `aai stream` Ctrl-C reuses it. - unify the two near-identical mic classes (MicSource + MicCapture) into a single MicrophoneSource (assemblyai_cli/microphone.py) used by both `stream` and `agent`; consolidate their tests into test_microphone.py. Behavior is unchanged (outputs preserved); ~30 fewer lines, 96% coverage. Note on Rich: transcripts already uses rich.table; evaluated rich.Live for the live transcript line but it complicates the JSON/threaded paths and testability without a real simplification, so kept the lightweight helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/agent/audio.py | 44 ++------------------- assemblyai_cli/agent/render.py | 46 +++------------------- assemblyai_cli/commands/agent.py | 5 ++- assemblyai_cli/commands/stream.py | 11 +++--- assemblyai_cli/microphone.py | 54 +++++++++++++++++++++++++ assemblyai_cli/render.py | 54 +++++++++++++++++++++++++ assemblyai_cli/streaming/render.py | 38 +++--------------- assemblyai_cli/streaming/sources.py | 37 +---------------- tests/test_agent_audio.py | 50 +---------------------- tests/test_microphone.py | 61 +++++++++++++++++++++++++++++ tests/test_streaming_sources.py | 56 -------------------------- 11 files changed, 196 insertions(+), 260 deletions(-) create mode 100644 assemblyai_cli/microphone.py create mode 100644 assemblyai_cli/render.py create mode 100644 tests/test_microphone.py diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index e2ae91f8..731c90bb 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -3,8 +3,8 @@ import contextlib import queue import threading -from collections.abc import Callable, Iterator -from typing import Any, cast +from collections.abc import Callable +from typing import Any from assemblyai_cli.errors import CLIError @@ -97,41 +97,5 @@ def close(self) -> None: pa.terminate() -def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[bytes]: - """SDK PyAudio-backed mic stream (lazy import so the base install stays light).""" - from assemblyai.extras import MicrophoneStream - - return cast(Iterator[bytes], MicrophoneStream(sample_rate=sample_rate, device_index=device)) - - -class MicCapture: - """Iterates PCM16 chunks from the microphone.""" - - def __init__( - self, - *, - sample_rate: int = SAMPLE_RATE, - device: int | None = None, - stream_factory: Callable[..., Iterator[bytes]] | None = None, - ) -> None: - self._rate = sample_rate - self._device = device - self._factory = stream_factory or _default_mic_stream - - def __iter__(self) -> Iterator[bytes]: - try: - stream = self._factory(sample_rate=self._rate, device=self._device) - except ImportError as exc: - raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc - except Exception as exc: - raise CLIError( - f"Could not open the microphone (device {self._device}): {exc}", - error_type="mic_error", - exit_code=1, - ) from exc - close = getattr(stream, "close", None) - try: - yield from stream - finally: - if callable(close): - close() +# Microphone capture (MicrophoneSource) lives in assemblyai_cli.microphone and is +# shared with `aai stream`; this module owns only the speaker-side Player. diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index 8e2568fc..66b5c71f 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -1,22 +1,14 @@ from __future__ import annotations -import contextlib -import json -import sys -from typing import TextIO +from assemblyai_cli.render import BaseRenderer -class AgentRenderer: +class AgentRenderer(BaseRenderer): """Renders Voice Agent events: human transcript lines, or NDJSON for agents. Audio payloads are never written; only text/state events are surfaced. """ - def __init__(self, *, json_mode: bool, out: TextIO | None = None) -> None: - self.json_mode = json_mode - self.out = out if out is not None else sys.stdout - self._partial_open = False - # --- lifecycle --------------------------------------------------------- def connected(self) -> None: if self.json_mode: @@ -24,12 +16,8 @@ def connected(self) -> None: else: self._write("Connected — start talking. (Ctrl-C to stop)\n") - def stopped(self) -> None: - if not self.json_mode: - self._write("Stopped.\n") - def notice(self, text: str) -> None: - """Write a human-facing notice line (no-op semantics in JSON mode are the caller's choice).""" + """Write a human-facing notice line (caller chooses when to suppress in JSON).""" self._write(text) # --- user -------------------------------------------------------------- @@ -37,15 +25,13 @@ def user_partial(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user.delta", "text": text}) return - self._write("\r\x1b[Kyou: " + text) - self._partial_open = True + self._update_line("you: " + text) def user_final(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user", "text": text}) return - self._write("\r\x1b[Kyou: " + text + "\n") - self._partial_open = False + self._finalize_line("you: " + text) # --- agent ------------------------------------------------------------- def reply_started(self) -> None: @@ -56,29 +42,9 @@ def agent_transcript(self, text: str, *, interrupted: bool) -> None: if self.json_mode: self._emit({"type": "transcript.agent", "text": text, "interrupted": interrupted}) return - self._finish_partial() + self._finalize_line() # close any open "you: …" partial first self._write("agent: " + text + "\n") def reply_done(self, *, interrupted: bool) -> None: if self.json_mode: self._emit({"type": "reply.done", "interrupted": interrupted}) - - # --- teardown ---------------------------------------------------------- - def close(self) -> None: - if self.json_mode: - return - self._finish_partial() - - # --- internals --------------------------------------------------------- - def _finish_partial(self) -> None: - if self._partial_open: - self._partial_open = False - self._write("\n") - - def _emit(self, obj: object) -> None: - self._write(json.dumps(obj) + "\n") - - def _write(self, text: str) -> None: - with contextlib.suppress(Exception): # downstream pipe may be closed - self.out.write(text) - self.out.flush() diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 27cae7e7..4d62b195 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -5,12 +5,13 @@ import typer from assemblyai_cli import config -from assemblyai_cli.agent.audio import SAMPLE_RATE, MicCapture, Player +from assemblyai_cli.agent.audio import SAMPLE_RATE, Player from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session from assemblyai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import CLIError, UsageError +from assemblyai_cli.microphone import MicrophoneSource app = typer.Typer() @@ -54,7 +55,7 @@ def body(state: AppState, json_mode: bool) -> None: renderer = AgentRenderer(json_mode=json_mode) player = Player(sample_rate=SAMPLE_RATE) - mic = MicCapture(sample_rate=SAMPLE_RATE, device=device) + mic = MicrophoneSource(sample_rate=SAMPLE_RATE, device=device) if not json_mode and not full_duplex: renderer.notice( "Half-duplex: mic mutes while the agent talks. " diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index d077dd32..9a099546 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -5,8 +5,9 @@ from assemblyai_cli import client, config from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError +from assemblyai_cli.microphone import MicrophoneSource from assemblyai_cli.streaming.render import StreamRenderer -from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource, MicSource +from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource app = typer.Typer() @@ -27,12 +28,12 @@ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) if source and (sample_rate != TARGET_RATE or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") - audio: FileSource | MicSource + audio: FileSource | MicrophoneSource if source: audio = FileSource(source) rate = audio.sample_rate else: - audio = MicSource(sample_rate=sample_rate, device=device) + audio = MicrophoneSource(sample_rate=sample_rate, device=device) rate = sample_rate renderer = StreamRenderer(json_mode=json_mode) try: @@ -47,9 +48,7 @@ def body(state: AppState, json_mode: bool) -> None: except KeyboardInterrupt: # Ctrl-C is a normal "user stopped" signal -> exit 0. renderer.close() - if not json_mode: - renderer.out.write("Stopped.\n") - renderer.out.flush() + renderer.stopped() except BrokenPipeError: # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. raise typer.Exit(code=0) from None diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py new file mode 100644 index 00000000..a7d8ae54 --- /dev/null +++ b/assemblyai_cli/microphone.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Any, cast + +from assemblyai_cli.errors import CLIError + +_MIC_MISSING_MSG = ( + "Microphone support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio" +) + + +def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[bytes]: + """The SDK's PyAudio-backed mic stream (imported lazily to keep startup fast).""" + from assemblyai.extras import MicrophoneStream + + return cast(Iterator[bytes], MicrophoneStream(sample_rate=sample_rate, device_index=device)) + + +class MicrophoneSource: + """Iterable of PCM16 chunks from the default microphone. + + Shared by `aai stream` (mic input) and `aai agent` (captured speech). The + stream factory is injectable so tests don't need real audio hardware. + """ + + def __init__( + self, + *, + sample_rate: int, + device: int | None = None, + stream_factory: Callable[..., Iterator[bytes]] | None = None, + ) -> None: + self.sample_rate = sample_rate + self.device = device + self._factory = stream_factory or _default_mic_stream + + def __iter__(self) -> Iterator[bytes]: + try: + stream: Any = self._factory(sample_rate=self.sample_rate, device=self.device) + except ImportError as exc: + raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc + except Exception as exc: + raise CLIError( + f"Could not open the microphone (device {self.device}): {exc}", + error_type="mic_error", + exit_code=1, + ) from exc + close = getattr(stream, "close", None) + try: + yield from stream + finally: + if callable(close): + close() diff --git a/assemblyai_cli/render.py b/assemblyai_cli/render.py new file mode 100644 index 00000000..dbc423bf --- /dev/null +++ b/assemblyai_cli/render.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import contextlib +import json +import sys +from typing import TextIO + + +class BaseRenderer: + """Shared plumbing for the streaming and voice-agent renderers. + + Two output modes: newline-delimited JSON for agents/pipes, or a human view + that redraws a single in-place line (partial transcript) and finalizes it + with a newline. Subclasses map domain events onto these primitives. + """ + + def __init__(self, *, json_mode: bool, out: TextIO | None = None) -> None: + self.json_mode = json_mode + self.out = out if out is not None else sys.stdout + self._line_open = False + + # --- output primitives ------------------------------------------------- + def _emit(self, obj: object) -> None: + """Write one NDJSON event.""" + self._write(json.dumps(obj) + "\n") + + def _write(self, text: str) -> None: + with contextlib.suppress(Exception): # downstream pipe may be closed + self.out.write(text) + self.out.flush() + + def _update_line(self, text: str) -> None: + """Redraw the current line in place (no trailing newline).""" + self._write("\r\x1b[K" + text) + self._line_open = True + + def _finalize_line(self, text: str | None = None) -> None: + """Commit the current line with a newline; optionally replace its text.""" + if text is not None: + self._write("\r\x1b[K" + text + "\n") + self._line_open = False + elif self._line_open: + self._write("\n") + self._line_open = False + + # --- shared lifecycle -------------------------------------------------- + def stopped(self) -> None: + if not self.json_mode: + self._write("Stopped.\n") + + def close(self) -> None: + """Finalize an in-progress human line so later output starts clean.""" + if not self.json_mode: + self._finalize_line() diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index c93b223b..6679ca80 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -1,39 +1,26 @@ from __future__ import annotations -import contextlib -import json -import sys -from typing import TextIO +from assemblyai_cli.render import BaseRenderer -class StreamRenderer: +class StreamRenderer(BaseRenderer): """Renders streaming events: a live-updating line for humans, NDJSON for agents.""" - def __init__(self, *, json_mode: bool, out: TextIO | None = None) -> None: - self.json_mode = json_mode - self.out = out if out is not None else sys.stdout - self._line_open = False - def begin(self, event: object) -> None: if self.json_mode: self._emit({"type": "begin", "id": getattr(event, "id", None)}) else: - self.out.write("Listening… (Ctrl-C to stop)\n") - self.out.flush() + self._write("Listening… (Ctrl-C to stop)\n") def turn(self, event: object) -> None: text = getattr(event, "transcript", "") or "" end = bool(getattr(event, "end_of_turn", False)) if self.json_mode: self._emit({"type": "turn", "transcript": text, "end_of_turn": end}) - return - self.out.write("\r\x1b[K" + text) # clear the line, then write the current turn - if end: - self.out.write("\n") - self._line_open = False + elif end: + self._finalize_line(text) else: - self._line_open = True - self.out.flush() + self._update_line(text) def termination(self, event: object) -> None: if self.json_mode: @@ -43,16 +30,3 @@ def termination(self, event: object) -> None: "audio_duration_seconds": getattr(event, "audio_duration_seconds", None), } ) - - def close(self) -> None: - """Finalize an in-progress (no-newline) human line, so later output starts clean.""" - if self.json_mode or not self._line_open: - return - self._line_open = False - with contextlib.suppress(Exception): # the downstream pipe may already be closed - self.out.write("\n") - self.out.flush() - - def _emit(self, obj: object) -> None: - self.out.write(json.dumps(obj) + "\n") - self.out.flush() diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py index 9189d792..1e5d7211 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/assemblyai_cli/streaming/sources.py @@ -7,7 +7,6 @@ import wave from collections.abc import Callable, Iterator from pathlib import Path -from typing import Any from assemblyai_cli.errors import APIError, CLIError @@ -107,37 +106,5 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: ) -def _load_microphone_stream() -> Any: - """Import the SDK's PyAudio-backed mic stream (isolated for testing/patching).""" - from assemblyai.extras import MicrophoneStream - - return MicrophoneStream - - -class MicSource: - """Yields PCM chunks from the default microphone.""" - - def __init__(self, *, sample_rate: int, device: int | None = None) -> None: - self.sample_rate = sample_rate - self.device = device - - def __iter__(self) -> Iterator[bytes]: - try: - microphone_stream_cls = _load_microphone_stream() - stream = microphone_stream_cls(sample_rate=self.sample_rate, device_index=self.device) - except ImportError as exc: - raise CLIError( - "Microphone support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio", - error_type="mic_missing", - exit_code=2, - ) from exc - except Exception as exc: - raise CLIError( - f"Could not open the microphone (device {self.device}): {exc}", - error_type="mic_error", - exit_code=1, - ) from exc - try: - yield from stream - finally: - stream.close() +# MicrophoneSource (mic capture) lives in assemblyai_cli.microphone and is shared +# with the voice agent; FileSource above is the only streaming-specific source. diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index e28384b7..a2792adb 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -1,7 +1,4 @@ -import pytest - -from assemblyai_cli.agent.audio import MicCapture, Player -from assemblyai_cli.errors import CLIError +from assemblyai_cli.agent.audio import Player class FakeStream: @@ -43,26 +40,6 @@ def test_player_flush_discards_pending_audio(): assert p.pending() == 0 -def test_miccapture_yields_chunks_from_factory(): - def fake_factory(*, sample_rate, device): - assert sample_rate == 24000 - return iter([b"aa", b"bb"]) - - mic = MicCapture(sample_rate=24000, device=None, stream_factory=fake_factory) - assert list(mic) == [b"aa", b"bb"] - - -def test_miccapture_missing_dependency_raises_cli_error(): - def boom(*, sample_rate, device): - raise ImportError("no pyaudio") - - mic = MicCapture(sample_rate=24000, device=None, stream_factory=boom) - with pytest.raises(CLIError) as excinfo: - list(mic) - assert excinfo.value.exit_code == 2 - assert "pyaudio" in excinfo.value.message.lower() - - def test_player_worker_survives_write_error(): class BoomStream(FakeStream): def write(self, data): @@ -73,28 +50,3 @@ def write(self, data): p.enqueue(b"\x01\x02") p.close() # must return (join has a timeout); thread must not be alive assert p._thread is not None and not p._thread.is_alive() - - -def test_miccapture_closes_closeable_stream(): - closed = {"called": False} - - class CloseableStream: - def __iter__(self): - return iter([b"x"]) - - def close(self): - closed["called"] = True - - mic = MicCapture(stream_factory=lambda *, sample_rate, device: CloseableStream()) - assert list(mic) == [b"x"] - assert closed["called"] is True # stream.close() invoked in the finally - - -def test_miccapture_device_error_raises_cli_error_exit_1(): - def boom(*, sample_rate, device): - raise RuntimeError("bad device") - - mic = MicCapture(sample_rate=24000, device=None, stream_factory=boom) - with pytest.raises(CLIError) as excinfo: - list(mic) - assert excinfo.value.exit_code == 1 diff --git a/tests/test_microphone.py b/tests/test_microphone.py new file mode 100644 index 00000000..9faea4ae --- /dev/null +++ b/tests/test_microphone.py @@ -0,0 +1,61 @@ +import pytest + +from assemblyai_cli.errors import CLIError +from assemblyai_cli.microphone import MicrophoneSource + + +def test_yields_chunks_from_factory_with_rate_and_device(): + seen = {} + + def fake_factory(*, sample_rate, device): + seen["rate"] = sample_rate + seen["device"] = device + return iter([b"aa", b"bb"]) + + mic = MicrophoneSource(sample_rate=24000, device=3, stream_factory=fake_factory) + assert list(mic) == [b"aa", b"bb"] + assert seen == {"rate": 24000, "device": 3} + + +def test_missing_dependency_raises_mic_missing(): + def boom(*, sample_rate, device): + raise ImportError("No module named 'pyaudio'") + + mic = MicrophoneSource(sample_rate=16000, stream_factory=boom) + with pytest.raises(CLIError) as exc: + list(mic) + assert exc.value.error_type == "mic_missing" + assert exc.value.exit_code == 2 + assert "pyaudio" in exc.value.message.lower() + + +def test_device_error_raises_mic_error_exit_1(): + def boom(*, sample_rate, device): + raise OSError("Invalid device") + + mic = MicrophoneSource(sample_rate=16000, device=99, stream_factory=boom) + with pytest.raises(CLIError) as exc: + list(mic) + assert exc.value.error_type == "mic_error" + assert exc.value.exit_code == 1 + + +def test_closes_closeable_stream_in_finally(): + closed = {"called": False} + + class CloseableStream: + def __iter__(self): + return iter([b"x"]) + + def close(self): + closed["called"] = True + + mic = MicrophoneSource(sample_rate=16000, stream_factory=lambda **_k: CloseableStream()) + assert list(mic) == [b"x"] + assert closed["called"] is True # close() invoked in the finally + + +def test_plain_iterator_without_close_is_fine(): + # A factory returning a bare iterator (no .close) must not error in teardown. + mic = MicrophoneSource(sample_rate=16000, stream_factory=lambda **_k: iter([b"z"])) + assert list(mic) == [b"z"] diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index 6bc08a85..7c1362f9 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -130,59 +130,3 @@ def test_filesource_empty_wav_raises(tmp_path): with pytest.raises(CLIError) as exc: list(FileSource(str(p), sleep=lambda _s: None)) assert exc.value.error_type == "empty_audio" - - -def test_micsource_missing_dependency_raises(monkeypatch): - def boom(): - raise ImportError("No module named 'pyaudio'") - - monkeypatch.setattr(sources, "_load_microphone_stream", boom) - with pytest.raises(CLIError) as exc: - list(sources.MicSource(sample_rate=16000)) - assert exc.value.error_type == "mic_missing" - assert "pyaudio" in exc.value.message.lower() - - -def test_micsource_yields_from_microphone_stream(monkeypatch): - captured = {} - - class FakeMic: - def __init__(self, sample_rate, device_index): - captured["rate"] = sample_rate - captured["device"] = device_index - - def __iter__(self): - return iter([b"\x00\x01", b"\x02\x03"]) - - def close(self): - captured["closed"] = True - - monkeypatch.setattr(sources, "_load_microphone_stream", lambda: FakeMic) - chunks = list(sources.MicSource(sample_rate=16000, device=2)) - assert chunks == [b"\x00\x01", b"\x02\x03"] - assert captured == {"rate": 16000, "device": 2, "closed": True} - - -def test_micsource_missing_dependency_at_construction(monkeypatch): - class ExtrasMissing(ImportError): - pass - - class FakeMic: - def __init__(self, sample_rate, device_index): - raise ExtrasMissing("You must install the extras") - - monkeypatch.setattr(sources, "_load_microphone_stream", lambda: FakeMic) - with pytest.raises(CLIError) as exc: - list(sources.MicSource(sample_rate=16000)) - assert exc.value.error_type == "mic_missing" - - -def test_micsource_device_error_becomes_clierror(monkeypatch): - class FakeMic: - def __init__(self, sample_rate, device_index): - raise OSError("Invalid device") - - monkeypatch.setattr(sources, "_load_microphone_stream", lambda: FakeMic) - with pytest.raises(CLIError) as exc: - list(sources.MicSource(sample_rate=16000, device=99)) - assert exc.value.error_type == "mic_error" From a76e2a7df64bee5e0d3aa8e815e2ba3ec515687b Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 09:16:53 -0700 Subject: [PATCH 06/87] fix: address code-review findings (auth detection, pipes, mic errors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - renderer _write re-raises BrokenPipeError so `aai stream/agent --json | head` stops cleanly again (the dedup had swallowed it); agent handles it like stream - is_auth_failure no longer matches bare numbers (401/403/1008) anywhere in a message — those caused valid keys/real errors to be reported as "key rejected". Voice Agent 1008 and pre-upgrade HTTP 401/403 are now detected structurally (close code / status code) in session.py - mic-open failures in the agent's daemon capture thread are now surfaced to the user (clean CLIError/exit) instead of vanishing with a hung session - ffmpeg no longer SIGTERM'd after natural EOF, removing a spurious "exit -15" decode error on fully-streamed files - validate_key reuses the shared is_auth_failure (catches forbidden/403) Cleanup: - shared status_str() for transcript status (was copy-pasted 3x) - shared pyaudio_missing_error() (was duplicated with divergent wording) - claude.py honors CLAUDE_CONFIG_DIR; Step TypedDict removes the type: ignore Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/agent/audio.py | 7 +--- assemblyai_cli/agent/session.py | 49 ++++++++++++++++++++---- assemblyai_cli/client.py | 9 ++++- assemblyai_cli/commands/agent.py | 7 +++- assemblyai_cli/commands/claude.py | 53 +++++++++++++++++++------- assemblyai_cli/commands/transcribe.py | 2 +- assemblyai_cli/commands/transcripts.py | 4 +- assemblyai_cli/errors.py | 13 +++---- assemblyai_cli/microphone.py | 13 +++++-- assemblyai_cli/render.py | 8 +++- assemblyai_cli/streaming/sources.py | 9 ++++- tests/test_agent_render.py | 21 ++++++++-- tests/test_agent_session.py | 48 +++++++++++++++++++++-- tests/test_claude.py | 38 +++++++++++++++++- tests/test_errors.py | 10 ++++- tests/test_streaming_render.py | 20 +++++++++- 16 files changed, 253 insertions(+), 58 deletions(-) diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index 731c90bb..44b371cb 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -7,20 +7,17 @@ from typing import Any from assemblyai_cli.errors import CLIError +from assemblyai_cli.microphone import pyaudio_missing_error SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate -_MIC_MISSING_MSG = ( - "Audio support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio" -) - def _default_output_stream(rate: int) -> Any: """Open a PyAudio PCM16 mono output stream (imported lazily to keep startup fast).""" try: import pyaudio except ImportError as exc: - raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc + raise pyaudio_missing_error() from exc try: pa = pyaudio.PyAudio() stream = pa.open(format=pyaudio.paInt16, channels=1, rate=rate, output=True) diff --git a/assemblyai_cli/agent/session.py b/assemblyai_cli/agent/session.py index 31f075a5..ac87162a 100644 --- a/assemblyai_cli/agent/session.py +++ b/assemblyai_cli/agent/session.py @@ -97,6 +97,24 @@ def _send_audio_loop(ws: Any, session: VoiceAgentSession, mic: Any) -> None: return +def _is_auth_rejection(exc: BaseException) -> bool: + """True when a connect/session failure means the credentials were rejected. + + Detected structurally where possible — the Voice Agent closes with WebSocket + code 1008 on a bad key, and a pre-upgrade HTTP 401/403 carries a status code — + then falls back to the shared text heuristic. + """ + code = getattr(exc, "code", None) + if code is None: + code = getattr(getattr(exc, "rcvd", None), "code", None) + if code == 1008: + return True + response = getattr(exc, "response", None) + if getattr(response, "status_code", None) in (401, 403): + return True + return is_auth_failure(exc) + + def run_session( api_key: str, *, @@ -122,16 +140,28 @@ def run_session( try: ws = _connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) except Exception as exc: - if is_auth_failure(exc): + if _is_auth_rejection(exc): raise auth_failure() from exc raise APIError(f"Could not connect to the voice agent: {exc}") from exc + # The mic opens lazily on first iteration, inside the capture thread; a failure + # there (no device, PyAudio missing) must reach the user instead of vanishing + # with the daemon thread. Capture it and close the socket to end the receive loop. + capture_error: list[CLIError] = [] + + def _capture() -> None: + try: + _send_audio_loop(ws, session, mic) + except CLIError as exc: + capture_error.append(exc) + with contextlib.suppress(Exception): + ws.close() + player_started = False try: player.start() # opens the speaker stream; CLIError here if PyAudio can't load player_started = True - capture = threading.Thread(target=_send_audio_loop, args=(ws, session, mic), daemon=True) - capture.start() + threading.Thread(target=_capture, daemon=True).start() ws.send( json.dumps( { @@ -146,11 +176,12 @@ def run_session( ) for raw in ws: session.dispatch(json.loads(raw)) - except (CLIError, KeyboardInterrupt): - raise # auth/protocol errors and user Ctrl-C handled upstream + except (CLIError, KeyboardInterrupt, BrokenPipeError): + raise # clean CLI errors, user Ctrl-C, and a closed pipe are handled upstream except Exception as exc: - if is_auth_failure(exc): - # The Voice Agent server closes with 1008 (policy violation) on a bad key. + if capture_error: + raise capture_error[0] from exc # a mic-open failure is the real cause + if _is_auth_rejection(exc): raise auth_failure() from exc raise APIError(f"Voice agent session failed: {exc}") from exc finally: @@ -158,3 +189,7 @@ def run_session( ws.close() if player_started: player.close() + # The receive loop can also end cleanly when the capture thread closes the + # socket after a mic failure; surface that error rather than exiting 0. + if capture_error: + raise capture_error[0] diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 3959f436..ec15e39b 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -28,8 +28,7 @@ def validate_key(api_key: str) -> bool: aai.Transcriber().list_transcripts(aai.ListTranscriptParameters(limit=1)) return True except aai.types.AssemblyAIError as exc: - msg = str(exc).lower() - if "auth" in msg or "token" in msg: + if is_auth_failure(exc): return False raise APIError(f"Could not validate key: {exc}") from exc except Exception as exc: @@ -65,6 +64,12 @@ def transcribe(api_key: str, audio: str, *, speaker_labels: bool) -> aai.Transcr return transcript +def status_str(transcript: aai.Transcript) -> str: + """The transcript's status as a plain string (SDK enum `.value` or raw value).""" + status = transcript.status + return str(getattr(status, "value", status)) + + def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: _configure(api_key) try: diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 4d62b195..2b1cfa19 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from pathlib import Path import typer @@ -74,7 +75,11 @@ def body(state: AppState, json_mode: bool) -> None: ) except KeyboardInterrupt: renderer.stopped() + except BrokenPipeError as exc: + # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. + raise typer.Exit(code=0) from exc finally: - renderer.close() + with contextlib.suppress(BrokenPipeError): + renderer.close() run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py index ad9af632..5147f347 100644 --- a/assemblyai_cli/commands/claude.py +++ b/assemblyai_cli/commands/claude.py @@ -1,8 +1,10 @@ from __future__ import annotations +import os import shutil import subprocess from pathlib import Path +from typing import TypedDict import typer from rich.markup import escape @@ -22,6 +24,14 @@ _VALID_SCOPES = ("user", "project", "local") +class Step(TypedDict): + """One line of setup output: a named step, its status, and a human detail.""" + + name: str + status: str + detail: str + + def _run(cmd: list[str], *, timeout: float = 120) -> subprocess.CompletedProcess: # stdin=DEVNULL so a child that would otherwise prompt (npx's "Ok to proceed?", # a `claude` confirmation) gets EOF and fails fast instead of hanging forever on @@ -47,7 +57,7 @@ def _mcp_present() -> bool: return _run(["claude", "mcp", "get", MCP_NAME]).returncode == 0 -def _install_mcp(scope: str, force: bool) -> dict: +def _install_mcp(scope: str, force: bool) -> Step: if shutil.which("claude") is None: return { "name": "mcp", @@ -77,7 +87,7 @@ def _install_mcp(scope: str, force: bool) -> dict: return {"name": "mcp", "status": "installed", "detail": f"{MCP_NAME} @ {scope} scope"} -def _install_skill() -> dict: +def _install_skill() -> Step: if shutil.which("npx") is None: return { "name": "skill", @@ -90,14 +100,33 @@ def _install_skill() -> dict: proc = _run(["npx", "-y", "skills", "add", SKILL_REPO], timeout=300) if proc.returncode != 0: return {"name": "skill", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} - return {"name": "skill", "status": "installed", "detail": SKILL_REPO} + # Trust the filesystem, not the exit code: confirm the skill actually landed + # where `status` looks, so the two commands can never disagree. + if not _skill_installed(): + return { + "name": "skill", + "status": "failed", + "detail": ( + f"'npx skills add {SKILL_REPO}' reported success but no skill was found at " + f"{_skill_dir()}. Install it manually: npx skills add {SKILL_REPO}" + ), + } + return {"name": "skill", "status": "installed", "detail": str(_skill_dir())} def _skill_dir() -> Path: - return Path.home() / ".claude" / "skills" / "assemblyai" + # Honor CLAUDE_CONFIG_DIR so install/status/remove agree with Claude Code's + # actual config root rather than assuming ~/.claude. + config_dir = os.environ.get("CLAUDE_CONFIG_DIR") + root = Path(config_dir) if config_dir else Path.home() / ".claude" + return root / "skills" / "assemblyai" + +def _skill_installed() -> bool: + return (_skill_dir() / "SKILL.md").exists() -def _mcp_status() -> dict: + +def _mcp_status() -> Step: if shutil.which("claude") is None: return {"name": "mcp", "status": "unknown", "detail": "Claude Code not found"} present = _mcp_present() @@ -108,16 +137,15 @@ def _mcp_status() -> dict: } -def _skill_status() -> dict: - present = (_skill_dir() / "SKILL.md").exists() +def _skill_status() -> Step: return { "name": "skill", - "status": "installed" if present else "not_installed", + "status": "installed" if _skill_installed() else "not_installed", "detail": str(_skill_dir()), } -def _remove_mcp(scope: str | None) -> dict: +def _remove_mcp(scope: str | None) -> Step: if shutil.which("claude") is None: return {"name": "mcp", "status": "skipped", "detail": "Claude Code not found"} if not _mcp_present(): @@ -131,7 +159,7 @@ def _remove_mcp(scope: str | None) -> dict: return {"name": "mcp", "status": "removed", "detail": MCP_NAME} -def _remove_skill() -> dict: +def _remove_skill() -> Step: target = _skill_dir() if not target.exists(): return {"name": "skill", "status": "not_installed", "detail": str(target)} @@ -142,9 +170,8 @@ def _remove_skill() -> dict: return {"name": "skill", "status": "removed", "detail": str(target)} -def _render_steps(data: object) -> str: - steps = data["steps"] # type: ignore[index] - lines = [f" {s['name']}: {s['status']} — {escape(str(s['detail']))}" for s in steps] +def _render_steps(data: dict[str, list[Step]]) -> str: + lines = [f" {s['name']}: {s['status']} — {escape(s['detail'])}" for s in data["steps"]] return "AssemblyAI coding-agent setup:\n" + "\n".join(lines) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 861f8578..4d501252 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -42,7 +42,7 @@ def body(state: AppState, json_mode: bool) -> None: output.emit( { "id": transcript.id, - "status": getattr(transcript.status, "value", transcript.status), + "status": client.status_str(transcript), "text": transcript.text, }, lambda d: escape(str(d["text"])), diff --git a/assemblyai_cli/commands/transcripts.py b/assemblyai_cli/commands/transcripts.py index 830d43c6..d0fbe302 100644 --- a/assemblyai_cli/commands/transcripts.py +++ b/assemblyai_cli/commands/transcripts.py @@ -22,7 +22,7 @@ def get( def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) transcript = client.get_transcript(api_key, transcript_id) - if getattr(transcript.status, "value", transcript.status) == "error": + if client.status_str(transcript) == "error": raise APIError( getattr(transcript, "error", None) or "Transcript failed.", transcript_id=transcript_id, @@ -30,7 +30,7 @@ def body(state: AppState, json_mode: bool) -> None: output.emit( { "id": transcript.id, - "status": getattr(transcript.status, "value", transcript.status), + "status": client.status_str(transcript), "text": transcript.text, }, lambda d: escape(str(d["text"])), diff --git a/assemblyai_cli/errors.py b/assemblyai_cli/errors.py index a91b0511..600b6b05 100644 --- a/assemblyai_cli/errors.py +++ b/assemblyai_cli/errors.py @@ -40,10 +40,11 @@ def __init__(self, message: str) -> None: super().__init__(message, error_type="usage_error", exit_code=2) -# Substrings that mark a failure as "the credentials were rejected" rather than a -# generic network/protocol error. Matched case-insensitively against str(exc). -# Includes WebSocket close 1008 (policy violation), which is how the Voice Agent -# server signals a bad API key. +# Word-level phrases that mark a failure as "the credentials were rejected" rather +# than a generic network/protocol error. Matched case-insensitively against str(exc). +# Deliberately NOT bare numbers like "401"/"403"/"1008": those match unrelated text +# (transcript ids, byte counts, ports). HTTP status codes and the Voice Agent's 1008 +# close are detected structurally at the call site instead (see agent/session.py). _AUTH_FAILURE_HINTS = ( "unauthorized", "forbidden", @@ -51,10 +52,6 @@ def __init__(self, message: str) -> None: "api token", "invalid api key", "invalid key", - "401", - "403", - "1008", - "policy violation", ) REJECTED_KEY_MESSAGE = ( diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py index a7d8ae54..b0124e5d 100644 --- a/assemblyai_cli/microphone.py +++ b/assemblyai_cli/microphone.py @@ -5,9 +5,14 @@ from assemblyai_cli.errors import CLIError -_MIC_MISSING_MSG = ( - "Microphone support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio" -) + +def pyaudio_missing_error() -> CLIError: + """The shared 'PyAudio can't be imported' error for mic and speaker paths.""" + return CLIError( + "Audio support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio", + error_type="mic_missing", + exit_code=2, + ) def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[bytes]: @@ -39,7 +44,7 @@ def __iter__(self) -> Iterator[bytes]: try: stream: Any = self._factory(sample_rate=self.sample_rate, device=self.device) except ImportError as exc: - raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc + raise pyaudio_missing_error() from exc except Exception as exc: raise CLIError( f"Could not open the microphone (device {self.device}): {exc}", diff --git a/assemblyai_cli/render.py b/assemblyai_cli/render.py index dbc423bf..3f000f11 100644 --- a/assemblyai_cli/render.py +++ b/assemblyai_cli/render.py @@ -1,6 +1,5 @@ from __future__ import annotations -import contextlib import json import sys from typing import TextIO @@ -25,9 +24,14 @@ def _emit(self, obj: object) -> None: self._write(json.dumps(obj) + "\n") def _write(self, text: str) -> None: - with contextlib.suppress(Exception): # downstream pipe may be closed + try: self.out.write(text) self.out.flush() + except BrokenPipeError: + # Consumer (e.g. `| head`) went away — let the command stop cleanly. + raise + except Exception: # noqa: BLE001, S110 - other downstream write errors are non-fatal + pass def _update_line(self, text: str) -> None: """Redraw the current line in place (no trailing newline).""" diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py index 1e5d7211..ab3629ac 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/assemblyai_cli/streaming/sources.py @@ -86,18 +86,23 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: stdout = proc.stdout if stdout is None: # pragma: no cover - defensive; PIPE always yields a stream raise APIError("ffmpeg did not expose an output stream.") + completed = False try: while True: data = stdout.read(CHUNK_BYTES) if not data: break yield data + completed = True # natural EOF: let ffmpeg exit on its own finally: - proc.terminate() + # SIGTERM only on early stop (generator close) or error — terminating a + # process that already finished would surface as a spurious exit -15. + if not completed: + proc.terminate() with contextlib.suppress(Exception): stdout.close() proc.wait() - # Reached only on natural EOF (not early generator close): surface a + # Reached only on natural EOF (not early generator close): surface a real # decode failure instead of silently streaming nothing. if proc.returncode: detail = proc.stderr.read().decode("utf-8", "replace").strip() if proc.stderr else "" diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 504ded37..5be2137f 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -100,7 +100,21 @@ def test_json_user_partial_emits_delta(): assert _json_lines(buf) == [{"type": "transcript.user.delta", "text": "typing…"}] -def test_write_swallows_broken_pipe(): +def test_write_swallows_non_pipe_errors(): + class FlakyOut: + def write(self, _text): + raise OSError("transient write error") + + def flush(self): + pass + + # Non-pipe write errors are non-fatal and must not raise. + AgentRenderer(json_mode=False, out=FlakyOut()).notice("anything") + + +def test_write_propagates_broken_pipe(): + import pytest + class BrokenOut: def write(self, _text): raise BrokenPipeError("downstream closed") @@ -108,5 +122,6 @@ def write(self, _text): def flush(self): pass - # Must not raise even though the underlying stream is broken. - AgentRenderer(json_mode=False, out=BrokenOut()).notice("anything") + # BrokenPipeError must propagate so the command can stop cleanly (`| head`). + with pytest.raises(BrokenPipeError): + AgentRenderer(json_mode=False, out=BrokenOut()).notice("x") diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index 3a89ca75..40625c28 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -184,12 +184,17 @@ def test_send_audio_loop_stops_on_send_error(): _send_audio_loop(ws, s, [b"\x01\x02", b"\x03\x04"]) -_POLICY_VIOLATION = "received 1008 (policy violation); then sent 1008 (policy violation)" +class _CloseError(Exception): + """Mimics websockets.ConnectionClosed carrying a structured close code.""" + + def __init__(self, code): + super().__init__(f"received {code} (policy violation)") + self.code = code def test_run_session_connect_auth_failure_raises_not_authenticated(): def bad_connect(url, **kwargs): - raise RuntimeError(_POLICY_VIOLATION) + raise _CloseError(1008) # Voice Agent rejects a bad key with close 1008 with pytest.raises(NotAuthenticated): run_session( @@ -210,7 +215,7 @@ def send(self, _msg): pass def __iter__(self): - raise RuntimeError(_POLICY_VIOLATION) + raise _CloseError(1008) def close(self): pass @@ -230,6 +235,43 @@ def close(self): assert player.closed is True # speaker stream still torn down +def test_run_session_surfaces_mic_open_failure_from_capture_thread(): + import threading as _threading + + from assemblyai_cli.errors import CLIError + + class _BoomMic: + def __iter__(self): + raise CLIError("no microphone", error_type="mic_error", exit_code=1) + + class _BlockingWS: + def __init__(self): + self._closed = _threading.Event() + + def send(self, _msg): + pass + + def __iter__(self): + self._closed.wait(timeout=2) # unblocked when the capture thread closes us + return iter(()) + + def close(self): + self._closed.set() + + with pytest.raises(CLIError) as exc: + run_session( + "sk_live", + renderer=FakeRenderer(), + player=FakePlayer(), + mic=_BoomMic(), + voice="ivy", + system_prompt="x", + greeting="hi", + connect=lambda url, **kwargs: _BlockingWS(), + ) + assert exc.value.exit_code == 1 # the real mic failure reaches the user, not a hang + + def test_run_session_non_auth_failure_stays_api_error(): def boom(url, **kwargs): raise RuntimeError("network unreachable") diff --git a/tests/test_claude.py b/tests/test_claude.py index 249532e9..dea97e86 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -1,6 +1,8 @@ import json import subprocess +from pathlib import Path +import pytest from typer.testing import CliRunner from assemblyai_cli.main import app @@ -8,16 +10,26 @@ runner = CliRunner() +@pytest.fixture(autouse=True) +def _isolate_home(tmp_path, monkeypatch): + """Keep skill writes/reads inside a temp HOME so tests never touch ~/.claude.""" + monkeypatch.setenv("HOME", str(tmp_path)) + + class FakeRun: """Records subprocess calls and returns canned CompletedProcess results. `returncodes` maps a command prefix tuple (the first N argv tokens) to a - return code; the longest matching prefix wins, default 0. + return code; the longest matching prefix wins, default 0. When + `creates_skill` is set, a successful `npx … add` materializes a SKILL.md + under HOME — mimicking a real install so `_install_skill`'s filesystem + verification passes. """ - def __init__(self, returncodes=None): + def __init__(self, returncodes=None, *, creates_skill=True): self.calls = [] self.returncodes = returncodes or {} + self.creates_skill = creates_skill def __call__(self, cmd, *args, **kwargs): self.calls.append(cmd) @@ -27,6 +39,10 @@ def __call__(self, cmd, *args, **kwargs): n = len(prefix) if tuple(cmd[:n]) == prefix and n > best: rc, best = code, n + if rc == 0 and self.creates_skill and cmd[:1] == ["npx"] and "add" in cmd: + skill = Path.home() / ".claude" / "skills" / "assemblyai" + skill.mkdir(parents=True, exist_ok=True) + (skill / "SKILL.md").write_text("# AssemblyAI") return subprocess.CompletedProcess(args=cmd, returncode=rc, stdout="", stderr="boom") @@ -64,6 +80,24 @@ def test_install_happy_path_runs_both_steps(monkeypatch): assert ["npx", "-y", "skills", "add", "AssemblyAI/assemblyai-skill"] in fake.calls +def test_install_skill_failed_when_npx_succeeds_but_nothing_installed(monkeypatch): + # Regression: `install` must verify the skill landed, not trust npx's exit + # code — otherwise install says "installed" while status says "not_installed". + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 1}, creates_skill=False) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 1 # skill step failed + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses["skill"] == "failed" + + # And status agrees: still not installed. + status_result = runner.invoke(app, ["claude", "status"]) + skill = {s["name"]: s["status"] for s in json.loads(status_result.output)["steps"]}["skill"] + assert skill == "not_installed" + + def test_install_detaches_stdin_and_sets_timeout(monkeypatch): """Regression: subprocess children must not inherit stdin, or an interactive prompt (npx, claude) hangs the CLI forever. Each call must pass a timeout too.""" diff --git a/tests/test_errors.py b/tests/test_errors.py index 4e3e096c..3e9d4caf 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -23,12 +23,20 @@ def test_to_dict_omits_none_transcript_id(): def test_is_auth_failure_matches_credential_signals(): - assert is_auth_failure(RuntimeError("received 1008 (policy violation)")) assert is_auth_failure(Exception("HTTP 401 Unauthorized")) + assert is_auth_failure(Exception("Forbidden")) assert is_auth_failure(Exception("Authentication error, API token missing/invalid")) + assert is_auth_failure(Exception("Invalid API key")) def test_is_auth_failure_ignores_generic_errors(): assert not is_auth_failure(RuntimeError("network unreachable")) assert not is_auth_failure(Exception("server exploded")) assert not is_auth_failure(ConnectionError("handshake refused")) + + +def test_is_auth_failure_ignores_bare_numeric_substrings(): + # Numbers like 401/403/1008 embedded in unrelated text must NOT be treated as auth. + assert not is_auth_failure(Exception("decode failed at frame 1008")) + assert not is_auth_failure(Exception("transcript abc401 not found")) + assert not is_auth_failure(Exception("retry after 403 seconds")) diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index 809eb855..a4433b3f 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -83,7 +83,22 @@ def test_close_is_noop_in_json_mode(): assert out.getvalue() == before -def test_close_swallows_broken_pipe(): +def test_close_swallows_non_pipe_errors(): + class FlakyOut: + def write(self, _text): + raise OSError("transient write error") + + def flush(self): + pass + + r = StreamRenderer(json_mode=False, out=FlakyOut()) + r._line_open = True # force the finalize path + r.close() # non-pipe errors are non-fatal + + +def test_close_propagates_broken_pipe(): + import pytest + class BrokenOut: def write(self, _text): raise BrokenPipeError("downstream closed") @@ -93,4 +108,5 @@ def flush(self): r = StreamRenderer(json_mode=False, out=BrokenOut()) r._line_open = True # force the finalize path - r.close() # must not raise + with pytest.raises(BrokenPipeError): # propagates so the command stops cleanly + r.close() From e6e74b01a56e4a91355d4b43f585c566b53f1f13 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 09:28:38 -0700 Subject: [PATCH 07/87] fix(claude): install skill at user scope and remove via skills CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the real `skills` CLI (vercel-labs/skills): - `skills add` auto-selects PROJECT scope when run inside a project, so a bare `skills add` from a repo never reached ~/.claude/skills — hence install said "installed" while status said "not_installed". Pass --global (+ --yes) to pin user scope, matching where status looks. Skill name confirmed: "assemblyai". - the skill is symlinked into ~/.claude/skills from the skills store, so shutil.rmtree couldn't remove it; `_remove_skill` now shells out to `npx skills remove assemblyai --global` (and verifies it's gone). End-to-end: `aai claude install` → `status` → `remove` now agree on a real machine. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/claude.py | 42 ++++++++++++++++--------- tests/test_claude.py | 51 +++++++++++++++++++------------ 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py index 5147f347..d4c05824 100644 --- a/assemblyai_cli/commands/claude.py +++ b/assemblyai_cli/commands/claude.py @@ -87,17 +87,22 @@ def _install_mcp(scope: str, force: bool) -> Step: return {"name": "mcp", "status": "installed", "detail": f"{MCP_NAME} @ {scope} scope"} +_SKILL_ADD = ["npx", "-y", "skills", "add", SKILL_REPO, "--global", "--yes"] +_SKILL_REMOVE = ["npx", "-y", "skills", "remove", "assemblyai", "--global"] +_SKILL_ADD_HINT = f"npx skills add {SKILL_REPO} --global" + + def _install_skill() -> Step: if shutil.which("npx") is None: return { "name": "skill", "status": "skipped", - "detail": ( - f"Node.js/npx not found. Install Node.js, then run: npx skills add {SKILL_REPO}" - ), + "detail": f"Node.js/npx not found. Install Node.js, then run: {_SKILL_ADD_HINT}", } - # -y: skip npx's interactive "Ok to proceed?" prompt; longer timeout covers the download. - proc = _run(["npx", "-y", "skills", "add", SKILL_REPO], timeout=300) + # --global: install at user scope (not project scope, which `skills` auto-selects + # when run inside a project) so the skill lands in ~/.claude/skills where `status` + # looks. npx -y skips its install prompt; the longer timeout covers the download. + proc = _run(_SKILL_ADD, timeout=300) if proc.returncode != 0: return {"name": "skill", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} # Trust the filesystem, not the exit code: confirm the skill actually landed @@ -107,8 +112,8 @@ def _install_skill() -> Step: "name": "skill", "status": "failed", "detail": ( - f"'npx skills add {SKILL_REPO}' reported success but no skill was found at " - f"{_skill_dir()}. Install it manually: npx skills add {SKILL_REPO}" + f"'{' '.join(_SKILL_ADD[3:])}' reported success but no skill was found at " + f"{_skill_dir()}. Install it manually: {_SKILL_ADD_HINT}" ), } return {"name": "skill", "status": "installed", "detail": str(_skill_dir())} @@ -160,14 +165,21 @@ def _remove_mcp(scope: str | None) -> Step: def _remove_skill() -> Step: - target = _skill_dir() - if not target.exists(): - return {"name": "skill", "status": "not_installed", "detail": str(target)} - try: - shutil.rmtree(target) - except OSError as err: - return {"name": "skill", "status": "failed", "detail": str(err)} - return {"name": "skill", "status": "removed", "detail": str(target)} + if not _skill_installed(): + return {"name": "skill", "status": "not_installed", "detail": str(_skill_dir())} + if shutil.which("npx") is None: + return { + "name": "skill", + "status": "skipped", + "detail": "Node.js/npx not found. Remove manually: npx skills remove assemblyai --global", + } + # `skills` symlinks the skill into ~/.claude/skills from its own store, so let it + # do the removal (a plain rmtree would choke on the symlink and orphan the store). + proc = _run(_SKILL_REMOVE, timeout=120) + if proc.returncode != 0 or _skill_installed(): + detail = (proc.stderr or proc.stdout).strip() or "skill still present after removal" + return {"name": "skill", "status": "failed", "detail": detail} + return {"name": "skill", "status": "removed", "detail": str(_skill_dir())} def _render_steps(data: dict[str, list[Step]]) -> str: diff --git a/tests/test_claude.py b/tests/test_claude.py index dea97e86..d03fd8ca 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -1,4 +1,5 @@ import json +import shutil import subprocess from pathlib import Path @@ -14,22 +15,28 @@ def _isolate_home(tmp_path, monkeypatch): """Keep skill writes/reads inside a temp HOME so tests never touch ~/.claude.""" monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + + +def _skill_path() -> Path: + return Path.home() / ".claude" / "skills" / "assemblyai" class FakeRun: """Records subprocess calls and returns canned CompletedProcess results. `returncodes` maps a command prefix tuple (the first N argv tokens) to a - return code; the longest matching prefix wins, default 0. When - `creates_skill` is set, a successful `npx … add` materializes a SKILL.md - under HOME — mimicking a real install so `_install_skill`'s filesystem - verification passes. + return code; the longest matching prefix wins, default 0. To mimic the real + `skills` CLI, a successful `npx … add` materializes the skill under HOME + (so `_install_skill`'s filesystem check passes) and `npx … remove` deletes + it — toggle with `creates_skill` / `removes_skill`. """ - def __init__(self, returncodes=None, *, creates_skill=True): + def __init__(self, returncodes=None, *, creates_skill=True, removes_skill=True): self.calls = [] self.returncodes = returncodes or {} self.creates_skill = creates_skill + self.removes_skill = removes_skill def __call__(self, cmd, *args, **kwargs): self.calls.append(cmd) @@ -39,10 +46,12 @@ def __call__(self, cmd, *args, **kwargs): n = len(prefix) if tuple(cmd[:n]) == prefix and n > best: rc, best = code, n - if rc == 0 and self.creates_skill and cmd[:1] == ["npx"] and "add" in cmd: - skill = Path.home() / ".claude" / "skills" / "assemblyai" - skill.mkdir(parents=True, exist_ok=True) - (skill / "SKILL.md").write_text("# AssemblyAI") + if rc == 0 and cmd[:1] == ["npx"]: + if "add" in cmd and self.creates_skill: + _skill_path().mkdir(parents=True, exist_ok=True) + (_skill_path() / "SKILL.md").write_text("# AssemblyAI") + elif "remove" in cmd and self.removes_skill: + shutil.rmtree(_skill_path(), ignore_errors=True) return subprocess.CompletedProcess(args=cmd, returncode=rc, stdout="", stderr="boom") @@ -77,7 +86,15 @@ def test_install_happy_path_runs_both_steps(monkeypatch): "assemblyai-docs", "https://mcp.assemblyai.com/docs", ] in fake.calls - assert ["npx", "-y", "skills", "add", "AssemblyAI/assemblyai-skill"] in fake.calls + assert [ + "npx", + "-y", + "skills", + "add", + "AssemblyAI/assemblyai-skill", + "--global", + "--yes", + ] in fake.calls def test_install_skill_failed_when_npx_succeeds_but_nothing_installed(monkeypatch): @@ -290,6 +307,7 @@ def test_remove_unwinds_both(monkeypatch, tmp_path): statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} assert statuses == {"mcp": "removed", "skill": "removed"} assert ["claude", "mcp", "remove", "assemblyai-docs"] in fake.calls + assert ["npx", "-y", "skills", "remove", "assemblyai", "--global"] in fake.calls assert not skill.exists() @@ -308,21 +326,16 @@ def test_remove_when_absent_is_not_an_error(monkeypatch, tmp_path): def test_remove_skill_failure_reports_failed(monkeypatch, tmp_path): _all_tools_present(monkeypatch) - monkeypatch.setenv("HOME", str(tmp_path)) - skill = tmp_path / ".claude" / "skills" / "assemblyai" + skill = _skill_path() skill.mkdir(parents=True) (skill / "SKILL.md").write_text("# AssemblyAI") - # MCP absent so only the skill step can fail. + # MCP absent (so only the skill step can fail) and `npx skills remove` runs but + # leaves the skill in place -> install/remove must report it as failed, not removed. monkeypatch.setattr( "assemblyai_cli.commands.claude.subprocess.run", - FakeRun({("claude", "mcp", "get"): 1}), + FakeRun({("claude", "mcp", "get"): 1}, removes_skill=False), ) - def boom(_path): - raise PermissionError("locked") - - monkeypatch.setattr("assemblyai_cli.commands.claude.shutil.rmtree", boom) - result = runner.invoke(app, ["claude", "remove"]) assert result.exit_code == 1 statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} From 49db820b223770e56dabd1d4abacb5b63b8d06ae Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 09:40:48 -0700 Subject: [PATCH 08/87] feat(stream): support --sample and file/URL sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aai stream` now mirrors `aai transcribe`: - `--sample` streams the same hosted wildfires.mp3 clip - a positional source can be a local file OR an http(s) URL (decoded via ffmpeg, which reads URLs natively — verified it yields 16k mono PCM from the sample URL) Shared the source-resolution logic in client.resolve_audio_source() so transcribe and stream don't duplicate the --sample / "provide a path or URL" handling. FileSource grew a URL branch (skips the local is_file/WAV fast-path, always ffmpeg). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 ++ assemblyai_cli/client.py | 14 ++++++++- assemblyai_cli/commands/stream.py | 14 +++++---- assemblyai_cli/commands/transcribe.py | 4 +-- assemblyai_cli/streaming/sources.py | 36 +++++++++++++++------- tests/test_client.py | 9 ++++++ tests/test_stream_command.py | 43 +++++++++++++++++++++++++++ tests/test_streaming_sources.py | 34 +++++++++++++++++++++ 8 files changed, 136 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e8216c53..189798f2 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,10 @@ output is piped or run by an agent). Auth problems surface as a clean ## Streaming ```sh +aai stream --sample # stream the hosted wildfires.mp3 sample (same clip as transcribe) aai stream path/to/audio.wav # 16 kHz mono WAV streams directly aai stream path/to/audio.mp3 # other formats need ffmpeg on PATH +aai stream https://…/clip.mp3 # a URL works too (decoded via ffmpeg) aai stream # from the microphone; Ctrl-C to stop ``` diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index ec15e39b..60f46c64 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -12,11 +12,23 @@ StreamingParameters, ) -from assemblyai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure +from assemblyai_cli.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" +def resolve_audio_source(source: str | None, *, sample: bool) -> str: + """The audio reference to use: the hosted --sample clip, else the given path/URL. + + Shared by `transcribe` and `stream` so both accept a file or URL and `--sample`. + """ + if sample: + return SAMPLE_AUDIO_URL + if not source: + raise UsageError("Provide an audio path/URL or use --sample.") + return source + + def _configure(api_key: str) -> None: aai.settings.api_key = api_key diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 9a099546..419113cd 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -15,22 +15,26 @@ @app.command() def stream( ctx: typer.Context, - source: str = typer.Argument(None, help="Audio file to stream. Omit to use the microphone."), + source: str = typer.Argument( + None, help="Audio file path or URL to stream. Omit to use the microphone." + ), + sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."), sample_rate: int = typer.Option( TARGET_RATE, "--sample-rate", help="Microphone sample rate in Hz." ), device: int | None = typer.Option(None, "--device", help="Microphone device index."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), ) -> None: - """Transcribe live audio from the microphone or a file in real time.""" + """Transcribe live audio from the microphone, a file, or a URL in real time.""" def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) - if source and (sample_rate != TARGET_RATE or device is not None): + from_file = bool(source) or sample + if from_file and (sample_rate != TARGET_RATE or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") audio: FileSource | MicrophoneSource - if source: - audio = FileSource(source) + if from_file: + audio = FileSource(client.resolve_audio_source(source, sample=sample)) rate = audio.sample_rate else: audio = MicrophoneSource(sample_rate=sample_rate, device=device) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 4d501252..bd16ea0e 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -25,9 +25,7 @@ def transcribe( def body(state: AppState, json_mode: bool) -> None: if srt and vtt: raise UsageError("--srt and --vtt are mutually exclusive.") - audio = client.SAMPLE_AUDIO_URL if sample else source - if not audio: - raise UsageError("Provide an audio path/URL or use --sample.") + audio = client.resolve_audio_source(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) transcript = client.transcribe(api_key, audio, speaker_labels=speaker_labels) diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py index ab3629ac..059336f2 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/assemblyai_cli/streaming/sources.py @@ -24,19 +24,31 @@ def _is_streamable_wav(path: Path) -> bool: return False +def _is_url(source: str) -> bool: + return source.startswith(("http://", "https://")) + + class FileSource: - """Yields real-time-paced 16 kHz mono PCM chunks from an audio file.""" + """Yields real-time-paced 16 kHz mono PCM chunks from a local file or a URL.""" - def __init__(self, path: str, *, sleep: Callable[[float], object] = time.sleep) -> None: - self.path = Path(path) + def __init__(self, source: str, *, sleep: Callable[[float], object] = time.sleep) -> None: + self.source = source self._sleep = sleep self.sample_rate = TARGET_RATE - if not self.path.is_file(): - raise CLIError(f"No such file: {self.path}", error_type="file_not_found", exit_code=2) - self._wav = _is_streamable_wav(self.path) + # Local paths get a fast WAV path and an existence check; URLs always decode + # through ffmpeg (which reads http/https inputs natively). + self._path = None if _is_url(source) else Path(source) + if self._path is not None: + if not self._path.is_file(): + raise CLIError( + f"No such file: {self._path}", error_type="file_not_found", exit_code=2 + ) + self._wav = _is_streamable_wav(self._path) + else: + self._wav = False if not self._wav and shutil.which("ffmpeg") is None: raise CLIError( - "This audio format needs ffmpeg. Install ffmpeg, or pass a 16 kHz mono 16-bit WAV.", + "This audio source needs ffmpeg. Install ffmpeg, or pass a 16 kHz mono 16-bit WAV.", error_type="ffmpeg_missing", exit_code=2, ) @@ -49,11 +61,13 @@ def __iter__(self) -> Iterator[bytes]: yield chunk self._sleep(len(chunk) / (TARGET_RATE * 2)) # ~real-time pacing if produced == 0: - raise CLIError(f"No audio data in {self.path}.", error_type="empty_audio", exit_code=2) + raise CLIError( + f"No audio data in {self.source}.", error_type="empty_audio", exit_code=2 + ) def _wav_chunks(self) -> Iterator[bytes]: frames_per_chunk = CHUNK_BYTES // 2 - with wave.open(str(self.path), "rb") as w: + with wave.open(str(self._path), "rb") as w: # _wav implies a local path while True: data = w.readframes(frames_per_chunk) if not data: @@ -68,7 +82,7 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: "-loglevel", "error", "-i", - str(self.path), + self.source, # a local path or an http(s) URL; ffmpeg reads both "-f", "s16le", "-acodec", @@ -107,7 +121,7 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: if proc.returncode: detail = proc.stderr.read().decode("utf-8", "replace").strip() if proc.stderr else "" raise APIError( - f"ffmpeg could not decode {self.path}: {detail or f'exit {proc.returncode}'}" + f"ffmpeg could not decode {self.source}: {detail or f'exit {proc.returncode}'}" ) diff --git a/tests/test_client.py b/tests/test_client.py index 761e61e3..7c5188db 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -66,6 +66,15 @@ def test_list_transcripts_rejected_key_becomes_not_authenticated(): client.list_transcripts("sk_bad") +def test_resolve_audio_source_sample_explicit_and_missing(): + from assemblyai_cli.errors import UsageError + + assert client.resolve_audio_source(None, sample=True) == client.SAMPLE_AUDIO_URL + assert client.resolve_audio_source("clip.mp3", sample=False) == "clip.mp3" + with pytest.raises(UsageError): + client.resolve_audio_source(None, sample=False) + + def test_transcribe_blocks_and_returns_transcript(): fake_transcript = MagicMock() fake_transcript.status = client.aai.TranscriptStatus.completed diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 1bc14fd3..7e8dc712 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -62,6 +62,49 @@ def test_stream_unauthenticated_exits_2(): assert result.exit_code == 2 +def _capture_source(seen): + def fake(api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None): + seen["source"] = source + seen["rate"] = sample_rate + + return fake + + +def test_stream_sample_uses_hosted_clip(monkeypatch): + from assemblyai_cli import client + + config.set_api_key("default", "sk_live") + monkeypatch.setattr( + "assemblyai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" + ) + seen = {} + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _capture_source(seen)) + result = runner.invoke(app, ["stream", "--sample"]) + assert result.exit_code == 0 + assert type(seen["source"]).__name__ == "FileSource" + assert seen["source"].source == client.SAMPLE_AUDIO_URL # same clip as `transcribe --sample` + assert seen["rate"] == 16000 + + +def test_stream_url_source_uses_filesource(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr( + "assemblyai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" + ) + seen = {} + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _capture_source(seen)) + result = runner.invoke(app, ["stream", "https://example.com/clip.mp3"]) + assert result.exit_code == 0 + assert type(seen["source"]).__name__ == "FileSource" + assert seen["source"].source == "https://example.com/clip.mp3" + + +def test_stream_sample_with_sample_rate_rejected(): + config.set_api_key("default", "sk_live") + result = runner.invoke(app, ["stream", "--sample", "--sample-rate", "44100"]) + assert result.exit_code == 2 # mic-only flags don't apply to a file/sample source + + def test_stream_ctrl_c_exits_cleanly(monkeypatch): config.set_api_key("default", "sk_live") diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index 7c1362f9..a7069869 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -130,3 +130,37 @@ def test_filesource_empty_wav_raises(tmp_path): with pytest.raises(CLIError) as exc: list(FileSource(str(p), sleep=lambda _s: None)) assert exc.value.error_type == "empty_audio" + + +def test_filesource_url_skips_local_check_and_streams_via_ffmpeg(monkeypatch): + monkeypatch.setattr(sources.shutil, "which", lambda _n: "/usr/bin/ffmpeg") + captured = {} + + class FakeProc: + def __init__(self): + self.stdout = io.BytesIO(b"\x00" * 3200) + self.stderr = io.BytesIO(b"") + self.returncode = 0 + + def terminate(self): + pass + + def wait(self): + pass + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + return FakeProc() + + monkeypatch.setattr(sources.subprocess, "Popen", fake_popen) + url = "https://example.com/clip.mp3" + chunks = list(FileSource(url, sleep=lambda _s: None)) # no is_file() check for URLs + assert chunks == [b"\x00" * 3200] + assert url in captured["cmd"] # passed straight to ffmpeg's -i + + +def test_filesource_url_without_ffmpeg_raises(monkeypatch): + monkeypatch.setattr(sources.shutil, "which", lambda _n: None) + with pytest.raises(CLIError) as exc: + FileSource("https://example.com/clip.mp3") + assert exc.value.error_type == "ffmpeg_missing" From 7268e76b5d2dc5eaec5611405f7c7f83f3dc687d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 10:01:48 -0700 Subject: [PATCH 09/87] feat(audio): switch audio backend from PyAudio to sounddevice PyAudio ships no macOS/Linux wheels, so a fresh install had to compile it from source against PortAudio headers (brew install portaudio + a compiler). sounddevice bundles PortAudio in its macOS/Windows wheels, so `pip install` now works with zero system dependencies on those platforms; Linux needs only the libportaudio2 runtime (no headers/compiler). - microphone.py: replace SDK PyAudio MicrophoneStream with a sounddevice RawInputStream iterator (_SoundDeviceMic); rename pyaudio_missing_error -> audio_missing_error - agent/audio.py: Player uses sounddevice RawOutputStream; simpler teardown - pyproject: pyaudio -> sounddevice; agent.py.tmpl + README updated - tests: cover _SoundDeviceMic, both factories, and missing-dep/device-failure branches (coverage 97.4%) Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +-- assemblyai_cli/agent/audio.py | 21 +++----- assemblyai_cli/agent/session.py | 4 +- assemblyai_cli/microphone.py | 46 +++++++++++++++--- assemblyai_cli/templates/agent.py.tmpl | 17 ++++--- pyproject.toml | 4 +- tests/test_agent_audio.py | 51 +++++++++++++++++++- tests/test_microphone.py | 67 ++++++++++++++++++++++++-- tests/test_samples.py | 2 + 9 files changed, 178 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 189798f2..3d83a44f 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ pipx install "git+https://github.com/AssemblyAI/cli.git" # or: pip install --u ``` Microphone and speaker support (for `stream` and `agent`) is **included by default** — -no extra install step. PyAudio ships prebuilt wheels for macOS, Windows, and Linux; on -Linux without a wheel you may need the PortAudio headers first (`sudo apt-get install -portaudio19-dev`). +no extra install step. Audio runs on [`sounddevice`](https://python-sounddevice.readthedocs.io), +whose macOS and Windows wheels bundle PortAudio, so there's nothing else to install. On Linux, +install the PortAudio runtime once (`sudo apt-get install libportaudio2`). ## Quick start diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index 44b371cb..10284a26 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -7,27 +7,26 @@ from typing import Any from assemblyai_cli.errors import CLIError -from assemblyai_cli.microphone import pyaudio_missing_error +from assemblyai_cli.microphone import audio_missing_error SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate def _default_output_stream(rate: int) -> Any: - """Open a PyAudio PCM16 mono output stream (imported lazily to keep startup fast).""" + """Open a sounddevice PCM16 mono output stream (imported lazily to keep startup fast).""" try: - import pyaudio + import sounddevice as sd except ImportError as exc: - raise pyaudio_missing_error() from exc + raise audio_missing_error() from exc try: - pa = pyaudio.PyAudio() - stream = pa.open(format=pyaudio.paInt16, channels=1, rate=rate, output=True) + stream = sd.RawOutputStream(samplerate=rate, channels=1, dtype="int16") + stream.start() except Exception as exc: raise CLIError( f"Could not open the audio output device: {exc}", error_type="audio_output_error", exit_code=1, ) from exc - stream._pa = pa # retain so PyAudio isn't GC'd before the stream; terminated in Player.close() return stream @@ -43,7 +42,7 @@ def __init__( self._rate = sample_rate self._factory = stream_factory or _default_output_stream self._queue: queue.Queue[bytes | None] = queue.Queue() - # PyAudio stream (or a test double); typed Any since pyaudio ships no stubs. + # sounddevice stream (or a test double); typed Any since sounddevice ships no stubs. self._stream: Any = None self._thread: threading.Thread | None = None @@ -82,16 +81,12 @@ def close(self) -> None: # thread returns promptly, avoiding a teardown race with the join below. if self._stream is not None: with contextlib.suppress(Exception): - self._stream.stop_stream() + self._stream.stop() if self._thread is not None: self._thread.join(timeout=2) if self._stream is not None: - pa = getattr(self._stream, "_pa", None) with contextlib.suppress(Exception): self._stream.close() - if pa is not None: - with contextlib.suppress(Exception): - pa.terminate() # Microphone capture (MicrophoneSource) lives in assemblyai_cli.microphone and is diff --git a/assemblyai_cli/agent/session.py b/assemblyai_cli/agent/session.py index ac87162a..f26d939a 100644 --- a/assemblyai_cli/agent/session.py +++ b/assemblyai_cli/agent/session.py @@ -145,7 +145,7 @@ def run_session( raise APIError(f"Could not connect to the voice agent: {exc}") from exc # The mic opens lazily on first iteration, inside the capture thread; a failure - # there (no device, PyAudio missing) must reach the user instead of vanishing + # there (no device, sounddevice missing) must reach the user instead of vanishing # with the daemon thread. Capture it and close the socket to end the receive loop. capture_error: list[CLIError] = [] @@ -159,7 +159,7 @@ def _capture() -> None: player_started = False try: - player.start() # opens the speaker stream; CLIError here if PyAudio can't load + player.start() # opens the speaker stream; CLIError here if sounddevice can't load player_started = True threading.Thread(target=_capture, daemon=True).start() ws.send( diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py index b0124e5d..ac3e6225 100644 --- a/assemblyai_cli/microphone.py +++ b/assemblyai_cli/microphone.py @@ -1,25 +1,55 @@ from __future__ import annotations from collections.abc import Callable, Iterator -from typing import Any, cast +from typing import Any from assemblyai_cli.errors import CLIError -def pyaudio_missing_error() -> CLIError: - """The shared 'PyAudio can't be imported' error for mic and speaker paths.""" +def audio_missing_error() -> CLIError: + """The shared 'sounddevice can't be imported' error for mic and speaker paths.""" return CLIError( - "Audio support (PyAudio) is unavailable. Try: pip install --force-reinstall pyaudio", + "Audio support (sounddevice) is unavailable. Try: pip install --force-reinstall sounddevice", error_type="mic_missing", exit_code=2, ) +class _SoundDeviceMic: + """Iterator of PCM16 byte chunks from a sounddevice raw input stream. + + Yields ~100 ms blocks; closeable so MicrophoneSource can tear it down. + """ + + def __init__(self, stream: Any, blocksize: int) -> None: + self._stream = stream + self._blocksize = blocksize + + def __iter__(self) -> Iterator[bytes]: + return self + + def __next__(self) -> bytes: + data, _overflowed = self._stream.read(self._blocksize) + return bytes(data) + + def close(self) -> None: + self._stream.stop() + self._stream.close() + + def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[bytes]: - """The SDK's PyAudio-backed mic stream (imported lazily to keep startup fast).""" - from assemblyai.extras import MicrophoneStream + """A sounddevice-backed PCM16 mic stream (imported lazily to keep startup fast).""" + try: + import sounddevice as sd + except ImportError as exc: + raise audio_missing_error() from exc - return cast(Iterator[bytes], MicrophoneStream(sample_rate=sample_rate, device_index=device)) + blocksize = max(1, sample_rate // 10) # ~100 ms per read + stream = sd.RawInputStream( + samplerate=sample_rate, device=device, channels=1, dtype="int16", blocksize=blocksize + ) + stream.start() + return _SoundDeviceMic(stream, blocksize) class MicrophoneSource: @@ -44,7 +74,7 @@ def __iter__(self) -> Iterator[bytes]: try: stream: Any = self._factory(sample_rate=self.sample_rate, device=self.device) except ImportError as exc: - raise pyaudio_missing_error() from exc + raise audio_missing_error() from exc except Exception as exc: raise CLIError( f"Could not open the microphone (device {self.device}): {exc}", diff --git a/assemblyai_cli/templates/agent.py.tmpl b/assemblyai_cli/templates/agent.py.tmpl index a960aa8d..19391135 100644 --- a/assemblyai_cli/templates/agent.py.tmpl +++ b/assemblyai_cli/templates/agent.py.tmpl @@ -1,5 +1,5 @@ # Live two-way voice conversation with an AssemblyAI voice agent. -# Requires audio support: pip install "assemblyai[extras]" pyaudio +# Requires audio support: pip install sounddevice websockets # # Tip: use headphones. This sample keeps the mic open while the agent speaks, # so without them the agent may hear (and respond to) its own voice. @@ -7,16 +7,17 @@ import base64 import json import threading -import pyaudio +import sounddevice as sd from websockets.sync.client import connect API_KEY = "{{API_KEY}}" WS_URL = "wss://agents.assemblyai.com/v1/ws" RATE = 24000 # Voice Agent native PCM16 mono sample rate -pa = pyaudio.PyAudio() -speaker = pa.open(format=pyaudio.paInt16, channels=1, rate=RATE, output=True) -mic = pa.open(format=pyaudio.paInt16, channels=1, rate=RATE, input=True, frames_per_buffer=1024) +speaker = sd.RawOutputStream(samplerate=RATE, channels=1, dtype="int16") +speaker.start() +mic = sd.RawInputStream(samplerate=RATE, channels=1, dtype="int16", blocksize=1024) +mic.start() ready = threading.Event() @@ -25,7 +26,8 @@ def send_mic(ws): """Forward microphone audio to the agent once the session is ready.""" while True: try: - chunk = mic.read(1024, exception_on_overflow=False) + data, _overflowed = mic.read(1024) + chunk = bytes(data) if ready.is_set(): ws.send(json.dumps({"type": "input.audio", "audio": base64.b64encode(chunk).decode()})) except Exception: @@ -58,6 +60,7 @@ with connect(WS_URL, additional_headers={"Authorization": f"Bearer {API_KEY}"}) except KeyboardInterrupt: print("\nStopped.") finally: + speaker.stop() speaker.close() + mic.stop() mic.close() - pa.terminate() diff --git a/pyproject.toml b/pyproject.toml index 5755ccf0..0069d412 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "platformdirs>=4.0", "tomli-w>=1.0", "websockets>=13", - "pyaudio>=0.2.14", + "sounddevice>=0.5", ] [project.optional-dependencies] @@ -38,7 +38,7 @@ testpaths = ["tests"] [tool.mypy] python_version = "3.10" files = ["assemblyai_cli"] -# Third-party deps (assemblyai, pyaudio) ship no type stubs. +# Third-party deps (assemblyai, sounddevice) ship no type stubs. ignore_missing_imports = true disallow_untyped_defs = true warn_unused_ignores = true diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index a2792adb..62e5a002 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -1,4 +1,10 @@ -from assemblyai_cli.agent.audio import Player +import sys +import types + +import pytest + +from assemblyai_cli.agent.audio import Player, _default_output_stream +from assemblyai_cli.errors import CLIError class FakeStream: @@ -10,7 +16,7 @@ def __init__(self): def write(self, data): self.writes.append(data) - def stop_stream(self): + def stop(self): self.stopped = True def close(self): @@ -50,3 +56,44 @@ def write(self, data): p.enqueue(b"\x01\x02") p.close() # must return (join has a timeout); thread must not be alive assert p._thread is not None and not p._thread.is_alive() + + +def test_default_output_stream_opens_started_sounddevice_stream(monkeypatch): + created = {} + + class FakeOut: + def __init__(self, **kwargs): + created.update(kwargs) + self.started = False + + def start(self): + self.started = True + + fake_sd = types.ModuleType("sounddevice") + fake_sd.RawOutputStream = lambda **kw: FakeOut(**kw) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + stream = _default_output_stream(24000) + assert stream.started + assert created["samplerate"] == 24000 + assert created["channels"] == 1 + + +def test_default_output_stream_missing_sounddevice_raises_mic_missing(monkeypatch): + monkeypatch.setitem(sys.modules, "sounddevice", None) # import -> ImportError + with pytest.raises(CLIError) as exc: + _default_output_stream(24000) + assert exc.value.error_type == "mic_missing" + + +def test_default_output_stream_open_failure_raises_audio_output_error(monkeypatch): + def boom(**kw): + raise OSError("no output device") + + fake_sd = types.ModuleType("sounddevice") + fake_sd.RawOutputStream = boom + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + with pytest.raises(CLIError) as exc: + _default_output_stream(24000) + assert exc.value.error_type == "audio_output_error" + assert exc.value.exit_code == 1 diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 9faea4ae..60a94890 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -1,7 +1,31 @@ +import sys +import types + import pytest from assemblyai_cli.errors import CLIError -from assemblyai_cli.microphone import MicrophoneSource +from assemblyai_cli.microphone import MicrophoneSource, _default_mic_stream, _SoundDeviceMic + + +class _FakeRawStream: + """Stand-in for sounddevice.RawInputStream (no hardware).""" + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.started = self.stopped = self.closed = False + self._chunks = [(b"\x01\x02", False), (b"\x03\x04", False)] + + def start(self): + self.started = True + + def read(self, frames): + return self._chunks.pop(0) + + def stop(self): + self.stopped = True + + def close(self): + self.closed = True def test_yields_chunks_from_factory_with_rate_and_device(): @@ -19,14 +43,14 @@ def fake_factory(*, sample_rate, device): def test_missing_dependency_raises_mic_missing(): def boom(*, sample_rate, device): - raise ImportError("No module named 'pyaudio'") + raise ImportError("No module named 'sounddevice'") mic = MicrophoneSource(sample_rate=16000, stream_factory=boom) with pytest.raises(CLIError) as exc: list(mic) assert exc.value.error_type == "mic_missing" assert exc.value.exit_code == 2 - assert "pyaudio" in exc.value.message.lower() + assert "sounddevice" in exc.value.message.lower() def test_device_error_raises_mic_error_exit_1(): @@ -59,3 +83,40 @@ def test_plain_iterator_without_close_is_fine(): # A factory returning a bare iterator (no .close) must not error in teardown. mic = MicrophoneSource(sample_rate=16000, stream_factory=lambda **_k: iter([b"z"])) assert list(mic) == [b"z"] + + +def test_sounddevice_mic_yields_bytes_then_stops_and_closes(): + stream = _FakeRawStream() + mic = _SoundDeviceMic(stream, blocksize=1024) + it = iter(mic) + assert next(it) == b"\x01\x02" + assert next(it) == b"\x03\x04" + mic.close() + assert stream.stopped and stream.closed + + +def test_default_mic_stream_opens_started_sounddevice_stream(monkeypatch): + created = {} + + def raw_input_stream(**kwargs): + created.update(kwargs) + return _FakeRawStream(**kwargs) + + fake_sd = types.ModuleType("sounddevice") + fake_sd.RawInputStream = raw_input_stream + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + stream = _default_mic_stream(sample_rate=16000, device=2) + assert isinstance(stream, _SoundDeviceMic) + assert created["samplerate"] == 16000 + assert created["device"] == 2 + assert created["blocksize"] == 1600 # ~100 ms at 16 kHz + assert next(iter(stream)) == b"\x01\x02" + + +def test_default_mic_stream_missing_sounddevice_raises_mic_missing(monkeypatch): + monkeypatch.setitem(sys.modules, "sounddevice", None) # import -> ImportError + with pytest.raises(CLIError) as exc: + _default_mic_stream(sample_rate=16000, device=None) + assert exc.value.error_type == "mic_missing" + assert exc.value.exit_code == 2 diff --git a/tests/test_samples.py b/tests/test_samples.py index 6f27e210..813970d5 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -33,6 +33,8 @@ def test_samples_create_agent_writes_script_with_key(tmp_path, monkeypatch): assert "sk_injected" in body assert "{{API_KEY}}" not in body assert "session.update" in body # the voice-agent handshake + assert "sounddevice" in body # audio backend (PortAudio bundled in the wheel) + assert "pyaudio" not in body def test_samples_no_subcommand_lists_commands(): From e6c24b2dd84b85c652012081446a66b88285772f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 10:06:56 -0700 Subject: [PATCH 10/87] ci: supply-chain hardening + branch coverage + property tests Supply-chain: - pin GitHub Actions to commit SHAs (a moved tag can't change what runs) - add least-privilege `permissions: contents: read` to the workflow - new `pip-audit` CI job that fails on known dependency CVEs (deps are clean today; documented the --ignore-vuln escape hatch for unfixable transitives) - .github/dependabot.yml to keep pip deps and the pinned Actions current - fix stale "PyAudio" CI label left by the sounddevice migration Tests: - branch coverage gate (pytest --cov-branch, still >=90; currently 96%) - add hypothesis property tests: NDJSON renderers preserve arbitrary text (quotes/newlines/unicode), and WAV chunking is byte-exact and bounded Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/dependabot.yml | 19 +++++++++++++++ .github/workflows/ci.yml | 46 +++++++++++++++++++++++++----------- pyproject.toml | 9 ++++++- scripts/check.sh | 4 ++-- tests/test_properties.py | 51 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 tests/test_properties.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..9d4d8391 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # Python dependencies declared in pyproject.toml. + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + groups: + python-deps: + patterns: ["*"] + + # Keep the SHA-pinned GitHub Actions current. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8f2803f..c05d4d14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,20 +6,24 @@ on: push: branches: [main] +# Least privilege: CI only needs to read the repo. Actions are pinned to commit +# SHAs (a moved tag can't silently change what runs); Dependabot keeps them current. +permissions: + contents: read + jobs: check: name: lint + typecheck + tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip - - name: System audio deps (PyAudio) - run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + - name: System audio deps (PortAudio for sounddevice) + run: sudo apt-get update && sudo apt-get install -y libportaudio2 - name: Install run: python -m pip install -e ".[dev]" @@ -31,29 +35,27 @@ jobs: name: pre-commit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip - - name: System audio deps (PyAudio) - run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + - name: System audio deps (PortAudio for sounddevice) + run: sudo apt-get update && sudo apt-get install -y libportaudio2 # The local pytest hook runs `python -m pytest`, so the package must be importable. - name: Install run: python -m pip install -e ".[dev]" - - uses: pre-commit/action@v3.0.1 + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 build: name: build + twine check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -65,3 +67,19 @@ jobs: - name: Validate metadata run: twine check dist/* + + audit: + name: pip-audit (dependency CVEs) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + + - name: Audit runtime dependencies for known CVEs + run: | + python -m pip install -e . pip-audit + # Append `--ignore-vuln ` to accept an unfixable transitive advisory. + python -m pip_audit diff --git a/pyproject.toml b/pyproject.toml index 0069d412..a8063c2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,14 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.11", "mypy>=1.10", "pre-commit>=4.0"] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "hypothesis>=6.0", + "ruff>=0.11", + "mypy>=1.10", + "pre-commit>=4.0", +] [project.scripts] aai = "assemblyai_cli.main:app" diff --git a/scripts/check.sh b/scripts/check.sh index 31c16adb..a6daa5b8 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -13,7 +13,7 @@ ruff format --check . echo "==> mypy" mypy -echo "==> pytest (with coverage gate)" -pytest -q --cov=assemblyai_cli --cov-report=term-missing --cov-fail-under=90 +echo "==> pytest (with branch-coverage gate)" +pytest -q --cov=assemblyai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 echo "All checks passed." diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 00000000..475b3642 --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,51 @@ +"""Property-based tests for the encoding/parsing-heavy paths.""" + +import io +import json +import types +import wave + +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from assemblyai_cli.agent.render import AgentRenderer +from assemblyai_cli.streaming import sources +from assemblyai_cli.streaming.render import StreamRenderer + + +@given(text=st.text()) +def test_agent_json_preserves_arbitrary_text(text): + # Quotes, newlines, unicode, control chars must survive the NDJSON round-trip. + buf = io.StringIO() + AgentRenderer(json_mode=True, out=buf).user_final(text) + events = [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] + assert {"type": "transcript.user", "text": text} in events + + +@given(text=st.text()) +def test_stream_json_preserves_arbitrary_transcript(text): + buf = io.StringIO() + StreamRenderer(json_mode=True, out=buf).turn( + types.SimpleNamespace(transcript=text, end_of_turn=True) + ) + assert json.loads(buf.getvalue()) == { + "type": "turn", + "transcript": text, + "end_of_turn": True, + } + + +@given(pcm=st.binary(max_size=8000)) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], max_examples=50) +def test_wav_chunks_reassemble_and_stay_bounded(pcm, tmp_path): + pcm = pcm[: len(pcm) // 2 * 2] # whole 16-bit mono frames + assume(pcm) # the empty-file case is covered by a dedicated unit test + clip = tmp_path / "clip.wav" + with wave.open(str(clip), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(sources.TARGET_RATE) + w.writeframes(pcm) + chunks = list(sources.FileSource(str(clip), sleep=lambda _s: None)) + assert b"".join(chunks) == pcm # streamed audio is byte-exact + assert all(len(c) <= sources.CHUNK_BYTES for c in chunks) # chunking respects the cap From 662d3657624d27ef76d4282def639a59de71f6f1 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 10:23:06 -0700 Subject: [PATCH 11/87] test: add real-API e2e tests (kokoro TTS) for stream + agent Drive the real `aai` CLI as a subprocess against the live AssemblyAI API, synthesizing speech locally with kokoro TTS. Marked `e2e`, they skip when the API key / kokoro / numpy is unavailable, so CI and keyless contributors are never blocked. A new precommit `pytest-e2e` hook runs them; the default unit run and coverage gate exclude them via `-m "not e2e"`. To make the agent drivable, `aai agent` now accepts a positional source / --sample (mirroring stream/transcribe): it streams a clip as the user's speech via a NullPlayer (headless), suppresses the greeting, runs full-duplex so nothing is muted, waits for session.ready before streaming, and exits after the agent's first reply. Live-mic behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pre-commit-config.yaml | 9 ++- assemblyai_cli/agent/audio.py | 24 +++++++ assemblyai_cli/agent/session.py | 41 ++++++++++- assemblyai_cli/commands/agent.py | 50 +++++++++---- pyproject.toml | 3 + scripts/check.sh | 4 +- tests/conftest.py | 15 ++++ tests/e2e/test_cli_e2e.py | 119 +++++++++++++++++++++++++++++++ tests/test_agent_command.py | 71 ++++++++++++++++++ tests/test_agent_session.py | 100 +++++++++++++++++++++++++- 10 files changed, 418 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/test_cli_e2e.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8e194ea..ea186c08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,14 @@ repos: hooks: - id: pytest name: pytest - entry: python -m pytest -q + entry: python -m pytest -q -m "not e2e" + language: system + types: [python] + pass_filenames: false + always_run: true + - id: pytest-e2e + name: pytest-e2e (real API; skips without ASSEMBLYAI_API_KEY + kokoro) + entry: python -m pytest -q -m e2e language: system types: [python] pass_filenames: false diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index 10284a26..4b54290f 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -89,5 +89,29 @@ def close(self) -> None: self._stream.close() +class NullPlayer: + """A Player look-alike that discards audio instead of opening a speaker. + + Used by file-driven agent runs (`aai agent `), which only need the + transcript events: there is no human listening, and headless/CI hosts have + no output device for `sounddevice` to open. + """ + + def start(self) -> None: + pass + + def enqueue(self, pcm: bytes) -> None: + pass + + def flush(self) -> None: + pass + + def pending(self) -> int: + return 0 + + def close(self) -> None: + pass + + # Microphone capture (MicrophoneSource) lives in assemblyai_cli.microphone and is # shared with `aai stream`; this module owns only the speaker-side Player. diff --git a/assemblyai_cli/agent/session.py b/assemblyai_cli/agent/session.py index f26d939a..c9783450 100644 --- a/assemblyai_cli/agent/session.py +++ b/assemblyai_cli/agent/session.py @@ -24,12 +24,26 @@ class VoiceAgentSession: """Routes Voice Agent server events to the renderer, player, and duplex state.""" - def __init__(self, *, renderer: Any, player: Any, full_duplex: bool = False) -> None: + def __init__( + self, + *, + renderer: Any, + player: Any, + full_duplex: bool = False, + exit_after_reply: bool = False, + ready_event: threading.Event | None = None, + ) -> None: self.renderer = renderer self.player = player self.full_duplex = full_duplex + # File-driven runs (`aai agent `) stop after the agent's first reply + # to the spoken input, so the process exits instead of waiting forever. + self.exit_after_reply = exit_after_reply + self.ready_event = ready_event self.ready = False self.muted = False + self.finished = False + self._saw_user = False def should_send_audio(self) -> bool: """True when captured mic frames should be forwarded to the server.""" @@ -40,6 +54,8 @@ def dispatch(self, event: dict) -> None: if etype == "session.ready": self.ready = True + if self.ready_event is not None: + self.ready_event.set() self.renderer.connected() elif etype == "input.speech.started": if self.full_duplex: @@ -49,6 +65,7 @@ def dispatch(self, event: dict) -> None: elif etype == "transcript.user.delta": self.renderer.user_partial(event.get("text", "")) elif etype == "transcript.user": + self._saw_user = True self.renderer.user_final(event.get("text", "")) elif etype == "reply.started": if not self.full_duplex: @@ -69,6 +86,9 @@ def dispatch(self, event: dict) -> None: if interrupted: self.player.flush() self.renderer.reply_done(interrupted=interrupted) + # File-driven run: the agent has answered the spoken input, so stop. + if self.exit_after_reply and self._saw_user and not interrupted: + self.finished = True elif etype == "session.error": self._raise_error(event) # tool.call and unknown event types: intentionally ignored. @@ -87,6 +107,10 @@ def _raise_error(self, event: dict) -> None: def _send_audio_loop(ws: Any, session: VoiceAgentSession, mic: Any) -> None: """Forward mic PCM as input.audio while the session gate allows it.""" + # File-driven runs wait for session.ready before consuming the source, so a + # finite clip isn't partly drained (and dropped) before the server accepts it. + if session.ready_event is not None: + session.ready_event.wait(timeout=10) for chunk in mic: if not session.should_send_audio(): continue # half-duplex: drop frames while the agent is speaking @@ -125,17 +149,28 @@ def run_session( system_prompt: str, greeting: str, full_duplex: bool = False, + exit_after_reply: bool = False, connect: Any = None, ) -> None: """Open the Voice Agent WebSocket and run the bidirectional loop until close. `connect` defaults to websockets' synchronous client; injectable for tests. + When `exit_after_reply` is set (file-driven runs), the loop stops after the + agent's first reply to the spoken input and the capture thread waits for + session.ready before streaming the source. """ _connect = connect if _connect is None: from websockets.sync.client import connect as _connect - session = VoiceAgentSession(renderer=renderer, player=player, full_duplex=full_duplex) + ready_event = threading.Event() if exit_after_reply else None + session = VoiceAgentSession( + renderer=renderer, + player=player, + full_duplex=full_duplex, + exit_after_reply=exit_after_reply, + ready_event=ready_event, + ) try: ws = _connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) @@ -176,6 +211,8 @@ def _capture() -> None: ) for raw in ws: session.dispatch(json.loads(raw)) + if session.finished: + break except (CLIError, KeyboardInterrupt, BrokenPipeError): raise # clean CLI errors, user Ctrl-C, and a closed pipe are handled upstream except Exception as exc: diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 2b1cfa19..8b97315d 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -5,14 +5,15 @@ import typer -from assemblyai_cli import config -from assemblyai_cli.agent.audio import SAMPLE_RATE, Player +from assemblyai_cli import client, config +from assemblyai_cli.agent.audio import SAMPLE_RATE, NullPlayer, Player from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session from assemblyai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import CLIError, UsageError from assemblyai_cli.microphone import MicrophoneSource +from assemblyai_cli.streaming.sources import FileSource app = typer.Typer() @@ -20,6 +21,12 @@ @app.command() def agent( ctx: typer.Context, + source: str = typer.Argument( + None, help="Audio file path or URL to speak to the agent. Omit to use the microphone." + ), + sample: bool = typer.Option( + False, "--sample", help="Speak the hosted wildfires.mp3 sample to the agent." + ), voice: str = typer.Option(DEFAULT_VOICE, "--voice", help="Agent voice. See --list-voices."), prompt: str = typer.Option(DEFAULT_PROMPT, "--prompt", help="System prompt."), prompt_file: Path = typer.Option( @@ -33,7 +40,11 @@ def agent( list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), ) -> None: - """Have a live two-way voice conversation with an AssemblyAI voice agent.""" + """Have a live two-way voice conversation with an AssemblyAI voice agent. + + Pass an audio file/URL (or --sample) to speak a recorded clip to the agent + instead of the microphone; the session then ends after the agent's reply. + """ if list_voices: typer.echo(format_voice_list()) @@ -43,6 +54,9 @@ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) if voice not in VOICES: raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") + from_file = bool(source) or sample + if from_file and device is not None: + raise UsageError("--device applies only to microphone input.") system_prompt = prompt if prompt_file is not None: try: @@ -55,23 +69,33 @@ def body(state: AppState, json_mode: bool) -> None: ) from exc renderer = AgentRenderer(json_mode=json_mode) - player = Player(sample_rate=SAMPLE_RATE) - mic = MicrophoneSource(sample_rate=SAMPLE_RATE, device=device) - if not json_mode and not full_duplex: - renderer.notice( - "Half-duplex: mic mutes while the agent talks. " - "Use --full-duplex (with headphones) for barge-in.\n" - ) + audio: FileSource | MicrophoneSource + player: NullPlayer | Player + if from_file: + # Stream the clip as the user's speech and stop after the agent replies. + # No greeting and full-duplex so no part of the clip is muted/dropped, + # and a NullPlayer since there is no listener for the reply audio. + audio = FileSource(client.resolve_audio_source(source, sample=sample)) + player = NullPlayer() + else: + audio = MicrophoneSource(sample_rate=SAMPLE_RATE, device=device) + player = Player(sample_rate=SAMPLE_RATE) + if not json_mode and not full_duplex: + renderer.notice( + "Half-duplex: mic mutes while the agent talks. " + "Use --full-duplex (with headphones) for barge-in.\n" + ) try: run_session( api_key, renderer=renderer, player=player, - mic=mic, + mic=audio, voice=voice, system_prompt=system_prompt, - greeting=greeting, - full_duplex=full_duplex, + greeting="" if from_file else greeting, + full_duplex=True if from_file else full_duplex, + exit_after_reply=from_file, ) except KeyboardInterrupt: renderer.stopped() diff --git a/pyproject.toml b/pyproject.toml index a8063c2c..8f7c046e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ packages = ["assemblyai_cli"] [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY + kokoro; skip otherwise)", +] [tool.mypy] python_version = "3.10" diff --git a/scripts/check.sh b/scripts/check.sh index a6daa5b8..6fb7d1c3 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -14,6 +14,8 @@ echo "==> mypy" mypy echo "==> pytest (with branch-coverage gate)" -pytest -q --cov=assemblyai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +# Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need +# a live API key + kokoro. Run them with: pytest -m e2e +pytest -q -m "not e2e" --cov=assemblyai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 echo "All checks passed." diff --git a/tests/conftest.py b/tests/conftest.py index 57bf0330..8a4235ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,22 @@ +import os + import keyring import pytest from keyring.backend import KeyringBackend +# Captured at import, before `isolate_env` strips ASSEMBLYAI_API_KEY from the +# environment. The e2e suite uses this real key to drive the CLI as a subprocess; +# unit tests still run fully isolated. +REAL_API_KEY = os.environ.get("ASSEMBLYAI_API_KEY") + + +@pytest.fixture +def real_api_key(): + """The real API key from the environment, or skip if none is set.""" + if not REAL_API_KEY: + pytest.skip("ASSEMBLYAI_API_KEY not set; skipping real-API e2e test.") + return REAL_API_KEY + class MemoryKeyring(KeyringBackend): priority = 1 diff --git a/tests/e2e/test_cli_e2e.py b/tests/e2e/test_cli_e2e.py new file mode 100644 index 00000000..ef1a9cd0 --- /dev/null +++ b/tests/e2e/test_cli_e2e.py @@ -0,0 +1,119 @@ +"""End-to-end tests that drive the real `aai` CLI against the live AssemblyAI API. + +Speech is synthesized locally with kokoro TTS, then fed through the CLI as a +subprocess so the binary, argument parsing, auth, audio decoding, and network +path are all exercised for real — no mocks. + +These tests are marked `e2e` and skip (never fail) when the API key, kokoro, or +numpy is unavailable, so CI and keyless contributors are not blocked. The +precommit `pytest-e2e` hook runs them; the default unit run excludes them. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import wave +from pathlib import Path +from typing import Any + +import pytest + +pytestmark = pytest.mark.e2e + +KOKORO_RATE = 24000 # kokoro emits 24 kHz float32 mono +STREAM_RATE = 16000 # what the CLI's fast WAV path expects (16 kHz mono PCM16) + + +@pytest.fixture(scope="session") +def kokoro_pipeline() -> Any: + """Build the kokoro TTS pipeline once per session, or skip if unavailable.""" + pytest.importorskip("numpy") + kokoro = pytest.importorskip("kokoro") + return kokoro.KPipeline(lang_code="a") # American English + + +def _synthesize_wav(pipeline: Any, text: str, path: Path, *, lead_silence_s: float = 0.6) -> Path: + """Synthesize `text` to a 16 kHz mono PCM16 WAV the CLI can stream directly. + + Resamples kokoro's 24 kHz output to 16 kHz (linear) and prepends a short + silence so nothing is clipped before the realtime session is ready. + """ + import numpy as np + + chunks = [] + for _gs, _ps, audio in pipeline(text, voice="af_heart"): + arr = audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio) + chunks.append(np.asarray(arr, dtype=np.float32).reshape(-1)) + samples = np.concatenate(chunks) + + n_dst = round(len(samples) * STREAM_RATE / KOKORO_RATE) + resampled = np.interp( + np.linspace(0.0, len(samples) - 1, n_dst), + np.arange(len(samples)), + samples, + ) + pcm = (np.clip(resampled, -1.0, 1.0) * 32767.0).astype(" subprocess.CompletedProcess[str]: + """Run `python -m assemblyai_cli ` against the working tree with the real key.""" + env = dict(os.environ) + env["ASSEMBLYAI_API_KEY"] = key + return subprocess.run( + [sys.executable, "-m", "assemblyai_cli", *args], + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + + +def _ndjson(stdout: str) -> list[dict[str, Any]]: + return [json.loads(line) for line in stdout.splitlines() if line.strip()] + + +def test_stream_file_transcribes_spoken_text(real_api_key, kokoro_pipeline, tmp_path): + spoken = "the quick brown fox jumps over the lazy dog" + wav = _synthesize_wav(kokoro_pipeline, spoken, tmp_path / "fox.wav") + + proc = _run_cli(["stream", str(wav), "--json"], real_api_key) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + + events = _ndjson(proc.stdout) + transcript = " ".join( + e.get("transcript", "") for e in events if e.get("type") == "turn" + ).lower() + assert transcript.strip(), f"no transcript produced; events={events}" + for word in ("fox", "lazy", "dog"): + assert word in transcript, f"{word!r} missing from streamed transcript: {transcript!r}" + + +def test_agent_file_gets_reply(real_api_key, kokoro_pipeline, tmp_path): + spoken = "Hi there. Can you say hello back to me in one short sentence?" + wav = _synthesize_wav(kokoro_pipeline, spoken, tmp_path / "hello.wav") + + proc = _run_cli(["agent", str(wav), "--json"], real_api_key) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + + events = _ndjson(proc.stdout) + user_finals = [ + e["text"] for e in events if e.get("type") == "transcript.user" and e.get("text") + ] + agent_replies = [ + e["text"] for e in events if e.get("type") == "transcript.agent" and e.get("text") + ] + + assert user_finals, f"agent never transcribed the spoken input; events={events}" + assert agent_replies, f"agent never replied; events={events}" diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 57dde084..6dc1d9db 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -45,6 +45,7 @@ def fake_run_session( system_prompt, greeting, full_duplex=False, + exit_after_reply=False, ): renderer.connected() renderer.user_final("hello agent") @@ -72,6 +73,7 @@ def fake_run_session( system_prompt, greeting, full_duplex=False, + exit_after_reply=False, ): seen["voice"] = voice seen["prompt"] = system_prompt @@ -131,3 +133,72 @@ def test_agent_prompt_file_not_found_exits_2(monkeypatch): monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) result = runner.invoke(app, ["agent", "--prompt-file", "/tmp/no_such_file_xyz_voiceagent.txt"]) assert result.exit_code == 2 + + +def _capture_run_session(monkeypatch): + """Patch run_session to record its kwargs and return the dict it fills in.""" + seen = {} + + def fake_run_session(api_key, **kwargs): + seen.update(kwargs) + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + return seen + + +def test_agent_file_source_streams_clip_and_exits_after_reply(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + wav = tmp_path / "say.wav" + wav.write_bytes(b"RIFF") # FileSource is faked below; contents don't matter + + monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: f"filesrc:{src}") + seen = _capture_run_session(monkeypatch) + + result = runner.invoke(app, ["agent", str(wav)]) + assert result.exit_code == 0 + # File input drives a deterministic, headless, self-terminating session. + assert seen["mic"] == f"filesrc:{wav}" + assert seen["exit_after_reply"] is True + assert seen["full_duplex"] is True + assert seen["greeting"] == "" + from assemblyai_cli.agent.audio import NullPlayer + + assert isinstance(seen["player"], NullPlayer) + + +def test_agent_sample_uses_hosted_clip(monkeypatch): + config.set_api_key("default", "sk_live") + captured = {} + + def fake_file_source(src): + captured["src"] = src + return "filesrc" + + monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", fake_file_source) + seen = _capture_run_session(monkeypatch) + + result = runner.invoke(app, ["agent", "--sample"]) + assert result.exit_code == 0 + assert captured["src"].endswith("wildfires.mp3") + assert seen["exit_after_reply"] is True + + +def test_agent_file_source_with_device_exits_2(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + wav = tmp_path / "say.wav" + wav.write_bytes(b"RIFF") + result = runner.invoke(app, ["agent", str(wav), "--device", "1"]) + assert result.exit_code == 2 # --device is microphone-only + + +def test_agent_file_source_no_half_duplex_notice(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + wav = tmp_path / "say.wav" + wav.write_bytes(b"RIFF") + result = runner.invoke(app, ["agent", str(wav)]) + assert result.exit_code == 0 + assert "Half-duplex" not in result.output # half-duplex note is mic-only diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index 40625c28..0157ced0 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -50,11 +50,13 @@ def close(self): self.closed = True -def _session(*, full_duplex=False): +def _session(*, full_duplex=False, exit_after_reply=False, ready_event=None): return VoiceAgentSession( renderer=FakeRenderer(), player=FakePlayer(), full_duplex=full_duplex, + exit_after_reply=exit_after_reply, + ready_event=ready_event, ) @@ -295,3 +297,99 @@ def test_full_duplex_reply_started_announces_without_muting(): s.dispatch({"type": "reply.started"}) assert s.muted is False assert ("reply_started",) in s.renderer.calls + + +def test_ready_sets_ready_event(): + import threading + + ev = threading.Event() + s = _session(exit_after_reply=True, ready_event=ev) + assert ev.is_set() is False + s.dispatch({"type": "session.ready"}) + assert ev.is_set() is True # capture thread is now free to stream the file + + +def test_exit_after_reply_finishes_after_user_then_reply_done(): + s = _session(full_duplex=True, exit_after_reply=True) + s.dispatch({"type": "session.ready"}) + s.dispatch({"type": "transcript.user", "text": "hello there"}) + assert s.finished is False # not until the agent has actually replied + s.dispatch({"type": "reply.done"}) + assert s.finished is True + + +def test_exit_after_reply_ignores_greeting_reply_before_user_speech(): + s = _session(full_duplex=True, exit_after_reply=True) + s.dispatch({"type": "session.ready"}) + s.dispatch({"type": "reply.done"}) # e.g. a greeting, before any user speech + assert s.finished is False + + +def test_exit_after_reply_ignores_interrupted_reply(): + s = _session(full_duplex=True, exit_after_reply=True) + s.dispatch({"type": "transcript.user", "text": "hi"}) + s.dispatch({"type": "reply.done", "status": "interrupted"}) + assert s.finished is False + + +def test_exit_after_reply_off_never_finishes(): + s = _session(full_duplex=True, exit_after_reply=False) + s.dispatch({"type": "transcript.user", "text": "hi"}) + s.dispatch({"type": "reply.done"}) + assert s.finished is False # live mic sessions run until Ctrl-C + + +def test_send_audio_loop_waits_for_ready_event_before_streaming(): + import threading + + ev = threading.Event() + ev.set() # already ready -> loop proceeds immediately + s = _session(exit_after_reply=True, ready_event=ev) + s.ready = True + ws = _RecordingWS() + _send_audio_loop(ws, s, [b"\x01\x02"]) + assert len(ws.sent) == 1 # frame forwarded once the gate is open + + +def test_run_session_file_driven_stops_after_reply(): + """A file-driven session ends on its own after the agent replies (no hang).""" + + class _ScriptedWS: + def __init__(self): + self.sent = [] + + def send(self, msg): + self.sent.append(msg) + + def __iter__(self): + return iter( + json.dumps(e) + for e in ( + {"type": "session.ready"}, + {"type": "transcript.user", "text": "what time is it"}, + {"type": "transcript.agent", "text": "it is noon", "interrupted": False}, + {"type": "reply.done"}, + # A trailing event the loop must never reach (it should have stopped). + {"type": "transcript.user", "text": "SHOULD NOT BE SEEN"}, + ) + ) + + def close(self): + pass + + renderer = FakeRenderer() + run_session( + "sk_live", + renderer=renderer, + player=FakePlayer(), + mic=[], # capture thread waits for ready, then this empty source ends at once + voice="ivy", + system_prompt="x", + greeting="", + full_duplex=True, + exit_after_reply=True, + connect=lambda url, **kwargs: _ScriptedWS(), + ) + finals = [c for c in renderer.calls if c[0] == "user_final"] + assert ("user_final", "what time is it") in finals + assert ("user_final", "SHOULD NOT BE SEEN") not in finals # stopped after the reply From d68221d32818aec99e5606b9e95daa3886f27f4d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 11:44:53 -0700 Subject: [PATCH 12/87] feat(llm): add LLM Gateway transforms to transcribe/stream + `aai llm` Add a --prompt switch to transcribe and stream that transforms the transcript through AssemblyAI's LLM Gateway, plus a standalone `aai llm` command. The gateway is OpenAI-compatible and has no assemblyai-SDK client for the synchronous endpoint, so we talk to it via the openai SDK pointed at https://llm-gateway.assemblyai.com/v1 (Bearer auth, transcript_id injection). - transcribe --prompt: transforms the finished transcript server-side via the transcript id ({{ transcript }} injection). Human prints the transform only; --json keeps raw text + transform{model,prompt,output}. Conflicts with --srt/--vtt. - stream --prompt: accumulates finalized turns, then runs one transform on the full transcript when the stream ends (native per-turn streaming gateway is not provisioned for general accounts, so we don't rely on it). - aai llm: prompt the gateway directly, with --transcript-id injection, --model/--system/--max-tokens, and --list-models. Adds openai>=1.40. Unit suite covers the gateway client, both switches, and the new command (coverage gate green); e2e tests exercise aai llm, transcribe --prompt, and stream --prompt against the live API. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/llm.py | 61 ++++++++++++ assemblyai_cli/commands/stream.py | 41 +++++++- assemblyai_cli/commands/transcribe.py | 38 +++++++- assemblyai_cli/llm.py | 133 +++++++++++++++++++++++++ assemblyai_cli/main.py | 12 ++- assemblyai_cli/streaming/render.py | 10 ++ pyproject.toml | 1 + tests/e2e/test_cli_e2e.py | 51 ++++++++++ tests/test_llm.py | 135 ++++++++++++++++++++++++++ tests/test_llm_command.py | 104 ++++++++++++++++++++ tests/test_stream_command.py | 74 +++++++++++++- tests/test_streaming_render.py | 24 +++++ tests/test_transcribe.py | 53 ++++++++++ 13 files changed, 726 insertions(+), 11 deletions(-) create mode 100644 assemblyai_cli/commands/llm.py create mode 100644 assemblyai_cli/llm.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_llm_command.py diff --git a/assemblyai_cli/commands/llm.py b/assemblyai_cli/commands/llm.py new file mode 100644 index 00000000..e791e8a6 --- /dev/null +++ b/assemblyai_cli/commands/llm.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import typer +from rich.markup import escape + +from assemblyai_cli import config, output +from assemblyai_cli import llm as gateway +from assemblyai_cli.context import AppState, run_command +from assemblyai_cli.errors import UsageError + +app = typer.Typer() + + +@app.command() +def llm( + ctx: typer.Context, + prompt: str = typer.Argument(None, help="The instruction / prompt to send."), + model: str = typer.Option(gateway.DEFAULT_MODEL, "--model", help="LLM Gateway model."), + transcript_id: str = typer.Option( + None, "--transcript-id", help="Inject this transcript's text into the prompt." + ), + system: str = typer.Option(None, "--system", help="Optional system prompt."), + max_tokens: int = typer.Option( + gateway.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens to generate." + ), + list_models: bool = typer.Option(False, "--list-models", help="Print known models and exit."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Send a prompt to AssemblyAI's LLM Gateway and print the response. + + With --transcript-id the transcript's text is injected server-side, so you + can ask questions about a past transcription (e.g. aai llm "summarize" --transcript-id ID). + """ + + if list_models: + typer.echo("\n".join(gateway.KNOWN_MODELS)) + raise typer.Exit(code=0) + + def body(state: AppState, json_mode: bool) -> None: + if not prompt: + raise UsageError("Provide a prompt, or use --list-models.") + api_key = config.resolve_api_key(profile=state.profile) + messages = gateway.build_messages(prompt, system=system, transcript_id=transcript_id) + response = gateway.complete( + api_key, + model=model, + messages=messages, + max_tokens=max_tokens, + transcript_id=transcript_id, + ) + output.emit( + { + "model": model, + "output": gateway.content_of(response), + "usage": gateway.usage_of(response), + }, + lambda d: escape(str(d["output"])), + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 419113cd..8403d9b8 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -2,7 +2,7 @@ import typer -from assemblyai_cli import client, config +from assemblyai_cli import client, config, llm from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError from assemblyai_cli.microphone import MicrophoneSource @@ -23,9 +23,22 @@ def stream( TARGET_RATE, "--sample-rate", help="Microphone sample rate in Hz." ), device: int | None = typer.Option(None, "--device", help="Microphone device index."), + prompt: str = typer.Option( + None, + "--prompt", + help="After streaming, transform the full transcript through LLM Gateway.", + ), + model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --prompt."), + max_tokens: int = typer.Option( + llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the --prompt transform." + ), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), ) -> None: - """Transcribe live audio from the microphone, a file, or a URL in real time.""" + """Transcribe live audio from the microphone, a file, or a URL in real time. + + Pass --prompt to transform the full transcript through LLM Gateway once the + stream ends (e.g. --prompt "summarize the call"). + """ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) @@ -40,17 +53,27 @@ def body(state: AppState, json_mode: bool) -> None: audio = MicrophoneSource(sample_rate=sample_rate, device=device) rate = sample_rate renderer = StreamRenderer(json_mode=json_mode) + # Collect finalized turns so we can transform the full transcript at the end. + turns: list[str] = [] + + def on_turn(event: object) -> None: + renderer.turn(event) + if prompt and getattr(event, "end_of_turn", False): + text = getattr(event, "transcript", "") or "" + if text: + turns.append(text) + try: client.stream_audio( api_key, audio, sample_rate=rate, on_begin=renderer.begin, - on_turn=renderer.turn, + on_turn=on_turn, on_termination=renderer.termination, ) except KeyboardInterrupt: - # Ctrl-C is a normal "user stopped" signal -> exit 0. + # Ctrl-C is a normal "user stopped" signal -> exit 0 (still transform below). renderer.close() renderer.stopped() except BrokenPipeError: @@ -59,4 +82,14 @@ def body(state: AppState, json_mode: bool) -> None: finally: renderer.close() + if prompt and turns: + transformed = llm.transform_transcript( + api_key, + prompt=prompt, + model=model, + transcript_text=" ".join(turns), + max_tokens=max_tokens, + ) + renderer.llm(transformed) + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index bd16ea0e..7f2bf03d 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -3,7 +3,7 @@ import typer from rich.markup import escape -from assemblyai_cli import client, config, output +from assemblyai_cli import client, config, llm, output from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError @@ -18,13 +18,26 @@ def transcribe( speaker_labels: bool = typer.Option(False, "--speaker-labels", help="Enable diarization."), srt: bool = typer.Option(False, "--srt", help="Output SRT subtitles."), vtt: bool = typer.Option(False, "--vtt", help="Output VTT subtitles."), + prompt: str = typer.Option( + None, "--prompt", help="Transform the transcript through LLM Gateway with this instruction." + ), + model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --prompt."), + max_tokens: int = typer.Option( + llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the --prompt transform." + ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Transcribe an audio file or URL and print the result.""" + """Transcribe an audio file or URL and print the result. + + Pass --prompt to transform the finished transcript through LLM Gateway + (e.g. --prompt "summarize in three bullets"). + """ def body(state: AppState, json_mode: bool) -> None: if srt and vtt: raise UsageError("--srt and --vtt are mutually exclusive.") + if prompt and (srt or vtt): + raise UsageError("--prompt cannot be combined with --srt/--vtt.") audio = client.resolve_audio_source(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) transcript = client.transcribe(api_key, audio, speaker_labels=speaker_labels) @@ -37,6 +50,27 @@ def body(state: AppState, json_mode: bool) -> None: output.console.print(transcript.export_subtitles_vtt(), markup=False) return + if prompt: + transformed = llm.transform_transcript( + api_key, + prompt=prompt, + model=model, + transcript_id=transcript.id, + max_tokens=max_tokens, + ) + # Human mode shows just the transform; JSON keeps the raw transcript too. + output.emit( + { + "id": transcript.id, + "status": client.status_str(transcript), + "text": transcript.text, + "transform": {"model": model, "prompt": prompt, "output": transformed}, + }, + lambda d: escape(str(d["transform"]["output"])), + json_mode=json_mode, + ) + return + output.emit( { "id": transcript.id, diff --git a/assemblyai_cli/llm.py b/assemblyai_cli/llm.py new file mode 100644 index 00000000..bf290723 --- /dev/null +++ b/assemblyai_cli/llm.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from typing import Any + +import openai +from openai import OpenAI + +from assemblyai_cli.errors import APIError, auth_failure + +# The LLM Gateway is OpenAI-compatible, so we talk to it through the OpenAI SDK +# pointed at this base URL. (The synchronous gateway has no assemblyai-SDK client.) +GATEWAY_BASE_URL = "https://llm-gateway.assemblyai.com/v1" +DEFAULT_MODEL = "claude-sonnet-4-6" +DEFAULT_MAX_TOKENS = 1000 + +# Exact tag the gateway substitutes with a transcript's text when `transcript_id` +# is supplied. Must be exactly "{{ transcript }}" (spaces included). +TRANSCRIPT_TAG = "{{ transcript }}" + +# A curated subset for `aai llm --list-models` and help text. The gateway is the +# source of truth for what's actually accepted, so we don't validate against this. +KNOWN_MODELS = ( + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "gpt-5.1", + "gpt-5", + "gpt-4.1", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", +) + + +def build_messages( + prompt: str, + *, + system: str | None = None, + transcript_id: str | None = None, + transcript_text: str | None = None, +) -> list[dict[str, str]]: + """Assemble the chat `messages` array for a transcript transform or plain prompt. + + With a `transcript_id`, the gateway injects the transcript server-side, so we + append the `{{ transcript }}` tag. Otherwise any `transcript_text` is inlined. + """ + if transcript_id is not None: + content = f"{prompt}\n\n{TRANSCRIPT_TAG}" + elif transcript_text is not None: + content = f"{prompt}\n\nTranscript:\n{transcript_text}" + else: + content = prompt + messages: list[dict[str, str]] = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": content}) + return messages + + +def _client(api_key: str) -> OpenAI: + return OpenAI(api_key=api_key, base_url=GATEWAY_BASE_URL) + + +def complete( + api_key: str, + *, + model: str, + messages: list[dict[str, str]], + max_tokens: int = DEFAULT_MAX_TOKENS, + transcript_id: str | None = None, +) -> Any: + """Create a chat completion via the gateway and return the OpenAI response. + + `transcript_id` is passed through as an extra body field so the gateway can + inject the transcript text server-side. Auth failures map to NotAuthenticated + and everything else to APIError, matching the rest of the CLI. + """ + client = _client(api_key) + extra_body = {"transcript_id": transcript_id} if transcript_id is not None else None + try: + return client.chat.completions.create( + model=model, + messages=messages, # type: ignore[arg-type] + max_tokens=max_tokens, + extra_body=extra_body, + ) + except (openai.AuthenticationError, openai.PermissionDeniedError) as exc: + raise auth_failure() from exc + except openai.OpenAIError as exc: + raise APIError(f"LLM Gateway request failed: {exc}") from exc + + +def content_of(response: Any) -> str: + """Pull the assistant's text out of a chat-completions response.""" + try: + content = response.choices[0].message.content + except (AttributeError, IndexError, TypeError) as exc: + raise APIError("LLM Gateway response contained no message content.") from exc + return content or "" + + +def usage_of(response: Any) -> dict[str, Any] | None: + """Return the token-usage block as a plain dict, if present.""" + usage = getattr(response, "usage", None) + if usage is None: + return None + if hasattr(usage, "model_dump"): + dumped: dict[str, Any] = usage.model_dump() + return dumped + if isinstance(usage, dict): + return usage + return None + + +def transform_transcript( + api_key: str, + *, + prompt: str, + model: str = DEFAULT_MODEL, + transcript_id: str | None = None, + transcript_text: str | None = None, + max_tokens: int = DEFAULT_MAX_TOKENS, +) -> str: + """Run `prompt` over a transcript (by id or inline text) and return the result.""" + messages = build_messages(prompt, transcript_id=transcript_id, transcript_text=transcript_text) + response = complete( + api_key, + model=model, + messages=messages, + max_tokens=max_tokens, + transcript_id=transcript_id, + ) + return content_of(response) diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py index 0c019829..c1606c42 100644 --- a/assemblyai_cli/main.py +++ b/assemblyai_cli/main.py @@ -3,7 +3,16 @@ import typer from assemblyai_cli import __version__ -from assemblyai_cli.commands import agent, claude, login, samples, stream, transcribe, transcripts +from assemblyai_cli.commands import ( + agent, + claude, + llm, + login, + samples, + stream, + transcribe, + transcripts, +) from assemblyai_cli.context import AppState app = typer.Typer( @@ -29,6 +38,7 @@ def version() -> None: app.add_typer(agent.app) +app.add_typer(llm.app) app.add_typer(login.app) app.add_typer(stream.app) app.add_typer(transcribe.app) diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index 6679ca80..2d037e50 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -30,3 +30,13 @@ def termination(self, event: object) -> None: "audio_duration_seconds": getattr(event, "audio_duration_seconds", None), } ) + + def llm(self, content: str) -> None: + """Render the LLM Gateway transform of the full transcript (shown last).""" + if not content: + return + if self.json_mode: + self._emit({"type": "llm", "content": content}) + else: + self._finalize_line() # close any open partial-turn line first + self._write("\N{ELECTRIC LIGHT BULB} " + content + "\n") diff --git a/pyproject.toml b/pyproject.toml index 8f7c046e..ec43d027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "tomli-w>=1.0", "websockets>=13", "sounddevice>=0.5", + "openai>=1.40", ] [project.optional-dependencies] diff --git a/tests/e2e/test_cli_e2e.py b/tests/e2e/test_cli_e2e.py index ef1a9cd0..98697b42 100644 --- a/tests/e2e/test_cli_e2e.py +++ b/tests/e2e/test_cli_e2e.py @@ -117,3 +117,54 @@ def test_agent_file_gets_reply(real_api_key, kokoro_pipeline, tmp_path): assert user_finals, f"agent never transcribed the spoken input; events={events}" assert agent_replies, f"agent never replied; events={events}" + + +# --- LLM Gateway ----------------------------------------------------------- + + +def test_llm_command_answers(real_api_key): + proc = _run_cli( + ["llm", "What is 2 + 2? Reply with just the number.", "--json"], real_api_key, timeout=60 + ) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + data = json.loads(proc.stdout) + assert "4" in data["output"], f"unexpected LLM output: {data!r}" + + +def test_transcribe_prompt_transforms_via_gateway(real_api_key): + proc = _run_cli( + [ + "transcribe", + "--sample", + "--prompt", + "Summarize this transcript in one short sentence.", + "--json", + ], + real_api_key, + timeout=180, + ) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + data = json.loads(proc.stdout) + assert data["text"].strip(), f"no transcript produced: {data!r}" + assert data["transform"]["output"].strip(), f"gateway returned no transform: {data!r}" + + +def test_stream_prompt_transforms_at_end(real_api_key, kokoro_pipeline, tmp_path): + spoken = "the quick brown fox jumps over the lazy dog" + wav = _synthesize_wav(kokoro_pipeline, spoken, tmp_path / "fox.wav") + + proc = _run_cli( + [ + "stream", + str(wav), + "--prompt", + "Summarize the transcript in one short sentence.", + "--json", + ], + real_api_key, + ) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + events = _ndjson(proc.stdout) + # The full transcript is transformed once after streaming, emitted as a final llm event. + llm_events = [e for e in events if e.get("type") == "llm" and e.get("content")] + assert llm_events, f"no transcript transform came back; events={events}" diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 00000000..c2ebe259 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,135 @@ +import types + +import httpx +import openai +import pytest + +from assemblyai_cli import llm +from assemblyai_cli.errors import APIError, NotAuthenticated + +_REQUEST = httpx.Request("POST", f"{llm.GATEWAY_BASE_URL}/chat/completions") + + +def _response(content: "str | None" = "hi there", usage=None): + message = types.SimpleNamespace(role="assistant", content=content) + choice = types.SimpleNamespace(message=message, finish_reason="stop") + return types.SimpleNamespace(choices=[choice], usage=usage) + + +class FakeCompletions: + def __init__(self, result=None, error=None, seen=None): + self._result = result + self._error = error + self._seen = seen if seen is not None else {} + + def create(self, **kwargs): + self._seen.update(kwargs) + if self._error is not None: + raise self._error + return self._result + + +def _fake_client(monkeypatch, *, result=None, error=None): + seen = {} + client = types.SimpleNamespace( + chat=types.SimpleNamespace(completions=FakeCompletions(result, error, seen)) + ) + monkeypatch.setattr(llm, "_client", lambda api_key: client) + return seen + + +def test_complete_sends_model_and_messages(monkeypatch): + seen = _fake_client(monkeypatch, result=_response("hi there")) + resp = llm.complete( + "sk_live", model="claude-sonnet-4-6", messages=[{"role": "user", "content": "x"}] + ) + assert llm.content_of(resp) == "hi there" + assert seen["model"] == "claude-sonnet-4-6" + assert seen["messages"] == [{"role": "user", "content": "x"}] + assert seen["extra_body"] is None # no transcript_id -> no extra body + + +def test_complete_passes_transcript_id_as_extra_body(monkeypatch): + seen = _fake_client(monkeypatch, result=_response()) + llm.complete("sk", model="m", messages=[], transcript_id="t_42") + assert seen["extra_body"] == {"transcript_id": "t_42"} + + +def test_complete_auth_error_maps_to_not_authenticated(monkeypatch): + err = openai.AuthenticationError( + "bad key", response=httpx.Response(401, request=_REQUEST), body=None + ) + _fake_client(monkeypatch, error=err) + with pytest.raises(NotAuthenticated): + llm.complete("sk", model="m", messages=[]) + + +def test_complete_permission_error_maps_to_not_authenticated(monkeypatch): + err = openai.PermissionDeniedError( + "forbidden", response=httpx.Response(403, request=_REQUEST), body=None + ) + _fake_client(monkeypatch, error=err) + with pytest.raises(NotAuthenticated): + llm.complete("sk", model="m", messages=[]) + + +def test_complete_bad_request_maps_to_api_error(monkeypatch): + err = openai.BadRequestError( + "missing model", response=httpx.Response(400, request=_REQUEST), body=None + ) + _fake_client(monkeypatch, error=err) + with pytest.raises(APIError): + llm.complete("sk", model="m", messages=[]) + + +def test_complete_connection_error_maps_to_api_error(monkeypatch): + _fake_client(monkeypatch, error=openai.APIConnectionError(request=_REQUEST)) + with pytest.raises(APIError): + llm.complete("sk", model="m", messages=[]) + + +def test_content_of_missing_raises(): + with pytest.raises(APIError): + llm.content_of(types.SimpleNamespace(choices=[])) + + +def test_content_of_none_returns_empty(): + assert llm.content_of(_response(content=None)) == "" + + +def test_usage_of_variants(): + assert llm.usage_of(_response(usage=None)) is None + assert llm.usage_of(_response(usage={"total_tokens": 5})) == {"total_tokens": 5} + model = types.SimpleNamespace(model_dump=lambda: {"total_tokens": 9}) + assert llm.usage_of(_response(usage=model)) == {"total_tokens": 9} + + +def test_build_messages_transcript_id_uses_tag(): + msgs = llm.build_messages("summarize", transcript_id="t_1") + assert msgs == [{"role": "user", "content": f"summarize\n\n{llm.TRANSCRIPT_TAG}"}] + + +def test_build_messages_inline_text(): + msgs = llm.build_messages("summarize", transcript_text="hello world") + assert msgs[0]["content"] == "summarize\n\nTranscript:\nhello world" + + +def test_build_messages_with_system_prompt(): + msgs = llm.build_messages("hi", system="be terse") + assert msgs[0] == {"role": "system", "content": "be terse"} + assert msgs[1] == {"role": "user", "content": "hi"} + + +def test_transform_transcript_roundtrips(monkeypatch): + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + seen["transcript_id"] = transcript_id + seen["messages"] = messages + return _response("SUMMARY") + + monkeypatch.setattr(llm, "complete", fake_complete) + out = llm.transform_transcript("sk", prompt="summarize", transcript_id="t_9") + assert out == "SUMMARY" + assert seen["transcript_id"] == "t_9" + assert llm.TRANSCRIPT_TAG in seen["messages"][0]["content"] diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py new file mode 100644 index 00000000..d72dc66c --- /dev/null +++ b/tests/test_llm_command.py @@ -0,0 +1,104 @@ +import json +import types + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_api_key("default", "sk_live") + + +def _payload(content="four"): + # Mimics the OpenAI SDK response object the command reads via content_of/usage_of. + message = types.SimpleNamespace(role="assistant", content=content) + choice = types.SimpleNamespace(message=message, finish_reason="stop") + return types.SimpleNamespace(choices=[choice], usage={"total_tokens": 3}) + + +def test_llm_help_lists_command(): + result = runner.invoke(app, ["llm", "--help"]) + assert result.exit_code == 0 + assert "gateway" in result.output.lower() + + +def test_llm_list_models_exits_without_network(monkeypatch): + called = {"ran": False} + monkeypatch.setattr( + "assemblyai_cli.commands.llm.gateway.complete", + lambda *a, **k: called.__setitem__("ran", True), + ) + result = runner.invoke(app, ["llm", "--list-models"]) + assert result.exit_code == 0 + assert "claude-sonnet-4-6" in result.output + assert called["ran"] is False + + +def test_llm_sends_prompt_and_prints_output(monkeypatch): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + seen["model"] = model + seen["messages"] = messages + seen["transcript_id"] = transcript_id + return _payload("4") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke(app, ["llm", "What is 2+2?", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["output"] == "4" + assert data["model"] == "claude-sonnet-4-6" + assert seen["transcript_id"] is None + assert seen["messages"][0]["content"] == "What is 2+2?" + + +def test_llm_transcript_id_injected(monkeypatch): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + seen["transcript_id"] = transcript_id + seen["content"] = messages[0]["content"] + return _payload("summary") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke(app, ["llm", "summarize", "--transcript-id", "t_7", "--json"]) + assert result.exit_code == 0 + assert seen["transcript_id"] == "t_7" + assert "{{ transcript }}" in seen["content"] + + +def test_llm_missing_prompt_exits_2(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + result = runner.invoke(app, ["llm"]) + assert result.exit_code == 2 + + +def test_llm_unauthenticated_exits_2(): + result = runner.invoke(app, ["llm", "hello"]) + assert result.exit_code == 2 + + +def test_llm_passes_model_and_max_tokens(monkeypatch): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + seen["model"] = model + seen["max_tokens"] = max_tokens + return _payload() + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke( + app, ["llm", "hi", "--model", "gemini-2.5-flash", "--max-tokens", "42", "--json"] + ) + assert result.exit_code == 0 + assert seen["model"] == "gemini-2.5-flash" + assert seen["max_tokens"] == 42 diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 7e8dc712..63fd0ed7 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -9,7 +9,9 @@ runner = CliRunner() -def _drive_turns(api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None): +def _drive_turns( + api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs +): # Simulate the streaming client driving the renderer callbacks. if on_begin: on_begin(types.SimpleNamespace(id="sess")) @@ -37,7 +39,7 @@ def test_stream_file_uses_filesource(monkeypatch, tmp_path): seen = {} def fake_stream_audio( - api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None + api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs ): seen["source_type"] = type(source).__name__ seen["rate"] = sample_rate @@ -63,7 +65,9 @@ def test_stream_unauthenticated_exits_2(): def _capture_source(seen): - def fake(api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None): + def fake( + api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs + ): seen["source"] = source seen["rate"] = sample_rate @@ -160,7 +164,9 @@ def test_stream_file_json_output(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") - def fake(api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None): + def fake( + api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs + ): if on_turn: on_turn(types.SimpleNamespace(transcript="from file", end_of_turn=True)) @@ -175,3 +181,63 @@ def fake(api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termin assert result.exit_code == 0 lines = [_json.loads(x) for x in result.output.splitlines() if x.strip()] assert {"type": "turn", "transcript": "from file", "end_of_turn": True} in lines + + +def test_stream_prompt_transforms_accumulated_transcript(monkeypatch): + config.set_api_key("default", "sk_live") + seen = {} + + def fake(api_key, source, *, sample_rate, on_turn=None, **kwargs): + if on_turn: + on_turn(types.SimpleNamespace(transcript="hola", end_of_turn=True)) + on_turn(types.SimpleNamespace(transcript="mundo", end_of_turn=True)) + on_turn(types.SimpleNamespace(transcript="partial", end_of_turn=False)) # ignored + + def fake_transform(api_key, *, prompt, model, transcript_text, max_tokens): + seen["prompt"] = prompt + seen["model"] = model + seen["transcript_text"] = transcript_text + seen["max_tokens"] = max_tokens + return "hello world" + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("assemblyai_cli.commands.stream.llm.transform_transcript", fake_transform) + result = runner.invoke( + app, + [ + "stream", + "--prompt", + "translate to english", + "--model", + "gpt-4.1", + "--max-tokens", + "50", + "--json", + ], + ) + assert result.exit_code == 0 + # The full transcript (finalized turns only) is sent for one transform. + assert seen["transcript_text"] == "hola mundo" + assert seen["model"] == "gpt-4.1" + assert seen["max_tokens"] == 50 + lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] + assert {"type": "llm", "content": "hello world"} in lines + + +def test_stream_without_prompt_does_not_transform(monkeypatch): + config.set_api_key("default", "sk_live") + called = {"ran": False} + + def fake(api_key, source, *, sample_rate, on_turn=None, **kwargs): + if on_turn: + on_turn(types.SimpleNamespace(transcript="hi", end_of_turn=True)) + + def fake_transform(*a, **k): + called["ran"] = True + return "x" + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("assemblyai_cli.commands.stream.llm.transform_transcript", fake_transform) + result = runner.invoke(app, ["stream", "--json"]) + assert result.exit_code == 0 + assert called["ran"] is False # no --prompt -> no gateway call diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index a4433b3f..520acdb7 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -96,6 +96,30 @@ def flush(self): r.close() # non-pipe errors are non-fatal +def test_llm_json_emits_event(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.llm("the summary") + assert json.loads(out.getvalue()) == {"type": "llm", "content": "the summary"} + + +def test_llm_human_prints_content(): + out = io.StringIO() + r = StreamRenderer(json_mode=False, out=out) + r.turn(_turn("partial", False)) # open a partial line first + r.llm("a tidy summary") + text = out.getvalue() + assert "a tidy summary" in text + assert text.endswith("\n") + + +def test_llm_ignores_empty_content(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.llm("") # nothing to show + assert out.getvalue() == "" + + def test_close_propagates_broken_pipe(): import pytest diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 1fb6569c..4d61763d 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -1,3 +1,4 @@ +import json from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -108,3 +109,55 @@ def test_transcribe_srt_vtt_conflict_json_error(): assert result.exit_code == 2 # In --json mode the error is a JSON envelope, not Typer usage text. assert '"error"' in result.output + + +def test_transcribe_prompt_transforms_json(monkeypatch): + _auth() + seen = {} + + def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens): + seen["prompt"] = prompt + seen["model"] = model + seen["transcript_id"] = transcript_id + return "a short summary" + + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + monkeypatch.setattr( + "assemblyai_cli.commands.transcribe.llm.transform_transcript", fake_transform + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "summarize", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["text"] == "hello world" # raw transcript still present in JSON + assert data["transform"]["output"] == "a short summary" + assert data["transform"]["prompt"] == "summarize" + # The transform is injected server-side via the transcript id. + assert seen["transcript_id"] == "t_1" + assert seen["model"] == "claude-sonnet-4-6" + + +def test_transcribe_prompt_human_shows_only_transform(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + monkeypatch.setattr( + "assemblyai_cli.commands.transcribe.llm.transform_transcript", + lambda *a, **k: "TRANSFORMED", + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "summarize"]) + assert result.exit_code == 0 + assert "TRANSFORMED" in result.output + assert "hello world" not in result.output # human mode shows the transform only + + +def test_transcribe_prompt_with_srt_exits_2(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "summarize", "--srt"]) + assert result.exit_code == 2 From ceb1ff0c1bd5e10509c3c592c746fa3128141670 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:03:43 -0700 Subject: [PATCH 13/87] feat(theme): add centralized Rich color theme module Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/theme.py | 64 +++++++++++++++++++++++++++++++++++++++++ tests/test_theme.py | 45 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 assemblyai_cli/theme.py create mode 100644 tests/test_theme.py diff --git a/assemblyai_cli/theme.py b/assemblyai_cli/theme.py new file mode 100644 index 00000000..db4446ea --- /dev/null +++ b/assemblyai_cli/theme.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import IO, Any + +from rich.console import Console +from rich.theme import Theme + +# AssemblyAI brand accent. Defined once so the whole CLI can be re-tinted here. +BRAND = "#2545D3" + +# Per-speaker label colors, rotated deterministically by speaker_style(). +SPEAKER_STYLES: tuple[str, ...] = ( + "aai.speaker.0", + "aai.speaker.1", + "aai.speaker.2", + "aai.speaker.3", + "aai.speaker.4", +) + +THEME = Theme( + { + "aai.brand": f"bold {BRAND}", + "aai.heading": f"bold {BRAND}", + "aai.label": BRAND, + "aai.success": "green", + "aai.error": "bold red", + "aai.warn": "yellow", + "aai.muted": "dim", + "aai.speaker.0": BRAND, + "aai.speaker.1": "cyan", + "aai.speaker.2": "magenta", + "aai.speaker.3": "green", + "aai.speaker.4": "yellow", + } +) + +# Status strings grouped by the semantic style they render in. +_SUCCESS = {"completed", "installed", "removed", "ok", "present", "authenticated"} +_ERROR = {"error", "failed"} +_WARN = {"queued", "processing", "in_progress", "running"} + + +def make_console(file: IO[str] | None = None, **kwargs: Any) -> Console: + """Build a Console with the AssemblyAI theme attached so `aai.*` names resolve.""" + return Console(file=file, theme=THEME, **kwargs) + + +def speaker_style(speaker: object) -> str: + """Deterministically map a speaker id to one of SPEAKER_STYLES.""" + key = str(speaker) + idx = sum(ord(c) for c in key) % len(SPEAKER_STYLES) + return SPEAKER_STYLES[idx] + + +def status_style(status: str) -> str: + """Map a transcript/setup status to a semantic style name (muted if unknown).""" + normalized = status.strip().lower() + if normalized in _SUCCESS: + return "aai.success" + if normalized in _ERROR: + return "aai.error" + if normalized in _WARN: + return "aai.warn" + return "aai.muted" diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 00000000..518e9e8c --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,45 @@ +import io + +from assemblyai_cli import theme + + +def test_make_console_resolves_named_styles(): + console = theme.make_console() + # get_style raises rich.errors.MissingStyle if a name is not in the theme. + for name in ( + "aai.brand", + "aai.heading", + "aai.label", + "aai.success", + "aai.error", + "aai.warn", + "aai.muted", + ): + console.get_style(name) + for name in theme.SPEAKER_STYLES: + console.get_style(name) + + +def test_make_console_passes_kwargs_through(): + buf = io.StringIO() + console = theme.make_console(file=buf, force_terminal=True, width=42) + assert console.file is buf + assert console.width == 42 + + +def test_status_style_maps_known_statuses(): + assert theme.status_style("completed") == "aai.success" + assert theme.status_style("ERROR") == "aai.error" + assert theme.status_style("failed") == "aai.error" + assert theme.status_style("queued") == "aai.warn" + assert theme.status_style("processing") == "aai.warn" + + +def test_status_style_unknown_falls_back_to_muted(): + assert theme.status_style("something-else") == "aai.muted" + + +def test_speaker_style_deterministic_and_in_palette(): + assert theme.speaker_style("A") in theme.SPEAKER_STYLES + assert theme.speaker_style("A") == theme.speaker_style("A") + assert theme.speaker_style("A") != theme.speaker_style("B") From 159abf1b135ff53a5e8a57650efb5243e049d911 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:06:54 -0700 Subject: [PATCH 14/87] feat(theme): theme the shared output console and error markup Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/output.py | 7 ++++--- tests/test_theme.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/assemblyai_cli/output.py b/assemblyai_cli/output.py index db7e0365..92de94f4 100644 --- a/assemblyai_cli/output.py +++ b/assemblyai_cli/output.py @@ -6,15 +6,16 @@ from collections.abc import Callable from typing import TYPE_CHECKING, TypeVar -from rich.console import Console from rich.markup import escape +from assemblyai_cli import theme + if TYPE_CHECKING: from assemblyai_cli.errors import CLIError T = TypeVar("T") -console = Console() +console = theme.make_console() _AGENT_ENV_VARS = ("CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT") @@ -45,4 +46,4 @@ def emit_error(err: CLIError, *, json_mode: bool) -> None: if json_mode: print(json.dumps(err.to_dict(), default=str)) else: - console.print(f"[red]Error:[/red] {escape(err.message)}") + console.print(f"[aai.error]Error:[/aai.error] {escape(err.message)}") diff --git a/tests/test_theme.py b/tests/test_theme.py index 518e9e8c..454672db 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -43,3 +43,20 @@ def test_speaker_style_deterministic_and_in_palette(): assert theme.speaker_style("A") in theme.SPEAKER_STYLES assert theme.speaker_style("A") == theme.speaker_style("A") assert theme.speaker_style("A") != theme.speaker_style("B") + + +def test_output_console_is_themed_and_error_is_styled(monkeypatch): + from assemblyai_cli import output, theme + from assemblyai_cli.errors import CLIError + + buf = io.StringIO() + monkeypatch.setattr( + output, + "console", + theme.make_console(file=buf, force_terminal=True, color_system="truecolor"), + ) + output.emit_error(CLIError("boom"), json_mode=False) + out = buf.getvalue() + assert "Error:" in out + assert "boom" in out + assert "\x1b[" in out # themed error emits ANSI on a forced-color console From 792c4df0d0b48583552b4895653729b16eb94f43 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:09:43 -0700 Subject: [PATCH 15/87] feat: prompts split, transcripts grouping, YouTube, Rich renderer, duplex agent audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI surface & consistency - transcribe/stream: split prompts — `--prompt` biases the speech model (u3-pro), `--llm-gateway-prompt` runs the LLM Gateway transform. Drop transcribe `--srt`/`--vtt`. - agent: rename `--prompt`/`--prompt-file` -> `--system-prompt`/`--system-prompt-file` (aligns with the speech-prompt meaning); drop `--full-duplex` (always full-duplex now). - Group `transcripts` under `aai transcripts get/list` (was top-level `get`/`list`). - transcribe `--speaker-labels` now actually renders diarized utterances (Speaker A: …) in human mode and adds `utterances` to --json (was silently dropped). YouTube - `aai transcribe/stream ` downloads best audio via yt-dlp to a temp file, then transcribes/streams it (new `youtube.py`). Adds yt-dlp dependency. LLM Gateway - Talk to the gateway through the OpenAI SDK (OpenAI-compatible) with transcript_id injection; switch the dependency from httpx to openai. Rendering - Replace the hand-rolled ANSI renderer with rich.live.Live (fixes long-line wrap cascade); JSON/NDJSON path unchanged. Audio robustness (macOS) - Capture at the device's native rate and resample (mic + speaker), instead of forcing an unsupported rate. - agent: drive mic + speaker through ONE full-duplex sd.RawStream (DuplexAudio). macOS AUHAL refuses two concurrent streams ("cannot do in current context", err -50), which silently killed capture — recording now works. Live agent prompts to use headphones. - stream: tidy ffmpeg generator teardown so a stray Ctrl-C in cleanup doesn't surface as "Exception ignored in generator". Tooling - check.sh + mypy now cover tests/ (tests is a package; pytest fns exempt from annotation rule); add markdownlint (docs/ excluded) and a .markdownlint.json. - Repo squash-only is configured on GitHub. Co-Authored-By: Claude Opus 4.8 (1M context) --- .markdownlint.json | 4 + README.md | 21 ++- assemblyai_cli/agent/audio.py | 198 ++++++++++++++++++++++++- assemblyai_cli/agent/render.py | 9 +- assemblyai_cli/client.py | 13 +- assemblyai_cli/commands/agent.py | 47 +++--- assemblyai_cli/commands/llm.py | 2 +- assemblyai_cli/commands/stream.py | 111 ++++++++------ assemblyai_cli/commands/transcribe.py | 101 ++++++++----- assemblyai_cli/commands/transcripts.py | 2 +- assemblyai_cli/main.py | 2 +- assemblyai_cli/microphone.py | 59 +++++++- assemblyai_cli/render.py | 76 +++++++--- assemblyai_cli/streaming/render.py | 5 +- assemblyai_cli/streaming/sources.py | 9 +- assemblyai_cli/youtube.py | 65 ++++++++ pyproject.toml | 8 +- scripts/check.sh | 11 +- tests/__init__.py | 0 tests/conftest.py | 4 +- tests/e2e/__init__.py | 0 tests/e2e/test_cli_e2e.py | 4 +- tests/test_agent_audio.py | 92 +++++++++++- tests/test_agent_command.py | 21 +-- tests/test_agent_render.py | 124 ++++++++-------- tests/test_client.py | 2 +- tests/test_microphone.py | 79 ++++++++-- tests/test_stream_command.py | 44 +++++- tests/test_streaming_render.py | 149 +++++++++---------- tests/test_streaming_sources.py | 36 +++++ tests/test_transcribe.py | 96 +++++++----- tests/test_transcripts.py | 10 +- tests/test_youtube.py | 105 +++++++++++++ 33 files changed, 1136 insertions(+), 373 deletions(-) create mode 100644 .markdownlint.json create mode 100644 assemblyai_cli/youtube.py create mode 100644 tests/__init__.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/test_youtube.py diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 00000000..b077f0e1 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "default": true, + "MD013": false +} diff --git a/README.md b/README.md index 3d83a44f..c101d060 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,11 @@ aai transcribe --sample # transcribe the hosted wildfires.mp3 sample | Command | What it does | | --- | --- | | `aai login` / `logout` / `whoami` | Manage the stored API key. | -| `aai transcribe ` | Transcribe an audio file or URL (`--sample` for a demo, `--srt`/`--vtt` for subtitles). | +| `aai transcribe ` | Transcribe an audio file, URL, or YouTube URL (`--sample` for a demo, `--llm-gateway-prompt` to transform the result). | | `aai transcripts list` / `get ` | Browse and fetch past transcripts. | | `aai stream [file]` | Real-time transcription from a file or the microphone. | | `aai agent` | Live two-way voice conversation with a voice agent. | +| `aai llm ` | Prompt AssemblyAI's LLM Gateway (optionally over a transcript with `--transcript-id`). | | `aai claude install` | Wire Claude Code up to AssemblyAI's docs + skill. | | `aai samples create ` | Scaffold a runnable starter script with your key injected. | @@ -44,6 +45,14 @@ Add `--json` to any command for machine-readable output (it's also the default w output is piped or run by an agent). Auth problems surface as a clean "not authenticated" error across every command. +> **Tip:** Quote URLs that contain `?` (most YouTube links do). In zsh the `?` is a +> glob character, so an unquoted URL fails with `zsh: no matches found` before the +> command runs: +> +> ```sh +> aai transcribe "https://www.youtube.com/watch?v=VIDEO_ID" +> ``` + ## Streaming ```sh @@ -61,13 +70,13 @@ Have a live, two-way voice conversation: ```sh aai agent # talk; the agent talks back. Ctrl-C to stop. aai agent --voice james --greeting "Hi" -aai agent --prompt-file persona.txt # load the system prompt from a file -aai agent --list-voices # see available voices +aai agent --system-prompt-file persona.txt # load the system prompt from a file +aai agent --list-voices # see available voices ``` -By default the agent runs **half-duplex**: your mic mutes while the agent speaks, so it -can't hear itself on your speakers. With headphones, add `--full-duplex` for true -barge-in (interrupt the agent mid-sentence). +The agent is full-duplex — your mic stays open while it speaks, so you can interrupt it +mid-sentence (barge-in). **Use headphones**, otherwise the agent hears itself on your +speakers. ## AI coding agents diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index 4b54290f..35de8f80 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -3,15 +3,33 @@ import contextlib import queue import threading -from collections.abc import Callable +from collections.abc import Callable, Iterator from typing import Any from assemblyai_cli.errors import CLIError -from assemblyai_cli.microphone import audio_missing_error +from assemblyai_cli.microphone import _FALLBACK_RATE, _resample, audio_missing_error SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate +def _output_default_rate(device: int | None = None) -> int: + """The output device's native sample rate. + + Like the mic, the speaker is opened at its own rate to avoid CoreAudio + 'paramErr' (-50) from forcing an unsupported one; agent audio (24 kHz) is + resampled to it. Falls back to a safe default when the device can't be queried. + """ + try: + import sounddevice as sd + except ImportError as exc: + raise audio_missing_error() from exc + try: + rate = int(sd.query_devices(device, "output")["default_samplerate"]) + except Exception: # noqa: BLE001 - any query failure -> safe fallback, never crash here + return _FALLBACK_RATE + return rate if rate > 0 else _FALLBACK_RATE + + def _default_output_stream(rate: int) -> Any: """Open a sounddevice PCM16 mono output stream (imported lazily to keep startup fast).""" try: @@ -38,24 +56,34 @@ def __init__( *, sample_rate: int = SAMPLE_RATE, stream_factory: Callable[[int], object] | None = None, + output_rate: int | None = None, + rate_query: Callable[[int | None], int] | None = None, ) -> None: - self._rate = sample_rate + self._source_rate = sample_rate # rate of enqueued audio (agent = 24 kHz) self._factory = stream_factory or _default_output_stream + query = rate_query or _output_default_rate + # Open the speaker at its native rate; resample agent audio to it. + self._device_rate = output_rate if output_rate is not None else query(None) self._queue: queue.Queue[bytes | None] = queue.Queue() # sounddevice stream (or a test double); typed Any since sounddevice ships no stubs. self._stream: Any = None self._thread: threading.Thread | None = None def start(self) -> None: - self._stream = self._factory(self._rate) + self._stream = self._factory(self._device_rate) self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def _run(self) -> None: + state: Any = None while True: chunk = self._queue.get() if chunk is None: return + if self._device_rate != self._source_rate: + chunk, state = _resample( + chunk, state, src_rate=self._source_rate, dst_rate=self._device_rate + ) try: self._stream.write(chunk) except Exception: # noqa: BLE001 - stream may be torn down mid-write @@ -113,5 +141,165 @@ def close(self) -> None: pass +def _default_duplex_stream( + *, rate: int, blocksize: int, callback: Any, device: int | None +) -> Any: + """Open ONE started full-duplex sounddevice stream (mic + speaker together).""" + try: + import sounddevice as sd + except ImportError as exc: + raise audio_missing_error() from exc + try: + stream = sd.RawStream( + samplerate=rate, + device=device, + channels=1, + dtype="int16", + blocksize=blocksize, + callback=callback, + ) + stream.start() + except Exception as exc: + raise CLIError( + f"Could not open the audio device: {exc}", + error_type="audio_output_error", + exit_code=1, + ) from exc + return stream + + +class DuplexAudio: + """Capture and playback over a single full-duplex stream. + + macOS AUHAL refuses two separate input+output streams on one device + ("cannot do in current context"), which silently kills capture. Driving both + directions through one `sd.RawStream` callback avoids that. Audio is captured + at the device's native rate and resampled to `target_rate` (the agent's 24 kHz) + for the mic side; playback is resampled back to the device rate. Exposes a + `Player`-compatible `player` and an iterable `mic` so `run_session` is unchanged. + """ + + def __init__( + self, + *, + target_rate: int = SAMPLE_RATE, + device: int | None = None, + device_rate: int | None = None, + stream_factory: Callable[..., Any] | None = None, + rate_query: Callable[[int | None], int] | None = None, + ) -> None: + query = rate_query or _output_default_rate + self._device_rate = device_rate if device_rate is not None else query(device) + self._target = target_rate + self._device = device + self._factory = stream_factory or _default_duplex_stream + self._blocksize = max(1, self._device_rate // 10) # ~100 ms + self._in: queue.Queue[bytes | None] = queue.Queue() + self._out = bytearray() # device-rate playback bytes + self._out_state: Any = None # ratecv state for target -> device + self._lock = threading.Lock() + self._stream: Any = None + self._started = False + self.player = _DuplexPlayer(self) + self.mic = _DuplexMic(self) + + def _callback(self, indata: Any, outdata: Any, _frames: int, _time: Any, _status: Any) -> None: + # Capture: hand the device-rate input bytes to the mic consumer. + with contextlib.suppress(Exception): + self._in.put_nowait(bytes(indata)) + # Playback: drain the buffer into the output, zero-filling any shortfall. + need = len(outdata) + with self._lock: + take = bytes(self._out[:need]) + del self._out[:need] + if len(take) == need: + outdata[:] = take + else: + outdata[: len(take)] = take + outdata[len(take) :] = b"\x00" * (need - len(take)) + + def start(self) -> None: + if self._started: + return + self._stream = self._factory( + rate=self._device_rate, + blocksize=self._blocksize, + callback=self._callback, + device=self._device, + ) + self._started = True + + def feed(self, pcm: bytes) -> None: + """Queue target-rate PCM for playback, resampled to the device rate.""" + if self._device_rate != self._target: + pcm, self._out_state = _resample( + pcm, self._out_state, src_rate=self._target, dst_rate=self._device_rate + ) + with self._lock: + self._out += pcm + + def flush(self) -> None: + with self._lock: + self._out.clear() + + def pending(self) -> int: + with self._lock: + return len(self._out) // 2 + + def capture_frames(self) -> Iterator[bytes]: + """Yield target-rate PCM captured from the device until closed.""" + state: Any = None + while True: + chunk = self._in.get() + if chunk is None: + return + if self._device_rate != self._target: + chunk, state = _resample( + chunk, state, src_rate=self._device_rate, dst_rate=self._target + ) + yield chunk + + def close(self) -> None: + self._in.put(None) # end capture_frames() + if self._stream is not None: + with contextlib.suppress(Exception): + self._stream.stop() + with contextlib.suppress(Exception): + self._stream.close() + self._started = False + + +class _DuplexPlayer: + """Player-compatible facade over a DuplexAudio's playback side.""" + + def __init__(self, duplex: DuplexAudio) -> None: + self._duplex = duplex + + def start(self) -> None: + self._duplex.start() + + def enqueue(self, pcm: bytes) -> None: + self._duplex.feed(pcm) + + def flush(self) -> None: + self._duplex.flush() + + def pending(self) -> int: + return self._duplex.pending() + + def close(self) -> None: + self._duplex.close() + + +class _DuplexMic: + """Iterable of captured target-rate PCM from a DuplexAudio.""" + + def __init__(self, duplex: DuplexAudio) -> None: + self._duplex = duplex + + def __iter__(self) -> Iterator[bytes]: + return self._duplex.capture_frames() + + # Microphone capture (MicrophoneSource) lives in assemblyai_cli.microphone and is -# shared with `aai stream`; this module owns only the speaker-side Player. +# shared with `aai stream`; the agent's live mic+speaker run through DuplexAudio. diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index 66b5c71f..ad3664f3 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -14,11 +14,11 @@ def connected(self) -> None: if self.json_mode: self._emit({"type": "session.ready"}) else: - self._write("Connected — start talking. (Ctrl-C to stop)\n") + self._line("Connected — start talking. (Ctrl-C to stop)") def notice(self, text: str) -> None: - """Write a human-facing notice line (caller chooses when to suppress in JSON).""" - self._write(text) + """Print a human-facing notice (caller chooses when to suppress in JSON).""" + self._line(text.rstrip("\n")) # --- user -------------------------------------------------------------- def user_partial(self, text: str) -> None: @@ -42,8 +42,7 @@ def agent_transcript(self, text: str, *, interrupted: bool) -> None: if self.json_mode: self._emit({"type": "transcript.agent", "text": text, "interrupted": interrupted}) return - self._finalize_line() # close any open "you: …" partial first - self._write("agent: " + text + "\n") + self._line("agent: " + text) # commits any open "you: …" partial first def reply_done(self, *, interrupted: bool) -> None: if self.json_mode: diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 60f46c64..31672f33 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -60,9 +60,11 @@ def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object] return [item.model_dump(mode="json") for item in resp.transcripts] -def transcribe(api_key: str, audio: str, *, speaker_labels: bool) -> aai.Transcript: +def transcribe( + api_key: str, audio: str, *, speaker_labels: bool, prompt: str | None = None +) -> aai.Transcript: _configure(api_key) - config = aai.TranscriptionConfig(speaker_labels=speaker_labels) + config = aai.TranscriptionConfig(speaker_labels=speaker_labels, prompt=prompt) try: transcript = aai.Transcriber().transcribe(audio, config=config) except APIError: @@ -100,11 +102,13 @@ def stream_audio( on_begin: Callable[[Any], Any] | None = None, on_turn: Callable[[Any], Any] | None = None, on_termination: Callable[[Any], Any] | None = None, + prompt: str | None = None, speech_model: SpeechModel = SpeechModel.universal_streaming_multilingual, ) -> None: """Stream `source` (an iterable of PCM bytes) through the v3 realtime API. Forwards Begin/Turn/Termination events to the callbacks; raises APIError on a stream error. + `prompt` biases the speech model (the realtime `prompt` parameter). """ sc = StreamingClient( StreamingClientOptions(api_key=api_key, api_host="streaming.assemblyai.com") @@ -121,7 +125,10 @@ def stream_audio( try: sc.connect( StreamingParameters( - sample_rate=sample_rate, format_turns=True, speech_model=speech_model + sample_rate=sample_rate, + format_turns=True, + speech_model=speech_model, + prompt=prompt, ) ) except CLIError: diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 8b97315d..e9c62335 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -2,17 +2,17 @@ import contextlib from pathlib import Path +from typing import Any import typer from assemblyai_cli import client, config -from assemblyai_cli.agent.audio import SAMPLE_RATE, NullPlayer, Player +from assemblyai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session from assemblyai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import CLIError, UsageError -from assemblyai_cli.microphone import MicrophoneSource from assemblyai_cli.streaming.sources import FileSource app = typer.Typer() @@ -28,14 +28,15 @@ def agent( False, "--sample", help="Speak the hosted wildfires.mp3 sample to the agent." ), voice: str = typer.Option(DEFAULT_VOICE, "--voice", help="Agent voice. See --list-voices."), - prompt: str = typer.Option(DEFAULT_PROMPT, "--prompt", help="System prompt."), - prompt_file: Path = typer.Option( - None, "--prompt-file", help="Read the system prompt from a file (overrides --prompt)." + system_prompt: str = typer.Option( + DEFAULT_PROMPT, "--system-prompt", help="System prompt (the agent's persona)." ), - greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting."), - full_duplex: bool = typer.Option( - False, "--full-duplex", help="Keep the mic open while the agent speaks (needs headphones)." + system_prompt_file: Path = typer.Option( + None, + "--system-prompt-file", + help="Read the system prompt from a file (overrides --system-prompt).", ), + greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting."), device: int | None = typer.Option(None, "--device", help="Microphone device index."), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), @@ -57,20 +58,21 @@ def body(state: AppState, json_mode: bool) -> None: from_file = bool(source) or sample if from_file and device is not None: raise UsageError("--device applies only to microphone input.") - system_prompt = prompt - if prompt_file is not None: + if system_prompt_file is not None: try: - system_prompt = prompt_file.read_text(encoding="utf-8") + system_prompt_text = system_prompt_file.read_text(encoding="utf-8") except OSError as exc: raise CLIError( - f"Could not read --prompt-file {prompt_file}: {exc}", + f"Could not read --system-prompt-file {system_prompt_file}: {exc}", error_type="file_not_found", exit_code=2, ) from exc + else: + system_prompt_text = system_prompt renderer = AgentRenderer(json_mode=json_mode) - audio: FileSource | MicrophoneSource - player: NullPlayer | Player + audio: Any + player: Any if from_file: # Stream the clip as the user's speech and stop after the agent replies. # No greeting and full-duplex so no part of the clip is muted/dropped, @@ -78,12 +80,15 @@ def body(state: AppState, json_mode: bool) -> None: audio = FileSource(client.resolve_audio_source(source, sample=sample)) player = NullPlayer() else: - audio = MicrophoneSource(sample_rate=SAMPLE_RATE, device=device) - player = Player(sample_rate=SAMPLE_RATE) - if not json_mode and not full_duplex: + # One full-duplex stream for mic + speaker: macOS rejects two separate + # streams on a device, which silently kills capture. + duplex = DuplexAudio(target_rate=SAMPLE_RATE, device=device) + audio = duplex.mic + player = duplex.player + if not json_mode: renderer.notice( - "Half-duplex: mic mutes while the agent talks. " - "Use --full-duplex (with headphones) for barge-in.\n" + "Use headphones — the mic stays open while the agent speaks, " + "so speakers would let it hear itself.\n" ) try: run_session( @@ -92,9 +97,9 @@ def body(state: AppState, json_mode: bool) -> None: player=player, mic=audio, voice=voice, - system_prompt=system_prompt, + system_prompt=system_prompt_text, greeting="" if from_file else greeting, - full_duplex=True if from_file else full_duplex, + full_duplex=True, # one duplex stream -> mic always open (use headphones) exit_after_reply=from_file, ) except KeyboardInterrupt: diff --git a/assemblyai_cli/commands/llm.py b/assemblyai_cli/commands/llm.py index e791e8a6..c3758104 100644 --- a/assemblyai_cli/commands/llm.py +++ b/assemblyai_cli/commands/llm.py @@ -14,7 +14,7 @@ @app.command() def llm( ctx: typer.Context, - prompt: str = typer.Argument(None, help="The instruction / prompt to send."), + prompt: str = typer.Argument(None, help="The prompt to send to the model."), model: str = typer.Option(gateway.DEFAULT_MODEL, "--model", help="LLM Gateway model."), transcript_id: str = typer.Option( None, "--transcript-id", help="Inject this transcript's text into the prompt." diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 8403d9b8..a193bcb6 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -1,8 +1,11 @@ from __future__ import annotations +import tempfile +from pathlib import Path + import typer -from assemblyai_cli import client, config, llm +from assemblyai_cli import client, config, llm, youtube from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError from assemblyai_cli.microphone import MicrophoneSource @@ -16,80 +19,96 @@ def stream( ctx: typer.Context, source: str = typer.Argument( - None, help="Audio file path or URL to stream. Omit to use the microphone." + None, + help="Audio file path, URL, or YouTube URL to stream. Omit to use the microphone.", ), sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."), - sample_rate: int = typer.Option( - TARGET_RATE, "--sample-rate", help="Microphone sample rate in Hz." + sample_rate: int | None = typer.Option( + None, "--sample-rate", help="Force a microphone capture rate in Hz (default: device native)." ), device: int | None = typer.Option(None, "--device", help="Microphone device index."), prompt: str = typer.Option( + None, "--prompt", help="Bias the speech model with this prompt (u3-pro)." + ), + llm_gateway_prompt: str = typer.Option( None, - "--prompt", + "--llm-gateway-prompt", help="After streaming, transform the full transcript through LLM Gateway.", ), - model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --prompt."), + model: str = typer.Option( + llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --llm-gateway-prompt." + ), max_tokens: int = typer.Option( - llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the --prompt transform." + llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the LLM Gateway transform." ), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), ) -> None: - """Transcribe live audio from the microphone, a file, or a URL in real time. + """Transcribe live audio from the microphone, a file, a URL, or YouTube in real time. - Pass --prompt to transform the full transcript through LLM Gateway once the - stream ends (e.g. --prompt "summarize the call"). + --prompt biases the speech model. --llm-gateway-prompt transforms the full + transcript through LLM Gateway once the stream ends (e.g. "summarize the call"). """ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) from_file = bool(source) or sample - if from_file and (sample_rate != TARGET_RATE or device is not None): + if from_file and (sample_rate is not None or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") - audio: FileSource | MicrophoneSource - if from_file: - audio = FileSource(client.resolve_audio_source(source, sample=sample)) - rate = audio.sample_rate - else: - audio = MicrophoneSource(sample_rate=sample_rate, device=device) - rate = sample_rate + renderer = StreamRenderer(json_mode=json_mode) # Collect finalized turns so we can transform the full transcript at the end. turns: list[str] = [] def on_turn(event: object) -> None: renderer.turn(event) - if prompt and getattr(event, "end_of_turn", False): + if llm_gateway_prompt and getattr(event, "end_of_turn", False): text = getattr(event, "transcript", "") or "" if text: turns.append(text) - try: - client.stream_audio( - api_key, - audio, - sample_rate=rate, - on_begin=renderer.begin, - on_turn=on_turn, - on_termination=renderer.termination, - ) - except KeyboardInterrupt: - # Ctrl-C is a normal "user stopped" signal -> exit 0 (still transform below). - renderer.close() - renderer.stopped() - except BrokenPipeError: - # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. - raise typer.Exit(code=0) from None - finally: - renderer.close() + def run(audio: FileSource | MicrophoneSource, rate: int) -> None: + try: + client.stream_audio( + api_key, + audio, + sample_rate=rate, + on_begin=renderer.begin, + on_turn=on_turn, + on_termination=renderer.termination, + prompt=prompt, + ) + except KeyboardInterrupt: + # Ctrl-C is a normal "user stopped" signal -> exit 0 (still transform below). + renderer.close() + renderer.stopped() + except BrokenPipeError: + # Downstream consumer (e.g. `| head`) closed the pipe; stop quietly. + raise typer.Exit(code=0) from None + finally: + renderer.close() - if prompt and turns: - transformed = llm.transform_transcript( - api_key, - prompt=prompt, - model=model, - transcript_text=" ".join(turns), - max_tokens=max_tokens, - ) - renderer.llm(transformed) + if llm_gateway_prompt and turns: + transformed = llm.transform_transcript( + api_key, + prompt=llm_gateway_prompt, + model=model, + transcript_text=" ".join(turns), + max_tokens=max_tokens, + ) + renderer.llm(transformed) + + if source and youtube.is_youtube_url(source): + # Fetch the audio first, then stream the local file in real time. + with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: + local = youtube.download_audio(source, Path(td)) + run(FileSource(str(local)), TARGET_RATE) + elif from_file: + file_audio = FileSource(client.resolve_audio_source(source, sample=sample)) + run(file_audio, file_audio.sample_rate) + else: + # Capture at the device's native rate (or --sample-rate override) and tell + # the streaming API that rate, rather than forcing one the device may reject. + mic = MicrophoneSource(device=device, capture_rate=sample_rate) + run(mic, mic.sample_rate) run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 7f2bf03d..3d704a99 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -1,59 +1,81 @@ from __future__ import annotations +import tempfile +from pathlib import Path + import typer from rich.markup import escape -from assemblyai_cli import client, config, llm, output +from assemblyai_cli import client, config, llm, output, youtube from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import UsageError app = typer.Typer() +def _utterances(transcript: object) -> list[dict[str, object]]: + """Speaker-labeled utterances ({speaker, text, start, end}), empty if none.""" + items = getattr(transcript, "utterances", None) or [] + return [ + {"speaker": u.speaker, "text": u.text, "start": u.start, "end": u.end} for u in items + ] + + +def _render_transcript(data: dict[str, object]) -> str: + """Human view: speaker-labeled lines when diarized, otherwise the plain text.""" + utterances = data.get("utterances") + if utterances: + lines = [f"Speaker {u['speaker']}: {u['text']}" for u in utterances] # type: ignore[union-attr] + return escape("\n".join(lines)) + return escape(str(data["text"])) + + @app.command() def transcribe( ctx: typer.Context, - source: str = typer.Argument(None, help="Audio file path or public URL."), + source: str = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."), sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), speaker_labels: bool = typer.Option(False, "--speaker-labels", help="Enable diarization."), - srt: bool = typer.Option(False, "--srt", help="Output SRT subtitles."), - vtt: bool = typer.Option(False, "--vtt", help="Output VTT subtitles."), prompt: str = typer.Option( - None, "--prompt", help="Transform the transcript through LLM Gateway with this instruction." + None, "--prompt", help="Bias the speech model with this prompt (u3-pro)." + ), + llm_gateway_prompt: str = typer.Option( + None, + "--llm-gateway-prompt", + help="Transform the finished transcript through LLM Gateway with this instruction.", + ), + model: str = typer.Option( + llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --llm-gateway-prompt." ), - model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --prompt."), max_tokens: int = typer.Option( - llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the --prompt transform." + llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the LLM Gateway transform." ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Transcribe an audio file or URL and print the result. + """Transcribe an audio file, URL, or YouTube URL and print the result. - Pass --prompt to transform the finished transcript through LLM Gateway - (e.g. --prompt "summarize in three bullets"). + --prompt biases the speech model. --llm-gateway-prompt transforms the + finished transcript through LLM Gateway (e.g. "summarize in three bullets"). """ def body(state: AppState, json_mode: bool) -> None: - if srt and vtt: - raise UsageError("--srt and --vtt are mutually exclusive.") - if prompt and (srt or vtt): - raise UsageError("--prompt cannot be combined with --srt/--vtt.") audio = client.resolve_audio_source(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) - transcript = client.transcribe(api_key, audio, speaker_labels=speaker_labels) - - # Subtitle formats are inherently plain text; --json does not apply here. - if srt: - output.console.print(transcript.export_subtitles_srt(), markup=False) - return - if vtt: - output.console.print(transcript.export_subtitles_vtt(), markup=False) - return + if youtube.is_youtube_url(audio): + # Fetch the audio first; AssemblyAI can't read a YouTube watch URL itself. + with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: + local = youtube.download_audio(audio, Path(td)) + transcript = client.transcribe( + api_key, str(local), speaker_labels=speaker_labels, prompt=prompt + ) + else: + transcript = client.transcribe( + api_key, audio, speaker_labels=speaker_labels, prompt=prompt + ) - if prompt: + if llm_gateway_prompt: transformed = llm.transform_transcript( api_key, - prompt=prompt, + prompt=llm_gateway_prompt, model=model, transcript_id=transcript.id, max_tokens=max_tokens, @@ -64,21 +86,28 @@ def body(state: AppState, json_mode: bool) -> None: "id": transcript.id, "status": client.status_str(transcript), "text": transcript.text, - "transform": {"model": model, "prompt": prompt, "output": transformed}, + "transform": { + "model": model, + "prompt": llm_gateway_prompt, + "output": transformed, + }, }, lambda d: escape(str(d["transform"]["output"])), json_mode=json_mode, ) return - output.emit( - { - "id": transcript.id, - "status": client.status_str(transcript), - "text": transcript.text, - }, - lambda d: escape(str(d["text"])), - json_mode=json_mode, - ) + data: dict[str, object] = { + "id": transcript.id, + "status": client.status_str(transcript), + "text": transcript.text, + } + # Surface diarization: --speaker-labels asks for it, so render the per-speaker + # utterances instead of silently dropping them into the flat .text. + if speaker_labels: + utterances = _utterances(transcript) + if utterances: + data["utterances"] = utterances + output.emit(data, _render_transcript, json_mode=json_mode) run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/transcripts.py b/assemblyai_cli/commands/transcripts.py index d0fbe302..630d312f 100644 --- a/assemblyai_cli/commands/transcripts.py +++ b/assemblyai_cli/commands/transcripts.py @@ -8,7 +8,7 @@ from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import APIError -app = typer.Typer() +app = typer.Typer(help="Browse and fetch past transcripts.", no_args_is_help=True) @app.command() diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py index c1606c42..8c9bb014 100644 --- a/assemblyai_cli/main.py +++ b/assemblyai_cli/main.py @@ -42,6 +42,6 @@ def version() -> None: app.add_typer(login.app) app.add_typer(stream.app) app.add_typer(transcribe.app) -app.add_typer(transcripts.app) +app.add_typer(transcripts.app, name="transcripts") app.add_typer(samples.app, name="samples") app.add_typer(claude.app, name="claude") diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py index ac3e6225..20df3747 100644 --- a/assemblyai_cli/microphone.py +++ b/assemblyai_cli/microphone.py @@ -5,6 +5,9 @@ from assemblyai_cli.errors import CLIError +# Used when the device's native rate can't be determined (e.g. headless CI). +_FALLBACK_RATE = 48000 + def audio_missing_error() -> CLIError: """The shared 'sounddevice can't be imported' error for mic and speaker paths.""" @@ -15,6 +18,34 @@ def audio_missing_error() -> CLIError: ) +def _device_default_rate(device: int | None = None) -> int: + """The input device's native sample rate. + + Opening the mic at its own rate avoids CoreAudio 'paramErr' (-50) failures + that happen when a device is forced to an unsupported rate. Falls back to a + safe default if the device can't be queried (no input device, headless CI). + """ + try: + import sounddevice as sd + except ImportError as exc: + raise audio_missing_error() from exc + try: + rate = int(sd.query_devices(device, "input")["default_samplerate"]) + except Exception: # noqa: BLE001 - any query failure -> safe fallback, never crash here + return _FALLBACK_RATE + return rate if rate > 0 else _FALLBACK_RATE + + +def _resample(chunk: bytes, state: Any, *, src_rate: int, dst_rate: int) -> tuple[bytes, Any]: + """Resample one PCM16 mono fragment from `src_rate` to `dst_rate`.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) # audioop is deprecated but stdlib + import audioop + return audioop.ratecv(chunk, 2, 1, src_rate, dst_rate, state) + + class _SoundDeviceMic: """Iterator of PCM16 byte chunks from a sounddevice raw input stream. @@ -53,26 +84,36 @@ def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[byt class MicrophoneSource: - """Iterable of PCM16 chunks from the default microphone. + """Iterable of PCM16 chunks captured at the microphone's native rate. Shared by `aai stream` (mic input) and `aai agent` (captured speech). The - stream factory is injectable so tests don't need real audio hardware. + device is opened at its own sample rate to avoid forcing an unsupported one; + with `target_rate` set (the voice agent needs 24 kHz) the captured audio is + resampled to it, otherwise frames are yielded at the capture rate, which + `sample_rate` reports for the streaming API. The stream factory and rate + lookup are injectable so tests don't need real audio hardware. """ def __init__( self, *, - sample_rate: int, + target_rate: int | None = None, device: int | None = None, + capture_rate: int | None = None, stream_factory: Callable[..., Iterator[bytes]] | None = None, + rate_query: Callable[[int | None], int] | None = None, ) -> None: - self.sample_rate = sample_rate self.device = device + self.target_rate = target_rate self._factory = stream_factory or _default_mic_stream + query = rate_query or _device_default_rate + self._capture_rate = capture_rate if capture_rate is not None else query(device) + # What the yielded PCM is sampled at (resampled to target_rate when set). + self.sample_rate = target_rate or self._capture_rate def __iter__(self) -> Iterator[bytes]: try: - stream: Any = self._factory(sample_rate=self.sample_rate, device=self.device) + stream: Any = self._factory(sample_rate=self._capture_rate, device=self.device) except ImportError as exc: raise audio_missing_error() from exc except Exception as exc: @@ -82,8 +123,14 @@ def __iter__(self) -> Iterator[bytes]: exit_code=1, ) from exc close = getattr(stream, "close", None) + state: Any = None try: - yield from stream + for chunk in stream: + if self.target_rate is not None and self.target_rate != self._capture_rate: + chunk, state = _resample( + chunk, state, src_rate=self._capture_rate, dst_rate=self.target_rate + ) + yield chunk finally: if callable(close): close() diff --git a/assemblyai_cli/render.py b/assemblyai_cli/render.py index 3f000f11..10a7353d 100644 --- a/assemblyai_cli/render.py +++ b/assemblyai_cli/render.py @@ -4,21 +4,29 @@ import sys from typing import TextIO +from rich.console import Console +from rich.live import Live +from rich.text import Text + class BaseRenderer: """Shared plumbing for the streaming and voice-agent renderers. - Two output modes: newline-delimited JSON for agents/pipes, or a human view - that redraws a single in-place line (partial transcript) and finalizes it - with a newline. Subclasses map domain events onto these primitives. + Two output modes. JSON mode writes newline-delimited JSON straight to the + stream (pipe-safe). Human mode renders through Rich: an in-progress line is + shown with `rich.live.Live` (which redraws and clears multi-row wraps + cleanly), and finalized lines are printed above it as permanent scrollback. """ - def __init__(self, *, json_mode: bool, out: TextIO | None = None) -> None: + def __init__( + self, *, json_mode: bool, out: TextIO | None = None, console: Console | None = None + ) -> None: self.json_mode = json_mode self.out = out if out is not None else sys.stdout - self._line_open = False + self._console = console + self._live: Live | None = None - # --- output primitives ------------------------------------------------- + # --- JSON output (plain text; preserves BrokenPipe for `| head`) ------- def _emit(self, obj: object) -> None: """Write one NDJSON event.""" self._write(json.dumps(obj) + "\n") @@ -33,26 +41,56 @@ def _write(self, text: str) -> None: except Exception: # noqa: BLE001, S110 - other downstream write errors are non-fatal pass + # --- human output (Rich) ---------------------------------------------- + def _console_obj(self) -> Console: + if self._console is None: + self._console = Console(file=self.out) + return self._console + + def _live_obj(self) -> Live: + if self._live is None: + # redirect_stdout/stderr stay off: Live must not hijack the process + # streams that the JSON path and threaded callbacks also write to. + self._live = Live( + console=self._console_obj(), + auto_refresh=False, + transient=False, + redirect_stdout=False, + redirect_stderr=False, + ) + self._live.start() + return self._live + + def _commit_live(self) -> None: + """Stop the live region, leaving its last frame as a permanent line.""" + if self._live is not None: + self._live.stop() + self._live = None + def _update_line(self, text: str) -> None: - """Redraw the current line in place (no trailing newline).""" - self._write("\r\x1b[K" + text) - self._line_open = True + """Redraw the in-progress line in place (Rich clears any prior wrap).""" + self._live_obj().update(Text(text), refresh=True) def _finalize_line(self, text: str | None = None) -> None: - """Commit the current line with a newline; optionally replace its text.""" - if text is not None: - self._write("\r\x1b[K" + text + "\n") - self._line_open = False - elif self._line_open: - self._write("\n") - self._line_open = False + """Commit the in-progress line (optionally replacing its text) as permanent.""" + if self._live is not None: + if text is not None: + self._live.update(Text(text), refresh=True) + self._commit_live() + elif text is not None: + self._console_obj().print(Text(text)) + + def _line(self, text: str) -> None: + """Print a standalone permanent line, committing any open partial first.""" + self._commit_live() + self._console_obj().print(Text(text)) # --- shared lifecycle -------------------------------------------------- def stopped(self) -> None: if not self.json_mode: - self._write("Stopped.\n") + self._line("Stopped.") def close(self) -> None: - """Finalize an in-progress human line so later output starts clean.""" + """Commit any in-progress line so later output starts clean.""" if not self.json_mode: - self._finalize_line() + self._commit_live() diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index 2d037e50..c9e3d72d 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -10,7 +10,7 @@ def begin(self, event: object) -> None: if self.json_mode: self._emit({"type": "begin", "id": getattr(event, "id", None)}) else: - self._write("Listening… (Ctrl-C to stop)\n") + self._line("Listening… (Ctrl-C to stop)") def turn(self, event: object) -> None: text = getattr(event, "transcript", "") or "" @@ -38,5 +38,4 @@ def llm(self, content: str) -> None: if self.json_mode: self._emit({"type": "llm", "content": content}) else: - self._finalize_line() # close any open partial-turn line first - self._write("\N{ELECTRIC LIGHT BULB} " + content + "\n") + self._line("\N{ELECTRIC LIGHT BULB} " + content) diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py index 059336f2..a1244e51 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/assemblyai_cli/streaming/sources.py @@ -115,7 +115,14 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: proc.terminate() with contextlib.suppress(Exception): stdout.close() - proc.wait() + try: + proc.wait() + except KeyboardInterrupt: + # The generator can be finalized late, during an interrupted + # shutdown; a stray Ctrl-C in wait() must not surface as the noisy + # "Exception ignored in generator". Kill the child and stay quiet. + with contextlib.suppress(Exception): + proc.kill() # Reached only on natural EOF (not early generator close): surface a real # decode failure instead of silently streaming nothing. if proc.returncode: diff --git a/assemblyai_cli/youtube.py b/assemblyai_cli/youtube.py new file mode 100644 index 00000000..5da037a8 --- /dev/null +++ b/assemblyai_cli/youtube.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from assemblyai_cli.errors import CLIError + +# youtube.com/watch, youtu.be/, music.youtube.com, shorts, with or without scheme. +_YOUTUBE_RE = re.compile( + r"^(https?://)?(www\.|m\.|music\.)?(youtube\.com/|youtu\.be/)", + re.IGNORECASE, +) + + +def is_youtube_url(source: str | None) -> bool: + """True if `source` looks like a YouTube watch/share URL.""" + if not source: + return False + return bool(_YOUTUBE_RE.match(source.strip())) + + +def download_audio(url: str, dest_dir: Path) -> Path: + """Download the best audio track of `url` into `dest_dir` and return its path. + + Uses yt-dlp; the resulting container (m4a/webm/…) is decodable by ffmpeg + (streaming) and uploadable for transcription. + """ + try: + import yt_dlp + except ImportError as exc: + raise CLIError( + "YouTube support needs yt-dlp. Install it with: pip install yt-dlp", + error_type="ytdlp_missing", + exit_code=2, + ) from exc + + options = { + "format": "bestaudio/best", + "outtmpl": str(dest_dir / "%(id)s.%(ext)s"), + "quiet": True, + "no_warnings": True, + "noprogress": True, + } + try: + with yt_dlp.YoutubeDL(options) as ydl: + info = ydl.extract_info(url, download=True) + path = Path(ydl.prepare_filename(info)) + except Exception as exc: # yt-dlp raises many types; surface one clean CLI error + raise CLIError( + f"Could not download audio from {url}: {exc}", + error_type="youtube_error", + exit_code=1, + ) from exc + + if not path.is_file(): + # Post-processing can change the extension; fall back to whatever landed. + files = [p for p in dest_dir.iterdir() if p.is_file()] + if not files: + raise CLIError( + f"yt-dlp produced no audio file for {url}.", + error_type="youtube_error", + exit_code=1, + ) + path = files[0] + return path diff --git a/pyproject.toml b/pyproject.toml index ec43d027..03a484d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "websockets>=13", "sounddevice>=0.5", "openai>=1.40", + "yt-dlp>=2024.0", ] [project.optional-dependencies] @@ -48,7 +49,7 @@ markers = [ [tool.mypy] python_version = "3.10" -files = ["assemblyai_cli"] +files = ["assemblyai_cli", "tests"] # Third-party deps (assemblyai, sounddevice) ship no type stubs. ignore_missing_imports = true disallow_untyped_defs = true @@ -56,6 +57,11 @@ warn_unused_ignores = true warn_return_any = true no_implicit_optional = true +[[tool.mypy.overrides]] +# Tests are type-checked too, but pytest functions don't need return annotations. +module = "tests.*" +disallow_untyped_defs = false + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/scripts/check.sh b/scripts/check.sh index 6fb7d1c3..47e334ba 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,14 +4,17 @@ set -euo pipefail cd "$(dirname "$0")/.." -echo "==> ruff check" +echo "==> ruff check (src + tests)" ruff check . -echo "==> ruff format --check" +echo "==> ruff format --check (src + tests)" ruff format --check . -echo "==> mypy" -mypy +echo "==> mypy (src + tests)" +mypy # files = ["assemblyai_cli", "tests"] in pyproject.toml + +echo "==> markdownlint (docs/ is generated, so excluded)" +markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cache echo "==> pytest (with branch-coverage gate)" # Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py index 8a4235ec..0304e163 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,9 @@ def real_api_key(): class MemoryKeyring(KeyringBackend): - priority = 1 + # keyring's base types `priority` as a classproperty[float]; a plain value is the + # documented way to set it for a backend, so the assignment mismatch is expected. + priority = 1 # type: ignore[assignment] def __init__(self): self._store = {} diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/test_cli_e2e.py b/tests/e2e/test_cli_e2e.py index 98697b42..027b6b4b 100644 --- a/tests/e2e/test_cli_e2e.py +++ b/tests/e2e/test_cli_e2e.py @@ -136,7 +136,7 @@ def test_transcribe_prompt_transforms_via_gateway(real_api_key): [ "transcribe", "--sample", - "--prompt", + "--llm-gateway-prompt", "Summarize this transcript in one short sentence.", "--json", ], @@ -157,7 +157,7 @@ def test_stream_prompt_transforms_at_end(real_api_key, kokoro_pipeline, tmp_path [ "stream", str(wav), - "--prompt", + "--llm-gateway-prompt", "Summarize the transcript in one short sentence.", "--json", ], diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index 62e5a002..97e55888 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -25,7 +25,7 @@ def close(self): def test_player_writes_enqueued_audio(): fake = FakeStream() - p = Player(sample_rate=24000, stream_factory=lambda rate: fake) + p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: fake) p.start() p.enqueue(b"\x01\x02") p.enqueue(b"\x03\x04") @@ -38,7 +38,7 @@ def test_player_writes_enqueued_audio(): def test_player_flush_discards_pending_audio(): fake = FakeStream() - p = Player(sample_rate=24000, stream_factory=lambda rate: fake) + p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: fake) # Do NOT start the worker; queue items directly so flush is deterministic. p.enqueue(b"stale-1") p.enqueue(b"stale-2") @@ -51,7 +51,7 @@ class BoomStream(FakeStream): def write(self, data): raise RuntimeError("device gone") - p = Player(sample_rate=24000, stream_factory=lambda rate: BoomStream()) + p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: BoomStream()) p.start() p.enqueue(b"\x01\x02") p.close() # must return (join has a timeout); thread must not be alive @@ -97,3 +97,89 @@ def boom(**kw): _default_output_stream(24000) assert exc.value.error_type == "audio_output_error" assert exc.value.exit_code == 1 + + +def test_player_opens_stream_at_device_rate(): + seen = {} + + def factory(rate): + seen["rate"] = rate + return FakeStream() + + p = Player(sample_rate=24000, output_rate=48000, stream_factory=factory) + p.start() + p.close() + assert seen["rate"] == 48000 # speaker opened at its native rate, not forced to 24 kHz + + +def test_player_resamples_source_to_device_rate(): + # Agent audio is 24 kHz; when the speaker opens at 48 kHz the worker upsamples. + fake = FakeStream() + p = Player(sample_rate=24000, output_rate=48000, stream_factory=lambda rate: fake) + p.start() + p.enqueue(b"\x00\x00" * 240) # 10 ms of 24 kHz silence + p.close() + written = b"".join(fake.writes) + assert len(written) > 240 * 2 # upsampled to ~48 kHz -> more bytes than the 24 kHz input + + +from assemblyai_cli.agent.audio import DuplexAudio # noqa: E402 + + +def test_duplex_opens_at_device_rate_and_closes(): + seen = {} + fake = FakeStream() + + def factory(*, rate, blocksize, callback, device): + seen["rate"] = rate + seen["device"] = device + return fake + + d = DuplexAudio(device=3, device_rate=48000, stream_factory=factory) + d.player.start() + assert seen["rate"] == 48000 and seen["device"] == 3 # one stream at device rate + d.close() + assert fake.stopped and fake.closed + + +def test_duplex_callback_captures_input_and_zero_fills_idle_output(): + cb = {} + + def factory(*, rate, blocksize, callback, device): + cb["fn"] = callback + return FakeStream() + + d = DuplexAudio(target_rate=24000, device_rate=48000, stream_factory=factory) + d.player.start() + indata = b"\x11\x11" * 4800 # 100 ms @ 48 kHz + outdata = bytearray(b"\xff" * 1920) # nothing queued -> should be zeroed + cb["fn"](indata, outdata, 4800, None, None) + assert bytes(outdata) == b"\x00" * 1920 # idle output is silence, not garbage + + chunk = next(iter(d.mic)) + assert 0 < len(chunk) < len(indata) # captured input resampled 48k -> 24k + d.close() + + +def test_duplex_playback_resamples_and_drains_into_output(): + cb = {} + + def factory(*, rate, blocksize, callback, device): + cb["fn"] = callback + return FakeStream() + + d = DuplexAudio(target_rate=24000, device_rate=48000, stream_factory=factory) + d.player.start() + d.player.enqueue(b"\x01\x02" * 240) # 24 kHz audio -> upsampled to 48 kHz in the buffer + assert d.player.pending() > 240 # more samples buffered after upsample + outdata = bytearray(200) + cb["fn"](b"\x00\x00" * 10, outdata, 10, None, None) + assert bytes(outdata) != b"\x00" * 200 # buffered audio was played out + d.close() + + +def test_duplex_mic_ends_after_close(): + d = DuplexAudio(target_rate=16000, device_rate=16000, stream_factory=lambda **k: FakeStream()) + d.player.start() + d.close() + assert list(d.mic) == [] # capture loop returns on the close sentinel diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 6dc1d9db..73035add 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -88,26 +88,25 @@ def fake_run_session( "agent", "--voice", "james", - "--prompt-file", + "--system-prompt-file", str(prompt_file), - "--prompt", + "--system-prompt", "ignored", - "--full-duplex", ], ) assert result.exit_code == 0 assert seen["voice"] == "james" - assert seen["prompt"] == "be a pirate" # --prompt-file overrides --prompt - assert seen["full_duplex"] is True + assert seen["prompt"] == "be a pirate" # --system-prompt-file overrides --system-prompt + assert seen["full_duplex"] is True # always full duplex now (one stream) -def test_agent_half_duplex_notice_in_human_mode(monkeypatch): +def test_agent_headphones_notice_in_human_mode(monkeypatch): config.set_api_key("default", "sk_live") monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) result = runner.invoke(app, ["agent"]) assert result.exit_code == 0 - assert "Half-duplex" in result.output + assert "headphones" in result.output.lower() # mic stays open -> warn to use headphones def test_agent_ctrl_c_exits_cleanly(monkeypatch): @@ -131,7 +130,9 @@ def test_agent_unknown_voice_exits_2(monkeypatch): def test_agent_prompt_file_not_found_exits_2(monkeypatch): config.set_api_key("default", "sk_live") monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) - result = runner.invoke(app, ["agent", "--prompt-file", "/tmp/no_such_file_xyz_voiceagent.txt"]) + result = runner.invoke( + app, ["agent", "--system-prompt-file", "/tmp/no_such_file_xyz_voiceagent.txt"] + ) assert result.exit_code == 2 @@ -192,7 +193,7 @@ def test_agent_file_source_with_device_exits_2(monkeypatch, tmp_path): assert result.exit_code == 2 # --device is microphone-only -def test_agent_file_source_no_half_duplex_notice(monkeypatch, tmp_path): +def test_agent_file_source_no_headphones_notice(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") @@ -201,4 +202,4 @@ def test_agent_file_source_no_half_duplex_notice(monkeypatch, tmp_path): wav.write_bytes(b"RIFF") result = runner.invoke(app, ["agent", str(wav)]) assert result.exit_code == 0 - assert "Half-duplex" not in result.output # half-duplex note is mic-only + assert "headphones" not in result.output.lower() # mic-only note; file mode is silent diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 5be2137f..30f3bf06 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -1,6 +1,9 @@ import io import json +import pytest +from rich.console import Console + from assemblyai_cli.agent.render import AgentRenderer @@ -8,6 +11,14 @@ def _json_lines(buf: io.StringIO): return [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()] +def _human(width=80): + """A human-mode renderer writing to a forced-terminal Rich console buffer.""" + buf = io.StringIO() + console = Console(file=buf, force_terminal=True, width=width, color_system=None) + return AgentRenderer(json_mode=False, out=buf, console=console), buf + + +# --- json mode (unchanged) ------------------------------------------------- def test_json_emits_user_and_agent_events(): buf = io.StringIO() r = AgentRenderer(json_mode=True, out=buf) @@ -17,11 +28,7 @@ def test_json_emits_user_and_agent_events(): lines = _json_lines(buf) assert {"type": "session.ready"} in lines assert {"type": "transcript.user", "text": "hello there"} in lines - assert { - "type": "transcript.agent", - "text": "hi back", - "interrupted": False, - } in lines + assert {"type": "transcript.agent", "text": "hi back", "interrupted": False} in lines def test_json_never_emits_audio_bytes(): @@ -36,34 +43,6 @@ def test_json_never_emits_audio_bytes(): assert {"type": "reply.done", "interrupted": True} in lines -def test_human_partial_updates_in_place_then_finalizes(): - buf = io.StringIO() - r = AgentRenderer(json_mode=False, out=buf) - r.user_partial("what is") - r.user_final("what is the time") - out = buf.getvalue() - assert "\r\x1b[K" in out # cleared the line for the partial - assert "what is the time" in out # finalized text present - assert out.endswith("\n") # finalized line ends clean - - -def test_human_agent_line_labeled(): - buf = io.StringIO() - r = AgentRenderer(json_mode=False, out=buf) - r.agent_transcript("the time is noon", interrupted=False) - out = buf.getvalue() - assert out.startswith("agent: ") - assert "the time is noon" in out - - -def test_close_finalizes_open_partial_line(): - buf = io.StringIO() - r = AgentRenderer(json_mode=False, out=buf) - r.user_partial("half a sen") - r.close() - assert buf.getvalue().endswith("\n") - - def test_json_stopped_is_silent(): buf = io.StringIO() AgentRenderer(json_mode=True, out=buf).stopped() @@ -76,23 +55,6 @@ def test_json_close_is_silent(): assert buf.getvalue() == "" -def test_notice_writes_to_buffer_in_human_mode(): - buf = io.StringIO() - r = AgentRenderer(json_mode=False, out=buf) - r.notice("hi") - assert buf.getvalue() == "hi" - - -def test_human_connected_and_stopped_announce(): - buf = io.StringIO() - r = AgentRenderer(json_mode=False, out=buf) - r.connected() - r.stopped() - out = buf.getvalue() - assert "start talking" in out.lower() - assert "Stopped." in out - - def test_json_user_partial_emits_delta(): buf = io.StringIO() r = AgentRenderer(json_mode=True, out=buf) @@ -100,28 +62,64 @@ def test_json_user_partial_emits_delta(): assert _json_lines(buf) == [{"type": "transcript.user.delta", "text": "typing…"}] -def test_write_swallows_non_pipe_errors(): - class FlakyOut: +def test_json_emit_propagates_broken_pipe(): + class BrokenOut: def write(self, _text): - raise OSError("transient write error") + raise BrokenPipeError("downstream closed") def flush(self): pass - # Non-pipe write errors are non-fatal and must not raise. - AgentRenderer(json_mode=False, out=FlakyOut()).notice("anything") - + r = AgentRenderer(json_mode=True, out=BrokenOut()) + with pytest.raises(BrokenPipeError): # propagates so the command stops cleanly + r.connected() -def test_write_propagates_broken_pipe(): - import pytest - class BrokenOut: +def test_json_emit_swallows_non_pipe_errors(): + class FlakyOut: def write(self, _text): - raise BrokenPipeError("downstream closed") + raise OSError("transient write error") def flush(self): pass - # BrokenPipeError must propagate so the command can stop cleanly (`| head`). - with pytest.raises(BrokenPipeError): - AgentRenderer(json_mode=False, out=BrokenOut()).notice("x") + AgentRenderer(json_mode=True, out=FlakyOut()).connected() # non-pipe errors are non-fatal + + +# --- human mode (Rich) ----------------------------------------------------- +def test_human_partial_then_final(): + r, buf = _human() + r.user_partial("what is") + r.user_final("what is the time") + r.close() + assert "what is the time" in buf.getvalue() + + +def test_human_agent_line_labeled(): + r, buf = _human() + r.agent_transcript("the time is noon", interrupted=False) + out = buf.getvalue() + assert "agent: " in out + assert "the time is noon" in out + + +def test_human_close_commits_open_partial(): + r, buf = _human() + r.user_partial("half a sentence") + r.close() + assert "half a sentence" in buf.getvalue() # committed, not dropped + + +def test_human_notice_rendered(): + r, buf = _human() + r.notice("Half-duplex note.\n") + assert "Half-duplex note." in buf.getvalue() + + +def test_human_connected_and_stopped_announce(): + r, buf = _human() + r.connected() + r.stopped() + out = buf.getvalue() + assert "start talking" in out.lower() + assert "Stopped." in out diff --git a/tests/test_client.py b/tests/test_client.py index 7c5188db..6091d9b3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -87,7 +87,7 @@ def test_transcribe_blocks_and_returns_transcript(): ): result = client.transcribe("sk", "audio.mp3", speaker_labels=True) - cfg.assert_called_once_with(speaker_labels=True) + cfg.assert_called_once_with(speaker_labels=True, prompt=None) fake_transcriber.transcribe.assert_called_once() assert result is fake_transcript diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 60a94890..554700d4 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -4,7 +4,13 @@ import pytest from assemblyai_cli.errors import CLIError -from assemblyai_cli.microphone import MicrophoneSource, _default_mic_stream, _SoundDeviceMic +from assemblyai_cli.microphone import ( + _FALLBACK_RATE, + MicrophoneSource, + _default_mic_stream, + _device_default_rate, + _SoundDeviceMic, +) class _FakeRawStream: @@ -28,7 +34,7 @@ def close(self): self.closed = True -def test_yields_chunks_from_factory_with_rate_and_device(): +def test_yields_chunks_at_capture_rate(): seen = {} def fake_factory(*, sample_rate, device): @@ -36,16 +42,39 @@ def fake_factory(*, sample_rate, device): seen["device"] = device return iter([b"aa", b"bb"]) - mic = MicrophoneSource(sample_rate=24000, device=3, stream_factory=fake_factory) + mic = MicrophoneSource(capture_rate=24000, device=3, stream_factory=fake_factory) + assert mic.sample_rate == 24000 # no target -> reports the capture rate assert list(mic) == [b"aa", b"bb"] - assert seen == {"rate": 24000, "device": 3} + assert seen == {"rate": 24000, "device": 3} # opened at the capture rate + + +def test_resamples_capture_rate_to_target(): + frames48 = b"\x00\x00" * 960 # 20 ms of silence at 48 kHz + + def fake_factory(*, sample_rate, device): + assert sample_rate == 48000 # device opened at its native rate + return iter([frames48]) + + mic = MicrophoneSource(target_rate=24000, capture_rate=48000, stream_factory=fake_factory) + assert mic.sample_rate == 24000 # callers see the target rate + out = b"".join(mic) + assert 0 < len(out) < len(frames48) # downsampled 48k -> 24k + + +def test_no_resample_when_target_matches_capture(): + def fake_factory(*, sample_rate, device): + return iter([b"\x01\x02\x03\x04"]) + + mic = MicrophoneSource(target_rate=16000, capture_rate=16000, stream_factory=fake_factory) + assert mic.sample_rate == 16000 + assert list(mic) == [b"\x01\x02\x03\x04"] # untouched when rates already match def test_missing_dependency_raises_mic_missing(): def boom(*, sample_rate, device): raise ImportError("No module named 'sounddevice'") - mic = MicrophoneSource(sample_rate=16000, stream_factory=boom) + mic = MicrophoneSource(capture_rate=16000, stream_factory=boom) with pytest.raises(CLIError) as exc: list(mic) assert exc.value.error_type == "mic_missing" @@ -57,7 +86,7 @@ def test_device_error_raises_mic_error_exit_1(): def boom(*, sample_rate, device): raise OSError("Invalid device") - mic = MicrophoneSource(sample_rate=16000, device=99, stream_factory=boom) + mic = MicrophoneSource(capture_rate=16000, device=99, stream_factory=boom) with pytest.raises(CLIError) as exc: list(mic) assert exc.value.error_type == "mic_error" @@ -74,17 +103,49 @@ def __iter__(self): def close(self): closed["called"] = True - mic = MicrophoneSource(sample_rate=16000, stream_factory=lambda **_k: CloseableStream()) + mic = MicrophoneSource(capture_rate=16000, stream_factory=lambda **_k: CloseableStream()) assert list(mic) == [b"x"] assert closed["called"] is True # close() invoked in the finally def test_plain_iterator_without_close_is_fine(): - # A factory returning a bare iterator (no .close) must not error in teardown. - mic = MicrophoneSource(sample_rate=16000, stream_factory=lambda **_k: iter([b"z"])) + mic = MicrophoneSource(capture_rate=16000, stream_factory=lambda **_k: iter([b"z"])) assert list(mic) == [b"z"] +def test_rate_query_resolves_capture_rate_when_not_given(): + seen = {} + + def fake_factory(*, sample_rate, device): + seen["rate"] = sample_rate + return iter([b"q"]) + + mic = MicrophoneSource( + device=7, stream_factory=fake_factory, rate_query=lambda _device: 32000 + ) + assert mic.sample_rate == 32000 + assert list(mic) == [b"q"] + assert seen["rate"] == 32000 + + +def test_device_default_rate_reads_device(monkeypatch): + fake_sd = types.ModuleType("sounddevice") + fake_sd.query_devices = lambda device, kind: {"default_samplerate": 44100.0} + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + assert _device_default_rate(2) == 44100 + + +def test_device_default_rate_falls_back_on_query_error(monkeypatch): + fake_sd = types.ModuleType("sounddevice") + + def boom(*a, **k): + raise RuntimeError("no input device") + + fake_sd.query_devices = boom + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + assert _device_default_rate(None) == _FALLBACK_RATE + + def test_sounddevice_mic_yields_bytes_then_stops_and_closes(): stream = _FakeRawStream() mic = _SoundDeviceMic(stream, blocksize=1024) diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 63fd0ed7..6600010f 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -206,7 +206,7 @@ def fake_transform(api_key, *, prompt, model, transcript_text, max_tokens): app, [ "stream", - "--prompt", + "--llm-gateway-prompt", "translate to english", "--model", "gpt-4.1", @@ -240,4 +240,44 @@ def fake_transform(*a, **k): monkeypatch.setattr("assemblyai_cli.commands.stream.llm.transform_transcript", fake_transform) result = runner.invoke(app, ["stream", "--json"]) assert result.exit_code == 0 - assert called["ran"] is False # no --prompt -> no gateway call + assert called["ran"] is False # no --llm-gateway-prompt -> no gateway call + + +def test_stream_prompt_biases_speech_model(monkeypatch): + config.set_api_key("default", "sk_live") + seen = {} + + def fake(api_key, source, *, sample_rate, prompt=None, **kwargs): + seen["prompt"] = prompt + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + result = runner.invoke(app, ["stream", "--prompt", "expect crypto jargon", "--json"]) + assert result.exit_code == 0 + # --prompt is the speech-model prompt, forwarded to the streaming session. + assert seen["prompt"] == "expect crypto jargon" + + +def test_stream_youtube_url_downloads_then_streams(monkeypatch, tmp_path): + import wave + + config.set_api_key("default", "sk_live") + fake = tmp_path / "vid.wav" + with wave.open(str(fake), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + monkeypatch.setattr( + "assemblyai_cli.commands.stream.youtube.download_audio", lambda url, d: fake + ) + seen = {} + + def fake_stream(api_key, source, *, sample_rate, **kwargs): + seen["source_type"] = type(source).__name__ + seen["src"] = getattr(source, "source", None) + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream) + result = runner.invoke(app, ["stream", "https://youtu.be/abc"]) + assert result.exit_code == 0 + assert seen["source_type"] == "FileSource" # streamed the downloaded local file + assert seen["src"] == str(fake) diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index 520acdb7..f9e065fa 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -2,6 +2,9 @@ import json import types +import pytest +from rich.console import Console + from assemblyai_cli.streaming.render import StreamRenderer @@ -9,16 +12,57 @@ def _turn(transcript, end_of_turn): return types.SimpleNamespace(transcript=transcript, end_of_turn=end_of_turn) -def test_human_turn_finalizes_on_end_of_turn(): - out = io.StringIO() - r = StreamRenderer(json_mode=False, out=out) +def _human(width=80): + """A human-mode renderer writing to a forced-terminal Rich console buffer.""" + buf = io.StringIO() + console = Console(file=buf, force_terminal=True, width=width, color_system=None) + return StreamRenderer(json_mode=False, out=buf, console=console), buf + + +# --- human mode (Rich) ----------------------------------------------------- +def test_human_turn_shows_and_finalizes_text(): + r, buf = _human() r.turn(_turn("hello", False)) r.turn(_turn("hello world", True)) - text = out.getvalue() - assert "hello world" in text - assert text.endswith("\n") + r.close() + assert "hello world" in buf.getvalue() + + +def test_human_begin_prints_notice(): + r, buf = _human() + r.begin(types.SimpleNamespace(id="x")) + assert "Ctrl-C" in buf.getvalue() + + +def test_human_long_partial_clears_wrapped_rows(): + # A partial wider than the terminal wraps; the next redraw must clear ALL + # wrapped rows (Rich emits cursor-up), not stack copies on screen. + r, buf = _human(width=20) + r.turn(_turn("x" * 100, False)) + r.turn(_turn("y" * 100, False)) + assert "\x1b[1A" in buf.getvalue() # moved up over the wrapped rows to clear them + + +def test_human_llm_line_rendered(): + r, buf = _human() + r.turn(_turn("hola", True)) + r.llm("the summary") + assert "the summary" in buf.getvalue() + + +def test_human_stopped_announced(): + r, buf = _human() + r.stopped() + assert "Stopped." in buf.getvalue() +def test_termination_silent_in_human_mode(): + r, buf = _human() + r.termination(types.SimpleNamespace(audio_duration_seconds=3.0)) + assert buf.getvalue() == "" # termination only surfaces in JSON + + +# --- json mode (plain NDJSON, unchanged) ----------------------------------- def test_json_mode_emits_ndjson_events(): out = io.StringIO() r = StreamRenderer(json_mode=True, out=out) @@ -29,49 +73,25 @@ def test_json_mode_emits_ndjson_events(): assert lines[1] == {"type": "turn", "transcript": "hi", "end_of_turn": True} -def test_human_begin_prints_notice(): - out = io.StringIO() - StreamRenderer(json_mode=False, out=out).begin(types.SimpleNamespace(id="x")) - assert "Ctrl-C" in out.getvalue() - - -def test_human_shorter_turn_leaves_no_trailing_padding(): - out = io.StringIO() - r = StreamRenderer(json_mode=False, out=out) - r.turn(_turn("hello world", False)) # long partial - r.turn(_turn("hi", True)) # shorter, finalized - # No leftover characters from the longer partial; finalized line ends clean. - assert out.getvalue().endswith("hi\n") - assert "hello world\n" not in out.getvalue() - - def test_termination_json_emits_duration(): out = io.StringIO() r = StreamRenderer(json_mode=True, out=out) r.termination(types.SimpleNamespace(audio_duration_seconds=12.5)) - import json as _json - - assert _json.loads(out.getvalue()) == { - "type": "termination", - "audio_duration_seconds": 12.5, - } + assert json.loads(out.getvalue()) == {"type": "termination", "audio_duration_seconds": 12.5} -def test_close_finalizes_open_partial_line(): +def test_llm_json_emits_event(): out = io.StringIO() - r = StreamRenderer(json_mode=False, out=out) - r.turn(_turn("partial", False)) # no end_of_turn -> line left open - r.close() - assert out.getvalue().endswith("\n") + r = StreamRenderer(json_mode=True, out=out) + r.llm("the summary") + assert json.loads(out.getvalue()) == {"type": "llm", "content": "the summary"} -def test_close_is_noop_when_line_already_finalized(): +def test_llm_ignores_empty_content(): out = io.StringIO() - r = StreamRenderer(json_mode=False, out=out) - r.turn(_turn("done", True)) # finalized with newline - before = out.getvalue() - r.close() - assert out.getvalue() == before # no extra newline + r = StreamRenderer(json_mode=True, out=out) + r.llm("") + assert out.getvalue() == "" def test_close_is_noop_in_json_mode(): @@ -83,54 +103,27 @@ def test_close_is_noop_in_json_mode(): assert out.getvalue() == before -def test_close_swallows_non_pipe_errors(): - class FlakyOut: +def test_json_emit_propagates_broken_pipe(): + class BrokenOut: def write(self, _text): - raise OSError("transient write error") + raise BrokenPipeError("downstream closed") def flush(self): pass - r = StreamRenderer(json_mode=False, out=FlakyOut()) - r._line_open = True # force the finalize path - r.close() # non-pipe errors are non-fatal - - -def test_llm_json_emits_event(): - out = io.StringIO() - r = StreamRenderer(json_mode=True, out=out) - r.llm("the summary") - assert json.loads(out.getvalue()) == {"type": "llm", "content": "the summary"} - - -def test_llm_human_prints_content(): - out = io.StringIO() - r = StreamRenderer(json_mode=False, out=out) - r.turn(_turn("partial", False)) # open a partial line first - r.llm("a tidy summary") - text = out.getvalue() - assert "a tidy summary" in text - assert text.endswith("\n") + r = StreamRenderer(json_mode=True, out=BrokenOut()) + # BrokenPipe must propagate so the command can stop cleanly (`| head`). + with pytest.raises(BrokenPipeError): + r.turn(_turn("hi", True)) -def test_llm_ignores_empty_content(): - out = io.StringIO() - r = StreamRenderer(json_mode=True, out=out) - r.llm("") # nothing to show - assert out.getvalue() == "" - - -def test_close_propagates_broken_pipe(): - import pytest - - class BrokenOut: +def test_json_emit_swallows_non_pipe_errors(): + class FlakyOut: def write(self, _text): - raise BrokenPipeError("downstream closed") + raise OSError("transient write error") def flush(self): pass - r = StreamRenderer(json_mode=False, out=BrokenOut()) - r._line_open = True # force the finalize path - with pytest.raises(BrokenPipeError): # propagates so the command stops cleanly - r.close() + r = StreamRenderer(json_mode=True, out=FlakyOut()) + r.turn(_turn("hi", True)) # non-pipe write errors are non-fatal diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index a7069869..b053cbf2 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -96,6 +96,42 @@ def wait(self): assert calls["terminated"] and calls["waited"] and calls["closed"] +def test_filesource_ffmpeg_wait_keyboardinterrupt_is_silenced(tmp_path, monkeypatch): + # A stray Ctrl-C while the generator is finalized (proc.wait()) must not escape + # as the noisy "Exception ignored in generator"; the child is killed instead. + p = tmp_path / "clip.mp3" + p.write_bytes(b"x") + monkeypatch.setattr(sources.shutil, "which", lambda _name: "/usr/bin/ffmpeg") + calls = {"killed": False} + + class FakeProc: + def __init__(self): + self.stdout = self + self.stderr = io.BytesIO(b"") + self.returncode = 0 + + def read(self, _n): + return b"\x00" * 3200 # endless + + def close(self): + pass + + def terminate(self): + pass + + def wait(self): + raise KeyboardInterrupt # second Ctrl-C lands during cleanup + + def kill(self): + calls["killed"] = True + + monkeypatch.setattr(sources.subprocess, "Popen", lambda *a, **k: FakeProc()) + gen = iter(FileSource(str(p), sleep=lambda _s: None)) + next(gen) # pull one chunk + gen.close() # must return cleanly despite wait() raising KeyboardInterrupt + assert calls["killed"] is True + + def test_filesource_ffmpeg_failure_raises(tmp_path, monkeypatch): p = tmp_path / "bad.mp3" p.write_bytes(b"x") diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 4d61763d..491b1f4c 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -18,8 +18,7 @@ def _fake_transcript(): t.id = "t_1" t.text = "hello world" t.status = "completed" - t.export_subtitles_srt.return_value = "1\n00:00\nhello" - t.export_subtitles_vtt.return_value = "WEBVTT\nhello" + t.utterances = [] return t @@ -41,40 +40,44 @@ def test_transcribe_requires_source(): assert result.exit_code == 2 -def test_transcribe_passes_speaker_labels(): - _auth() - with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) - assert tx.call_args.kwargs["speaker_labels"] is True - +def test_transcribe_speaker_labels_renders_utterances(monkeypatch): + import types -def test_transcribe_srt_export(): _auth() - with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--srt"]) - assert "00:00" in result.output + t = _fake_transcript() + t.utterances = [ + types.SimpleNamespace(speaker="A", text="hi there", start=0, end=500), + types.SimpleNamespace(speaker="B", text="hello back", start=500, end=900), + ] + # human mode -> "Speaker A: ..." lines + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) + assert result.exit_code == 0 + assert "Speaker A: hi there" in result.output + assert "Speaker B: hello back" in result.output -def test_transcribe_vtt_export(): +def test_transcribe_speaker_labels_json_includes_utterances(): + import types + _auth() - with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--vtt"]) - assert "WEBVTT" in result.output + t = _fake_transcript() + t.utterances = [types.SimpleNamespace(speaker="A", text="hi there", start=0, end=500)] + with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["utterances"] == [{"speaker": "A", "text": "hi there", "start": 0, "end": 500}] -def test_transcribe_srt_vtt_mutually_exclusive(): +def test_transcribe_passes_speaker_labels(): _auth() with patch( "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--srt", "--vtt"]) - assert result.exit_code != 0 + ) as tx: + runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) + assert tx.call_args.kwargs["speaker_labels"] is True def test_transcribe_json_output(): @@ -103,14 +106,6 @@ def test_transcribe_status_renders_enum_value(): assert '"status": "completed"' in result.output -def test_transcribe_srt_vtt_conflict_json_error(): - _auth() - result = runner.invoke(app, ["transcribe", "audio.mp3", "--srt", "--vtt", "--json"]) - assert result.exit_code == 2 - # In --json mode the error is a JSON envelope, not Typer usage text. - assert '"error"' in result.output - - def test_transcribe_prompt_transforms_json(monkeypatch): _auth() seen = {} @@ -127,7 +122,9 @@ def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens): monkeypatch.setattr( "assemblyai_cli.commands.transcribe.llm.transform_transcript", fake_transform ) - result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "summarize", "--json"]) + result = runner.invoke( + app, ["transcribe", "audio.mp3", "--llm-gateway-prompt", "summarize", "--json"] + ) assert result.exit_code == 0 data = json.loads(result.output) assert data["text"] == "hello world" # raw transcript still present in JSON @@ -148,16 +145,35 @@ def test_transcribe_prompt_human_shows_only_transform(monkeypatch): "assemblyai_cli.commands.transcribe.llm.transform_transcript", lambda *a, **k: "TRANSFORMED", ) - result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "summarize"]) + result = runner.invoke( + app, ["transcribe", "audio.mp3", "--llm-gateway-prompt", "summarize"] + ) assert result.exit_code == 0 assert "TRANSFORMED" in result.output assert "hello world" not in result.output # human mode shows the transform only -def test_transcribe_prompt_with_srt_exits_2(): +def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path): _auth() + fake = tmp_path / "vid.m4a" + fake.write_bytes(b"x") + monkeypatch.setattr( + "assemblyai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake + ) with patch( "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "summarize", "--srt"]) - assert result.exit_code == 2 + ) as tx: + result = runner.invoke(app, ["transcribe", "https://youtu.be/abc", "--json"]) + assert result.exit_code == 0 + assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file + + +def test_transcribe_prompt_biases_speech_model(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "expect medical terms"]) + assert result.exit_code == 0 + # --prompt is the speech-model prompt, forwarded to the transcription call. + assert tx.call_args.kwargs["prompt"] == "expect medical terms" diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 796c42cc..abd1958e 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -15,7 +15,7 @@ def test_get_prints_transcript_text(): fake.text = "retrieved text" fake.status = "completed" with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): - result = runner.invoke(app, ["get", "t_42"]) + result = runner.invoke(app, ["transcripts", "get", "t_42"]) assert result.exit_code == 0 assert "retrieved text" in result.output @@ -24,13 +24,13 @@ def test_list_renders_rows(): config.set_api_key("default", "sk_live") rows = [{"id": "t1", "status": "completed"}, {"id": "t2", "status": "processing"}] with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): - result = runner.invoke(app, ["list", "--json"]) + result = runner.invoke(app, ["transcripts", "list", "--json"]) assert result.exit_code == 0 assert "t1" in result.output and "t2" in result.output def test_list_unauthenticated_exits_2(): - result = runner.invoke(app, ["list"]) + result = runner.invoke(app, ["transcripts", "list"]) assert result.exit_code == 2 @@ -39,7 +39,7 @@ def test_list_human_mode_renders_table(monkeypatch): monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) rows = [{"id": "t1", "status": "completed", "created": "2026-01-01"}] with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): - result = runner.invoke(app, ["list"]) + result = runner.invoke(app, ["transcripts", "list"]) assert result.exit_code == 0 assert "t1" in result.output # rendered through the Rich table path @@ -53,5 +53,5 @@ def test_get_errored_transcript_exits_nonzero(): fake.status = "error" fake.error = "decode failed" with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): - result = runner.invoke(app, ["get", "t_err"]) + result = runner.invoke(app, ["transcripts", "get", "t_err"]) assert result.exit_code == 1 diff --git a/tests/test_youtube.py b/tests/test_youtube.py new file mode 100644 index 00000000..d73de82f --- /dev/null +++ b/tests/test_youtube.py @@ -0,0 +1,105 @@ +import sys +import types + +import pytest + +from assemblyai_cli import youtube +from assemblyai_cli.errors import CLIError + + +def test_is_youtube_url_variants(): + assert youtube.is_youtube_url("https://www.youtube.com/watch?v=abc") + assert youtube.is_youtube_url("http://youtube.com/watch?v=abc") + assert youtube.is_youtube_url("https://youtu.be/abc123") + assert youtube.is_youtube_url("youtube.com/watch?v=x") + assert youtube.is_youtube_url("https://music.youtube.com/watch?v=x") + assert not youtube.is_youtube_url("https://example.com/clip.mp3") + assert not youtube.is_youtube_url("/local/file.wav") + assert not youtube.is_youtube_url(None) + assert not youtube.is_youtube_url("") + + +def _fake_ytdlp(monkeypatch, ydl_cls): + monkeypatch.setitem(sys.modules, "yt_dlp", types.SimpleNamespace(YoutubeDL=ydl_cls)) + + +def test_download_audio_returns_prepared_path(tmp_path, monkeypatch): + created = tmp_path / "vid123.m4a" + + class FakeYDL: + def __init__(self, opts): + self.opts = opts + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def extract_info(self, url, download): + created.write_bytes(b"audio") + return {"id": "vid123", "ext": "m4a"} + + def prepare_filename(self, info): + return str(created) + + _fake_ytdlp(monkeypatch, FakeYDL) + out = youtube.download_audio("https://youtu.be/vid123", tmp_path) + assert out == created + assert out.is_file() + + +def test_download_audio_falls_back_to_landed_file(tmp_path, monkeypatch): + landed = tmp_path / "actual.webm" + + class FakeYDL: + def __init__(self, opts): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def extract_info(self, url, download): + landed.write_bytes(b"x") + return {"id": "x"} + + def prepare_filename(self, info): + return str(tmp_path / "guessed.m4a") # wrong extension; file doesn't exist + + _fake_ytdlp(monkeypatch, FakeYDL) + assert youtube.download_audio("https://youtu.be/x", tmp_path) == landed + + +def test_download_audio_error_raises_cli_error(tmp_path, monkeypatch): + class FakeYDL: + def __init__(self, opts): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def extract_info(self, url, download): + raise RuntimeError("network down") + + def prepare_filename(self, info): + return "" + + _fake_ytdlp(monkeypatch, FakeYDL) + with pytest.raises(CLIError) as exc: + youtube.download_audio("https://youtu.be/x", tmp_path) + assert exc.value.error_type == "youtube_error" + assert exc.value.exit_code == 1 + + +def test_download_audio_missing_ytdlp_raises(tmp_path, monkeypatch): + monkeypatch.setitem(sys.modules, "yt_dlp", None) # force ImportError on `import yt_dlp` + with pytest.raises(CLIError) as exc: + youtube.download_audio("https://youtu.be/x", tmp_path) + assert exc.value.error_type == "ytdlp_missing" + assert exc.value.exit_code == 2 From 227721da7e164016d709e14566588af0ad865e8e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:12:24 -0700 Subject: [PATCH 16/87] feat(theme): themed base renderer console and styled line helpers Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/render.py | 24 +++++++++++++++--------- tests/test_streaming_render.py | 17 +++++++++++++---- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/assemblyai_cli/render.py b/assemblyai_cli/render.py index 10a7353d..ccef5bf0 100644 --- a/assemblyai_cli/render.py +++ b/assemblyai_cli/render.py @@ -8,6 +8,8 @@ from rich.live import Live from rich.text import Text +from assemblyai_cli import theme + class BaseRenderer: """Shared plumbing for the streaming and voice-agent renderers. @@ -44,7 +46,7 @@ def _write(self, text: str) -> None: # --- human output (Rich) ---------------------------------------------- def _console_obj(self) -> Console: if self._console is None: - self._console = Console(file=self.out) + self._console = theme.make_console(file=self.out) return self._console def _live_obj(self) -> Live: @@ -67,28 +69,32 @@ def _commit_live(self) -> None: self._live.stop() self._live = None - def _update_line(self, text: str) -> None: + @staticmethod + def _as_text(text: str | Text) -> Text: + return text if isinstance(text, Text) else Text(text) + + def _update_line(self, text: str | Text) -> None: """Redraw the in-progress line in place (Rich clears any prior wrap).""" - self._live_obj().update(Text(text), refresh=True) + self._live_obj().update(self._as_text(text), refresh=True) - def _finalize_line(self, text: str | None = None) -> None: + def _finalize_line(self, text: str | Text | None = None) -> None: """Commit the in-progress line (optionally replacing its text) as permanent.""" if self._live is not None: if text is not None: - self._live.update(Text(text), refresh=True) + self._live.update(self._as_text(text), refresh=True) self._commit_live() elif text is not None: - self._console_obj().print(Text(text)) + self._console_obj().print(self._as_text(text)) - def _line(self, text: str) -> None: + def _line(self, text: str | Text) -> None: """Print a standalone permanent line, committing any open partial first.""" self._commit_live() - self._console_obj().print(Text(text)) + self._console_obj().print(self._as_text(text)) # --- shared lifecycle -------------------------------------------------- def stopped(self) -> None: if not self.json_mode: - self._line("Stopped.") + self._line(Text("Stopped.", style="aai.muted")) def close(self) -> None: """Commit any in-progress line so later output starts clean.""" diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index f9e065fa..c2a57e25 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -3,8 +3,8 @@ import types import pytest -from rich.console import Console +from assemblyai_cli import theme from assemblyai_cli.streaming.render import StreamRenderer @@ -12,13 +12,22 @@ def _turn(transcript, end_of_turn): return types.SimpleNamespace(transcript=transcript, end_of_turn=end_of_turn) -def _human(width=80): - """A human-mode renderer writing to a forced-terminal Rich console buffer.""" +def _human(width=80, color_system=None): + """A human-mode renderer writing to a forced-terminal themed console buffer.""" buf = io.StringIO() - console = Console(file=buf, force_terminal=True, width=width, color_system=None) + console = theme.make_console( + file=buf, force_terminal=True, width=width, color_system=color_system + ) return StreamRenderer(json_mode=False, out=buf, console=console), buf +def test_default_console_is_themed(): + buf = io.StringIO() + r = StreamRenderer(json_mode=False, out=buf) + # _console_obj builds via theme.make_console, so aai.* names resolve. + r._console_obj().get_style("aai.brand") + + # --- human mode (Rich) ----------------------------------------------------- def test_human_turn_shows_and_finalizes_text(): r, buf = _human() From b56be91d24ba189c4e0b27029ea0bbc40858cc34 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:13:33 -0700 Subject: [PATCH 17/87] build: add uv lock for reproducible dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uv lock` resolves the PEP 621 deps in pyproject.toml into uv.lock (hatchling stays the build backend; uv only manages resolution/locking). Use `uv sync` to install from the lock and `uv lock` to refresh it. Also gitignore poetry.lock — this project doesn't use poetry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + uv.lock | 1583 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1586 insertions(+) create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 9b9aaf19..caa963c5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ docs/ # Local scratch scripts (often contain live keys) transcribe/ + +# Wrong tool for this project (hatchling + uv); never commit a poetry lock +poetry.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..96030f22 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1583 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "assemblyai" +version = "0.64.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings", marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/cf/749048698da4a469a9ca1afc36e437ecd36b415a626f3f00ac5ce7aaf6f5/assemblyai-0.64.3.tar.gz", hash = "sha256:6fffa15cb7942184ebbe43867a244568fab507d5e09548ba728da47ca241be7e", size = 71386, upload-time = "2026-05-19T21:51:28.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f2/ff2044c128b783ebea34806d9cb2a2ac9d2a5d86d642fc7f9fbf807057c5/assemblyai-0.64.3-py3-none-any.whl", hash = "sha256:3ee8806fd27ce4004d35d6cbc63564ffd1c7ddad97375f780f4ee3353282d907", size = 62864, upload-time = "2026-05-19T21:51:27.53Z" }, +] + +[[package]] +name = "assemblyai-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "assemblyai" }, + { name = "keyring" }, + { name = "openai" }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "sounddevice" }, + { name = "tomli-w" }, + { name = "typer" }, + { name = "websockets" }, + { name = "yt-dlp" }, +] + +[package.optional-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "assemblyai", specifier = ">=0.34" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "keyring", specifier = ">=24.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "openai", specifier = ">=1.40" }, + { name = "platformdirs", specifier = ">=4.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, + { name = "sounddevice", specifier = ">=0.5" }, + { name = "tomli-w", specifier = ">=1.0" }, + { name = "typer", specifier = ">=0.12" }, + { name = "websockets", specifier = ">=13" }, + { name = "yt-dlp", specifier = ">=2024.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.153.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/c3/8c661bb893725eedeb003e85f3050274da2d77abf0847c4d61b4af53969c/hypothesis-6.153.6.tar.gz", hash = "sha256:8f7663251c57c9ee1fb6c0e919a6027cbda98d52b210dea441957d11d644c271", size = 475551, upload-time = "2026-05-27T17:43:32.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/33/f3ec54e6fb89c2279f0dd911ba512321e70038e447d1984c35fad61840f8/hypothesis-6.153.6-py3-none-any.whl", hash = "sha256:a892e3460e4dd8cfb8525682d8901be8f5e2d2c7b352359b71a44e5def2b89c8", size = 541876, upload-time = "2026-05-27T17:43:30.807Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/da/76a2c7e510ba15fe323d9509c223ab272da79ea59f54488f4a78da6426db/jiter-0.15.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:edebcf7d1f601199084bb6e844d7dc67e03e04f6ac786b0332d616635c4ff7a4", size = 310849, upload-time = "2026-05-19T10:06:51.944Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8e/827be942883a4dc0862c48626ff41af3320b1902d136a0bf4b9041f2c567/jiter-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f924585cdacf631cd382b657966847bb537bf9ed0a6f9b991da5f05a631480f", size = 314991, upload-time = "2026-05-19T10:06:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/be2832be361ba1b9517c76f46d30b64e985be1dd43c974f4c3a4b1844436/jiter-0.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abbf258599526ad0326fe51e252e24f2bd6f24f1852681b4b78feda3808f1d18", size = 340843, upload-time = "2026-05-19T10:06:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/90f01fb83c0c7ba509303ec93e32a308fbfa167d264860b01c0fd0dbbd06/jiter-0.15.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c468136b8bd6bb18c8786e4236a1fa27362f24cb23450ba0cb204ab379b8e6f", size = 365116, upload-time = "2026-05-19T10:06:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/91/38/94593d34f8c67a0b6f6cbc027f016ffa9780b3a858a7a86f6fd7a15bcc1e/jiter-0.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05906b93d72f03339e6bb7cf8dc10ebda64a0266126eed6beba79e20abcf5fd4", size = 457970, upload-time = "2026-05-19T10:06:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/df/04/d79962dd49d00c97e2a9b4cacea1947904d02135936960351f9a96d4c1a6/jiter-0.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30ce785d2adb8e32c3f7741442370a74834ec4c01f3c48f0750227a0b4ef27d6", size = 375744, upload-time = "2026-05-19T10:07:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2e/5d37abe2be0e819c21e2338bebd410e481763ce526a9138c8c3652fa0123/jiter-0.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd73e3da91a0a722d67165e849ce2cdc10de0e0d48738c142be8c6c5f310f4c", size = 349609, upload-time = "2026-05-19T10:07:01.829Z" }, + { url = "https://files.pythonhosted.org/packages/7a/90/98768ad2ed90c1fda15d64157de2dfbf73c1c074d4b1bfaca915480bc7cf/jiter-0.15.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:ceb8fc27d38793f9c97149be8302720c5b22e5c195a37bf2c45dc36c4600a512", size = 354366, upload-time = "2026-05-19T10:07:03.587Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c4/fbfb806209f1fe4b7dccdfb07bc62bb044300734a945b06fd64db446ef6a/jiter-0.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d726e3ceeb337191324b49de298142f27c3ad10886341555d1d5315b5f252c6a", size = 393519, upload-time = "2026-05-19T10:07:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/37/1c/b9c257cd70cb453b6d10f3ebf0402cdb11669ab455389096f09839670290/jiter-0.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c8aea7781d2a372227871de4e1a1332aa96f5a89fd76c5e835dafdbad102887", size = 519952, upload-time = "2026-05-19T10:07:06.589Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1a/aa85027db7ab15829c12feebbc33b404f53fc399bd559d85fd0d6365ff0d/jiter-0.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf4bd113a69c0a740e27cb962ce10630c36d2b8f59d759a651b955ee9d18a823", size = 550770, upload-time = "2026-05-19T10:07:08.228Z" }, + { url = "https://files.pythonhosted.org/packages/d4/54/8c3f65c8a5687925e84708f19d63f7f37d28e2b86a48d951702ad94424d8/jiter-0.15.0-cp310-cp310-win32.whl", hash = "sha256:d92a5cd21fdb083931d546c207aa29633787c5dc5b02daab2d32b843f88a2c53", size = 209303, upload-time = "2026-05-19T10:07:10.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/0528a1eb9f42dd2d8228a0711458628f35924d131f623eaebc35fd23d3d4/jiter-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:e58585a58209d72691ce2d62a9147445f5a87beb0bde97fde284c96ae392a3d1", size = 200404, upload-time = "2026-05-19T10:07:11.426Z" }, + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[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, upload-time = "2026-05-07T12:08:28.36Z" } +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, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[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, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openai" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version >= '3.14'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.14'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "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, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sounddevice" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/a5/756f2e6bc81a7dd79aa3c625dd01b74cabc4516628cace2caaec09ca6ff2/typer-0.26.2.tar.gz", hash = "sha256:9b4f19e08fcc9427a822d1ef467b1fe76737a2f65c7926bdeba2337d73569b68", size = 198991, upload-time = "2026-05-27T10:41:39.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/a5/6ffd702beda8798b2b82ff70805ed4a66d963557e43a5d1823ab456251a4/typer-0.26.2-py3-none-any.whl", hash = "sha256:39beff72ffbb31978a5b545f677d57edb97c6f980f433b38556deb0af25f094d", size = 123123, upload-time = "2026-05-27T10:41:40.504Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "yt-dlp" +version = "2026.3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From 8b6a09e80ad0f40ef443f877665bd52e47c276fe Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:16:59 -0700 Subject: [PATCH 18/87] feat(theme): color streaming notice and LLM line Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/streaming/render.py | 6 ++++-- tests/test_streaming_render.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index c9e3d72d..94300cc9 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -1,5 +1,7 @@ from __future__ import annotations +from rich.text import Text + from assemblyai_cli.render import BaseRenderer @@ -10,7 +12,7 @@ def begin(self, event: object) -> None: if self.json_mode: self._emit({"type": "begin", "id": getattr(event, "id", None)}) else: - self._line("Listening… (Ctrl-C to stop)") + self._line(Text("Listening… (Ctrl-C to stop)", style="aai.muted")) def turn(self, event: object) -> None: text = getattr(event, "transcript", "") or "" @@ -38,4 +40,4 @@ def llm(self, content: str) -> None: if self.json_mode: self._emit({"type": "llm", "content": content}) else: - self._line("\N{ELECTRIC LIGHT BULB} " + content) + self._line(Text("\N{ELECTRIC LIGHT BULB} " + content, style="aai.brand")) diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index c2a57e25..570ddefe 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -136,3 +136,18 @@ def flush(self): r = StreamRenderer(json_mode=True, out=FlakyOut()) r.turn(_turn("hi", True)) # non-pipe write errors are non-fatal + + +def test_human_begin_notice_is_muted(): + r, buf = _human(color_system="truecolor") + r.begin(types.SimpleNamespace(id="x")) + assert "\x1b[" in buf.getvalue() # muted styling emits ANSI + + +def test_human_llm_line_is_branded(): + r, buf = _human(color_system="truecolor") + r.turn(_turn("hola", True)) + r.llm("the summary") + out = buf.getvalue() + assert "the summary" in out + assert "\x1b[" in out # brand styling emits ANSI From ad5a7cac0ef2051a3ca8b48a0a4d0bff40ea1450 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:20:46 -0700 Subject: [PATCH 19/87] feat(theme): accent you:/agent: labels and mute lifecycle notices Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/agent/render.py | 15 +++++++++++---- tests/test_agent_render.py | 29 +++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index ad3664f3..febead7b 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -1,8 +1,15 @@ from __future__ import annotations +from rich.text import Text + from assemblyai_cli.render import BaseRenderer +def _labeled(label: str, body: str) -> Text: + """A line whose `label` prefix is brand-accented and whose body is default.""" + return Text.assemble((label, "aai.label"), body) + + class AgentRenderer(BaseRenderer): """Renders Voice Agent events: human transcript lines, or NDJSON for agents. @@ -14,7 +21,7 @@ def connected(self) -> None: if self.json_mode: self._emit({"type": "session.ready"}) else: - self._line("Connected — start talking. (Ctrl-C to stop)") + self._line(Text("Connected — start talking. (Ctrl-C to stop)", style="aai.muted")) def notice(self, text: str) -> None: """Print a human-facing notice (caller chooses when to suppress in JSON).""" @@ -25,13 +32,13 @@ def user_partial(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user.delta", "text": text}) return - self._update_line("you: " + text) + self._update_line(_labeled("you: ", text)) def user_final(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user", "text": text}) return - self._finalize_line("you: " + text) + self._finalize_line(_labeled("you: ", text)) # --- agent ------------------------------------------------------------- def reply_started(self) -> None: @@ -42,7 +49,7 @@ def agent_transcript(self, text: str, *, interrupted: bool) -> None: if self.json_mode: self._emit({"type": "transcript.agent", "text": text, "interrupted": interrupted}) return - self._line("agent: " + text) # commits any open "you: …" partial first + self._line(_labeled("agent: ", text)) # commits any open "you: …" partial first def reply_done(self, *, interrupted: bool) -> None: if self.json_mode: diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 30f3bf06..751c9151 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -2,8 +2,8 @@ import json import pytest -from rich.console import Console +from assemblyai_cli import theme from assemblyai_cli.agent.render import AgentRenderer @@ -11,10 +11,12 @@ def _json_lines(buf: io.StringIO): return [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()] -def _human(width=80): - """A human-mode renderer writing to a forced-terminal Rich console buffer.""" +def _human(width=80, color_system=None): + """A human-mode renderer writing to a forced-terminal themed console buffer.""" buf = io.StringIO() - console = Console(file=buf, force_terminal=True, width=width, color_system=None) + console = theme.make_console( + file=buf, force_terminal=True, width=width, color_system=color_system + ) return AgentRenderer(json_mode=False, out=buf, console=console), buf @@ -123,3 +125,22 @@ def test_human_connected_and_stopped_announce(): out = buf.getvalue() assert "start talking" in out.lower() assert "Stopped." in out + + +def test_human_agent_label_is_colored(): + r, buf = _human(color_system="truecolor") + r.agent_transcript("the time is noon", interrupted=False) + out = buf.getvalue() + assert "agent: " in out + assert "the time is noon" in out + assert "\x1b[" in out # label styling emits ANSI + + +def test_human_you_label_is_colored(): + r, buf = _human(color_system="truecolor") + r.user_final("what is the time") + r.close() + out = buf.getvalue() + assert "you: " in out + assert "what is the time" in out + assert "\x1b[" in out From 698802b8213415a1bfc22bae06be509e8e1ba3d5 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:22:52 -0700 Subject: [PATCH 20/87] feat(stream): default streaming to u3-rt-pro speech model Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/client.py | 2 +- assemblyai_cli/commands/stream.py | 2 +- assemblyai_cli/templates/stream.py.tmpl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 31672f33..dd6fb636 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -103,7 +103,7 @@ def stream_audio( on_turn: Callable[[Any], Any] | None = None, on_termination: Callable[[Any], Any] | None = None, prompt: str | None = None, - speech_model: SpeechModel = SpeechModel.universal_streaming_multilingual, + speech_model: SpeechModel = SpeechModel.u3_rt_pro, ) -> None: """Stream `source` (an iterable of PCM bytes) through the v3 realtime API. diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index a193bcb6..61ba162b 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -28,7 +28,7 @@ def stream( ), device: int | None = typer.Option(None, "--device", help="Microphone device index."), prompt: str = typer.Option( - None, "--prompt", help="Bias the speech model with this prompt (u3-pro)." + None, "--prompt", help="Bias the speech model with this prompt (u3-rt-pro)." ), llm_gateway_prompt: str = typer.Option( None, diff --git a/assemblyai_cli/templates/stream.py.tmpl b/assemblyai_cli/templates/stream.py.tmpl index 9dcfed36..9cbcfdfa 100644 --- a/assemblyai_cli/templates/stream.py.tmpl +++ b/assemblyai_cli/templates/stream.py.tmpl @@ -27,7 +27,7 @@ client.connect( StreamingParameters( sample_rate=16000, format_turns=True, - speech_model=SpeechModel.universal_streaming_multilingual, + speech_model=SpeechModel.u3_rt_pro, ) ) From 29199fb7885a2cd3a977248aa696f441a7ed0d41 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:23:18 -0700 Subject: [PATCH 21/87] feat(theme): color diarized speaker labels in transcribe output Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/transcribe.py | 20 +++++++++++------- tests/test_transcribe.py | 30 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 3d704a99..ef250ca0 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -5,8 +5,9 @@ import typer from rich.markup import escape +from rich.text import Text -from assemblyai_cli import client, config, llm, output, youtube +from assemblyai_cli import client, config, llm, output, theme, youtube from assemblyai_cli.context import AppState, run_command app = typer.Typer() @@ -15,17 +16,20 @@ def _utterances(transcript: object) -> list[dict[str, object]]: """Speaker-labeled utterances ({speaker, text, start, end}), empty if none.""" items = getattr(transcript, "utterances", None) or [] - return [ - {"speaker": u.speaker, "text": u.text, "start": u.start, "end": u.end} for u in items - ] + return [{"speaker": u.speaker, "text": u.text, "start": u.start, "end": u.end} for u in items] -def _render_transcript(data: dict[str, object]) -> str: +def _render_transcript(data: dict[str, object]) -> str | Text: """Human view: speaker-labeled lines when diarized, otherwise the plain text.""" utterances = data.get("utterances") - if utterances: - lines = [f"Speaker {u['speaker']}: {u['text']}" for u in utterances] # type: ignore[union-attr] - return escape("\n".join(lines)) + if isinstance(utterances, list) and utterances: + line = Text() + for i, u in enumerate(utterances): + if i: + line.append("\n") + line.append(f"Speaker {u['speaker']}: ", style=theme.speaker_style(u["speaker"])) + line.append(str(u["text"])) + return line return escape(str(data["text"])) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 491b1f4c..77df8871 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -177,3 +177,33 @@ def test_transcribe_prompt_biases_speech_model(): assert result.exit_code == 0 # --prompt is the speech-model prompt, forwarded to the transcription call. assert tx.call_args.kwargs["prompt"] == "expect medical terms" + + +def test_render_transcript_colors_speaker_labels(): + import io + + from assemblyai_cli import theme + from assemblyai_cli.commands.transcribe import _render_transcript + + data = { + "text": "ignored when utterances present", + "utterances": [ + {"speaker": "A", "text": "hello", "start": 0, "end": 1}, + {"speaker": "B", "text": "hi there", "start": 1, "end": 2}, + ], + } + rendered = _render_transcript(data) + buf = io.StringIO() + console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor") + console.print(rendered) + out = buf.getvalue() + assert "Speaker A:" in out + assert "hello" in out + assert "Speaker B:" in out + assert "\x1b[" in out # speaker labels are styled + + +def test_render_transcript_plain_text_unchanged(): + from assemblyai_cli.commands.transcribe import _render_transcript + + assert _render_transcript({"text": "just the words"}) == "just the words" From 4163d9f3fa9419ae47b6292a3d0b1a5de072b756 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:26:18 -0700 Subject: [PATCH 22/87] feat(theme): brand table header and status-colored cells in transcripts list Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/transcripts.py | 8 +++++--- tests/test_transcripts.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/assemblyai_cli/commands/transcripts.py b/assemblyai_cli/commands/transcripts.py index 630d312f..6d92bc6e 100644 --- a/assemblyai_cli/commands/transcripts.py +++ b/assemblyai_cli/commands/transcripts.py @@ -3,8 +3,9 @@ import typer from rich.markup import escape from rich.table import Table +from rich.text import Text -from assemblyai_cli import client, config, output +from assemblyai_cli import client, config, output, theme from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import APIError @@ -53,11 +54,12 @@ def body(state: AppState, json_mode: bool) -> None: rows = client.list_transcripts(api_key, limit=limit) def render(data: list[dict[str, object]]) -> Table: - table = Table("id", "status", "created") + table = Table("id", "status", "created", header_style="aai.heading") for row in data: + status = str(row["status"]) table.add_row( escape(str(row["id"])), - escape(str(row["status"])), + Text(status, style=theme.status_style(status)), escape(str(row.get("created", ""))), ) return table diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index abd1958e..0f40c395 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -55,3 +55,21 @@ def test_get_errored_transcript_exits_nonzero(): with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): result = runner.invoke(app, ["transcripts", "get", "t_err"]) assert result.exit_code == 1 + + +def test_list_table_colors_status(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + # Force a real color terminal so styling produces ANSI we can assert on. + monkeypatch.setattr( + "assemblyai_cli.output.console", + __import__("assemblyai_cli.theme", fromlist=["make_console"]).make_console( + force_terminal=True, color_system="truecolor" + ), + ) + rows = [{"id": "t1", "status": "completed", "created": "2026-01-01"}] + with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + result = runner.invoke(app, ["transcripts", "list"], color=True) + assert result.exit_code == 0 + assert "completed" in result.output + assert "\x1b[" in result.output # status cell is colored From 8ddb828a7234e2ee9294b509fe791ce6517860ef Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:29:22 -0700 Subject: [PATCH 23/87] test(theme): assert per-status color codes in transcripts list table Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_transcripts.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 0f40c395..d675ab97 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -58,18 +58,23 @@ def test_get_errored_transcript_exits_nonzero(): def test_list_table_colors_status(monkeypatch): + from assemblyai_cli.theme import make_console + config.set_api_key("default", "sk_live") monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) # Force a real color terminal so styling produces ANSI we can assert on. monkeypatch.setattr( "assemblyai_cli.output.console", - __import__("assemblyai_cli.theme", fromlist=["make_console"]).make_console( - force_terminal=True, color_system="truecolor" - ), + make_console(force_terminal=True, color_system="truecolor"), ) - rows = [{"id": "t1", "status": "completed", "created": "2026-01-01"}] + rows = [ + {"id": "t1", "status": "completed", "created": "2026-01-01"}, + {"id": "t2", "status": "error", "created": "2026-01-02"}, + ] with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): result = runner.invoke(app, ["transcripts", "list"], color=True) assert result.exit_code == 0 assert "completed" in result.output - assert "\x1b[" in result.output # status cell is colored + assert "error" in result.output + assert "\x1b[32m" in result.output # aai.success (green) → "completed" cell + assert "\x1b[1;31m" in result.output # aai.error (bold red) → "error" cell From a58fa07fbf8738d3a0dbd0b71ad5455fbcbc5016 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:31:21 -0700 Subject: [PATCH 24/87] feat(theme): semantic colors for setup steps, login, and samples notices Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/claude.py | 12 +++++++++--- assemblyai_cli/commands/login.py | 4 ++-- assemblyai_cli/commands/samples.py | 2 +- tests/test_claude_render.py | 21 +++++++++++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 tests/test_claude_render.py diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py index d4c05824..db0c935a 100644 --- a/assemblyai_cli/commands/claude.py +++ b/assemblyai_cli/commands/claude.py @@ -9,7 +9,7 @@ import typer from rich.markup import escape -from assemblyai_cli import output +from assemblyai_cli import output, theme from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError @@ -183,8 +183,14 @@ def _remove_skill() -> Step: def _render_steps(data: dict[str, list[Step]]) -> str: - lines = [f" {s['name']}: {s['status']} — {escape(s['detail'])}" for s in data["steps"]] - return "AssemblyAI coding-agent setup:\n" + "\n".join(lines) + lines = [] + for s in data["steps"]: + style = theme.status_style(s["status"]) + lines.append( + f" {escape(s['name'])}: " + f"[{style}]{escape(s['status'])}[/{style}] — {escape(s['detail'])}" + ) + return "[aai.heading]AssemblyAI coding-agent setup:[/aai.heading]\n" + "\n".join(lines) @app.command() diff --git a/assemblyai_cli/commands/login.py b/assemblyai_cli/commands/login.py index 4876b48a..3643461d 100644 --- a/assemblyai_cli/commands/login.py +++ b/assemblyai_cli/commands/login.py @@ -33,7 +33,7 @@ def body(state: AppState, json_mode: bool) -> None: webbrowser.open(DASHBOARD_KEYS_URL) except Exception: # noqa: BLE001 - opening a browser is best-effort output.console.print( - "[dim]Could not open a browser; open the URL above manually.[/dim]" + "[aai.muted]Could not open a browser; open the URL above manually.[/aai.muted]" ) key = typer.prompt("Paste your API key", hide_input=True) if not client.validate_key(key): @@ -41,7 +41,7 @@ def body(state: AppState, json_mode: bool) -> None: config.set_api_key(profile, key) output.emit( {"authenticated": True, "profile": profile}, - lambda _d: f"[green]Authenticated[/green] on profile '{escape(profile)}'.", + lambda _d: f"[aai.success]Authenticated[/aai.success] on profile '{escape(profile)}'.", json_mode=json_mode, ) diff --git a/assemblyai_cli/commands/samples.py b/assemblyai_cli/commands/samples.py index 3733840b..a96aac97 100644 --- a/assemblyai_cli/commands/samples.py +++ b/assemblyai_cli/commands/samples.py @@ -82,7 +82,7 @@ def body(state: AppState, json_mode: bool) -> None: {"created": str(target)}, lambda d: ( f"Created {escape(d['created'])}\n" - f"[yellow]Note:[/yellow] this file contains your API key — do not commit it.\n" + f"[aai.warn]Note:[/aai.warn] this file contains your API key — do not commit it.\n" f"Run it with: python {escape(d['created'])}" ), json_mode=json_mode, diff --git a/tests/test_claude_render.py b/tests/test_claude_render.py new file mode 100644 index 00000000..2e66deed --- /dev/null +++ b/tests/test_claude_render.py @@ -0,0 +1,21 @@ +import io + +from assemblyai_cli import theme +from assemblyai_cli.commands.claude import _render_steps + + +def test_render_steps_colors_status(): + data = { + "steps": [ + {"name": "mcp", "status": "installed", "detail": "/path"}, + {"name": "skill", "status": "failed", "detail": "nope"}, + ] + } + rendered = _render_steps(data) + buf = io.StringIO() + console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor") + console.print(rendered) + out = buf.getvalue() + assert "installed" in out + assert "failed" in out + assert "\x1b[" in out # statuses are colored From 0b224a272960c223464e70b34570db019f0015b9 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:34:11 -0700 Subject: [PATCH 25/87] test(theme): assert semantic status style tags in claude setup steps Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_claude_render.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_claude_render.py b/tests/test_claude_render.py index 2e66deed..efa86cdf 100644 --- a/tests/test_claude_render.py +++ b/tests/test_claude_render.py @@ -12,10 +12,16 @@ def test_render_steps_colors_status(): ] } rendered = _render_steps(data) + # The markup string carries the semantic style tags per status... + assert "[aai.success]installed[/aai.success]" in rendered + assert "[aai.error]failed[/aai.error]" in rendered + assert "[aai.heading]" in rendered + # ...and renders to real ANSI through the themed console. buf = io.StringIO() console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor") console.print(rendered) out = buf.getvalue() assert "installed" in out assert "failed" in out - assert "\x1b[" in out # statuses are colored + assert "\x1b[32m" in out # aai.success (green) → "installed" + assert "\x1b[1;31m" in out # aai.error (bold red) → "failed" From c4fd04508f5c61339d9b65151cf1a508788d1db6 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:35:19 -0700 Subject: [PATCH 26/87] =?UTF-8?q?fix(stream,agent):=20show=20"Listening?= =?UTF-8?q?=E2=80=A6"=20only=20once=20the=20mic=20is=20recording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Listening…" notice fired on the streaming session's Begin event, before the mic device was lazily opened — so early speech fell into the gap. Gate it on the mic actually opening via a MicrophoneSource on_open callback, and emit it only for live mic input (file/URL/YouTube sources show no notice). For the voice agent, suppress the "start talking" prompt on file-driven runs, which have no mic. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/agent/render.py | 9 ++++- assemblyai_cli/commands/agent.py | 2 +- assemblyai_cli/commands/stream.py | 10 ++++-- assemblyai_cli/microphone.py | 6 ++++ assemblyai_cli/streaming/render.py | 7 +++- tests/test_agent_command.py | 44 ++++++++++++++++++++++++ tests/test_agent_render.py | 16 +++++++++ tests/test_microphone.py | 26 ++++++++++++++ tests/test_stream_command.py | 55 ++++++++++++++++++++++++++++++ tests/test_streaming_render.py | 25 +++++++++++--- 10 files changed, 191 insertions(+), 9 deletions(-) diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index febead7b..2859bd91 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from rich.text import Text from assemblyai_cli.render import BaseRenderer @@ -16,11 +18,16 @@ class AgentRenderer(BaseRenderer): Audio payloads are never written; only text/state events are surfaced. """ + def __init__(self, *, mic_input: bool = True, **kwargs: Any) -> None: + super().__init__(**kwargs) + # File-driven runs have no mic, so they skip the "start talking" prompt. + self.mic_input = mic_input + # --- lifecycle --------------------------------------------------------- def connected(self) -> None: if self.json_mode: self._emit({"type": "session.ready"}) - else: + elif self.mic_input: self._line(Text("Connected — start talking. (Ctrl-C to stop)", style="aai.muted")) def notice(self, text: str) -> None: diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index e9c62335..56f58790 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -70,7 +70,7 @@ def body(state: AppState, json_mode: bool) -> None: else: system_prompt_text = system_prompt - renderer = AgentRenderer(json_mode=json_mode) + renderer = AgentRenderer(json_mode=json_mode, mic_input=not from_file) audio: Any player: Any if from_file: diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 61ba162b..99bb29df 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -24,7 +24,9 @@ def stream( ), sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."), sample_rate: int | None = typer.Option( - None, "--sample-rate", help="Force a microphone capture rate in Hz (default: device native)." + None, + "--sample-rate", + help="Force a microphone capture rate in Hz (default: device native).", ), device: int | None = typer.Option(None, "--device", help="Microphone device index."), prompt: str = typer.Option( @@ -108,7 +110,11 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: else: # Capture at the device's native rate (or --sample-rate override) and tell # the streaming API that rate, rather than forcing one the device may reject. - mic = MicrophoneSource(device=device, capture_rate=sample_rate) + # Announce "Listening…" only once the device is open and recording, + # not when the session opens — so early speech isn't lost in the gap. + mic = MicrophoneSource( + device=device, capture_rate=sample_rate, on_open=renderer.listening + ) run(mic, mic.sample_rate) run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py index 20df3747..c482f330 100644 --- a/assemblyai_cli/microphone.py +++ b/assemblyai_cli/microphone.py @@ -102,9 +102,13 @@ def __init__( capture_rate: int | None = None, stream_factory: Callable[..., Iterator[bytes]] | None = None, rate_query: Callable[[int | None], int] | None = None, + on_open: Callable[[], None] | None = None, ) -> None: self.device = device self.target_rate = target_rate + # Fired once the device is open and capturing, so callers only announce + # "listening" when the mic is truly recording — not when the session opens. + self._on_open = on_open self._factory = stream_factory or _default_mic_stream query = rate_query or _device_default_rate self._capture_rate = capture_rate if capture_rate is not None else query(device) @@ -122,6 +126,8 @@ def __iter__(self) -> Iterator[bytes]: error_type="mic_error", exit_code=1, ) from exc + if self._on_open is not None: + self._on_open() # the device is open and recording now close = getattr(stream, "close", None) state: Any = None try: diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index 94300cc9..3906744c 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -9,9 +9,14 @@ class StreamRenderer(BaseRenderer): """Renders streaming events: a live-updating line for humans, NDJSON for agents.""" def begin(self, event: object) -> None: + # The "Listening…" notice waits for the mic (see listening()); opening the + # session only emits the protocol event for JSON consumers. if self.json_mode: self._emit({"type": "begin", "id": getattr(event, "id", None)}) - else: + + def listening(self) -> None: + """Announce capture has started — called once the mic is open and recording.""" + if not self.json_mode: self._line(Text("Listening… (Ctrl-C to stop)", style="aai.muted")) def turn(self, event: object) -> None: diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 73035add..49cdb22e 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -203,3 +203,47 @@ def test_agent_file_source_no_headphones_notice(monkeypatch, tmp_path): result = runner.invoke(app, ["agent", str(wav)]) assert result.exit_code == 0 assert "headphones" not in result.output.lower() # mic-only note; file mode is silent + + +def test_agent_file_source_no_start_talking_notice(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") + + def fake_run_session(api_key, *, renderer, **kwargs): + renderer.connected() # session.ready arrives even for a file-driven run + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + wav = tmp_path / "say.wav" + wav.write_bytes(b"RIFF") + result = runner.invoke(app, ["agent", str(wav)]) + assert result.exit_code == 0 + # No mic on a file-driven run -> no "start talking" prompt. + assert "start talking" not in result.output.lower() + + +def test_agent_mic_shows_start_talking_notice(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + + # Avoid opening real audio hardware; the renderer is what we're testing. + class FakeDuplex: + def __init__(self, **kwargs): + self.mic = iter([]) + self.player = self + + def start(self): + pass + + def close(self): + pass + + monkeypatch.setattr("assemblyai_cli.commands.agent.DuplexAudio", FakeDuplex) + + def fake_run_session(api_key, *, renderer, **kwargs): + renderer.connected() + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + result = runner.invoke(app, ["agent"]) + assert result.exit_code == 0 + assert "start talking" in result.output.lower() # live mic -> prompt the user to speak diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 751c9151..233b0fe0 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -127,6 +127,22 @@ def test_human_connected_and_stopped_announce(): assert "Stopped." in out +def test_human_connected_silent_without_mic_input(): + # File-driven runs have no mic, so the "start talking" prompt is suppressed. + buf = io.StringIO() + console = theme.make_console(file=buf, force_terminal=True, width=80) + r = AgentRenderer(json_mode=False, mic_input=False, out=buf, console=console) + r.connected() + assert "start talking" not in buf.getvalue().lower() + + +def test_json_connected_still_emits_ready_without_mic_input(): + # The protocol event is independent of the human prompt. + buf = io.StringIO() + AgentRenderer(json_mode=True, mic_input=False, out=buf).connected() + assert {"type": "session.ready"} in _json_lines(buf) + + def test_human_agent_label_is_colored(): r, buf = _human(color_system="truecolor") r.agent_transcript("the time is noon", interrupted=False) diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 554700d4..968a4eb9 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -113,6 +113,32 @@ def test_plain_iterator_without_close_is_fine(): assert list(mic) == [b"z"] +def test_on_open_fires_once_after_device_opens(): + events = [] + mic = MicrophoneSource( + capture_rate=16000, + stream_factory=lambda **_k: iter([b"x", b"y"]), + on_open=lambda: events.append("open"), + ) + assert events == [] # not signaled until iteration opens the device + assert list(mic) == [b"x", b"y"] + assert events == ["open"] # fired exactly once, when the mic became live + + +def test_on_open_not_called_when_device_fails_to_open(): + events = [] + + def boom(**_k): + raise OSError("no input device") + + mic = MicrophoneSource( + capture_rate=16000, stream_factory=boom, on_open=lambda: events.append("open") + ) + with pytest.raises(CLIError): + list(mic) + assert events == [] # never claimed "listening" because recording never started + + def test_rate_query_resolves_capture_rate_when_not_given(): seen = {} diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 6600010f..fdac54f0 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -59,6 +59,61 @@ def fake_stream_audio( assert seen["rate"] == 16000 +def test_stream_mic_listening_notice_waits_for_mic_open(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + + captured = {} + + class FakeMic: + def __init__(self, *, device=None, capture_rate=None, on_open=None): + captured["on_open"] = on_open + self.sample_rate = 16000 + + def __iter__(self): + captured["on_open"]() # the SDK iterating us == the mic is now live + return iter([b"\x00\x00"]) + + monkeypatch.setattr("assemblyai_cli.commands.stream.MicrophoneSource", FakeMic) + + order = [] + + def fake_stream_audio(api_key, source, *, sample_rate, on_begin=None, **_kwargs): + if on_begin: + on_begin(types.SimpleNamespace(id="x")) # Begin must NOT print "Listening…" + order.append("begin") + list(source) # consume the mic -> on_open fires -> "Listening…" prints + order.append("consumed") + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + result = runner.invoke(app, ["stream"]) + assert result.exit_code == 0 + assert "Listening" in result.output # shown once the mic opened + assert callable(captured["on_open"]) # wired to the renderer's listening notice + + +def test_stream_file_shows_no_listening_notice(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + + def fake(api_key, source, *, sample_rate, on_begin=None, **_kwargs): + if on_begin: + on_begin(types.SimpleNamespace(id="x")) + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + import wave + + p = tmp_path / "a.wav" + with wave.open(str(p), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + result = runner.invoke(app, ["stream", str(p)]) + assert result.exit_code == 0 + assert "Listening" not in result.output # no mic -> no listening notice + + def test_stream_unauthenticated_exits_2(): result = runner.invoke(app, ["stream"]) assert result.exit_code == 2 diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index 570ddefe..720cde98 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -37,10 +37,20 @@ def test_human_turn_shows_and_finalizes_text(): assert "hello world" in buf.getvalue() -def test_human_begin_prints_notice(): +def test_human_begin_is_silent_until_mic_opens(): + # The session opening (Begin) no longer prints "Listening…"; that waits for + # the mic to actually open and start recording (renderer.listening()). r, buf = _human() r.begin(types.SimpleNamespace(id="x")) - assert "Ctrl-C" in buf.getvalue() + assert buf.getvalue() == "" + + +def test_human_listening_prints_notice(): + r, buf = _human() + r.listening() + out = buf.getvalue() + assert "Listening" in out + assert "Ctrl-C" in out def test_human_long_partial_clears_wrapped_rows(): @@ -138,12 +148,19 @@ def flush(self): r.turn(_turn("hi", True)) # non-pipe write errors are non-fatal -def test_human_begin_notice_is_muted(): +def test_human_listening_notice_is_muted(): r, buf = _human(color_system="truecolor") - r.begin(types.SimpleNamespace(id="x")) + r.listening() assert "\x1b[" in buf.getvalue() # muted styling emits ANSI +def test_listening_is_silent_in_json_mode(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.listening() + assert out.getvalue() == "" # the "Listening…" line is human-only + + def test_human_llm_line_is_branded(): r, buf = _human(color_system="truecolor") r.turn(_turn("hola", True)) From 0da1951882f9694054a1824603a6619c3c756ea7 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:35:28 -0700 Subject: [PATCH 27/87] feat(cli): order --help commands by workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group top-level commands as core transcription → voice/LLM → account → tooling, with version last, via an OrderedGroup that controls list_commands (Typer otherwise renders direct commands before sub-typer groups). Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/main.py | 58 ++++++++++++++++++++++++++++++++++-------- tests/test_smoke.py | 23 +++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py index 8c9bb014..1e566a2f 100644 --- a/assemblyai_cli/main.py +++ b/assemblyai_cli/main.py @@ -1,6 +1,8 @@ from __future__ import annotations +import click import typer +from typer.core import TyperGroup from assemblyai_cli import __version__ from assemblyai_cli.commands import ( @@ -15,11 +17,44 @@ ) from assemblyai_cli.context import AppState +# The order commands appear under `aai --help`: core transcription first, then +# voice/LLM, then account, then tooling, with `version` last. Names not listed +# fall to the end, sorted alphabetically. +_COMMAND_ORDER = ( + "transcribe", + "stream", + "transcripts", + "agent", + "llm", + "login", + "logout", + "whoami", + "samples", + "claude", + "version", +) + + +class _OrderedGroup(TyperGroup): + """Lists commands in `_COMMAND_ORDER` rather than registration order. + + Typer renders all direct commands before sub-typer groups, so registration + order alone can't place `version` last; sorting here controls help output. + """ + + def list_commands(self, ctx: click.Context) -> list[str]: + rank = {name: i for i, name in enumerate(_COMMAND_ORDER)} + return sorted( + super().list_commands(ctx), key=lambda name: (rank.get(name, len(rank)), name) + ) + + app = typer.Typer( name="aai", help="Command-line interface for AssemblyAI.", no_args_is_help=True, add_completion=False, + cls=_OrderedGroup, ) @@ -31,17 +66,20 @@ def main( ctx.obj = AppState(profile=profile) -@app.command() -def version() -> None: - """Show the CLI version.""" - typer.echo(__version__) - - -app.add_typer(agent.app) -app.add_typer(llm.app) -app.add_typer(login.app) -app.add_typer(stream.app) +# Commands are registered in the order they should appear in `aai --help`: +# core transcription first, then voice/LLM, then account, then tooling. `version` +# is defined last so it sorts to the bottom (registration order is preserved). app.add_typer(transcribe.app) +app.add_typer(stream.app) app.add_typer(transcripts.app, name="transcripts") +app.add_typer(agent.app) +app.add_typer(llm.app) +app.add_typer(login.app) # login, logout, whoami app.add_typer(samples.app, name="samples") app.add_typer(claude.app, name="claude") + + +@app.command() +def version() -> None: + """Show the CLI version.""" + typer.echo(__version__) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 94284282..469d8464 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -28,3 +28,26 @@ def test_stream_registered_top_level(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "stream" in result.output + + +def test_help_lists_commands_in_workflow_order(): + import click + from typer.main import get_command + + cmd = get_command(app) + assert isinstance(cmd, click.Group) + names = cmd.list_commands(click.Context(cmd)) # the order shown under --help + # Core transcription first, then voice/LLM, account, tooling, version last. + assert names == [ + "transcribe", + "stream", + "transcripts", + "agent", + "llm", + "login", + "logout", + "whoami", + "samples", + "claude", + "version", + ] From c5a8873b43687af661c0a94e6007619566cab499 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:37:49 -0700 Subject: [PATCH 28/87] style: ruff format audio.py and test_microphone.py Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/agent/audio.py | 4 +--- tests/test_microphone.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index 35de8f80..918073ff 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -141,9 +141,7 @@ def close(self) -> None: pass -def _default_duplex_stream( - *, rate: int, blocksize: int, callback: Any, device: int | None -) -> Any: +def _default_duplex_stream(*, rate: int, blocksize: int, callback: Any, device: int | None) -> Any: """Open ONE started full-duplex sounddevice stream (mic + speaker together).""" try: import sounddevice as sd diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 968a4eb9..657eedb6 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -146,9 +146,7 @@ def fake_factory(*, sample_rate, device): seen["rate"] = sample_rate return iter([b"q"]) - mic = MicrophoneSource( - device=7, stream_factory=fake_factory, rate_query=lambda _device: 32000 - ) + mic = MicrophoneSource(device=7, stream_factory=fake_factory, rate_query=lambda _device: 32000) assert mic.sample_rate == 32000 assert list(mic) == [b"q"] assert seen["rate"] == 32000 From 9b310db2e987a7d5ced6bbba24b5b0564e5780c8 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:39:26 -0700 Subject: [PATCH 29/87] feat(config): add config_builder for SDK option merge/coercion --- assemblyai_cli/config_builder.py | 203 +++++++++++++++++++++++++++++++ tests/test_config_builder.py | 67 ++++++++++ 2 files changed, 270 insertions(+) create mode 100644 assemblyai_cli/config_builder.py create mode 100644 tests/test_config_builder.py diff --git a/assemblyai_cli/config_builder.py b/assemblyai_cli/config_builder.py new file mode 100644 index 00000000..57e73b11 --- /dev/null +++ b/assemblyai_cli/config_builder.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import assemblyai as aai +from assemblyai.streaming.v3 import StreamingParameters + +from assemblyai_cli.errors import UsageError + +# field name -> coercion kind for --config/--config-file string values. +# The KEYS are the authoritative set of valid config fields per command. +TRANSCRIBE_COERCE: dict[str, str] = { + "language_code": "str", + "language_codes": "list", + "punctuate": "bool", + "format_text": "bool", + "dual_channel": "bool", + "multichannel": "bool", + "webhook_url": "str", + "webhook_auth_header_name": "str", + "webhook_auth_header_value": "str", + "audio_start_from": "int", + "audio_end_at": "int", + "word_boost": "list", + "boost_param": "str", + "filter_profanity": "bool", + "redact_pii": "bool", + "redact_pii_audio": "bool", + "redact_pii_audio_quality": "str", + "redact_pii_audio_options": "json", + "redact_pii_policies": "list", + "redact_pii_sub": "str", + "redact_pii_return_unredacted": "bool", + "speaker_labels": "bool", + "speakers_expected": "int", + "speaker_options": "json", + "content_safety": "bool", + "content_safety_confidence": "int", + "iab_categories": "bool", + "custom_spelling": "json", + "disfluencies": "bool", + "sentiment_analysis": "bool", + "auto_chapters": "bool", + "entity_detection": "bool", + "summarization": "bool", + "summary_model": "str", + "summary_type": "str", + "auto_highlights": "bool", + "language_detection": "bool", + "language_confidence_threshold": "float", + "language_detection_options": "json", + "speech_threshold": "float", + "speech_model": "str", + "speech_models": "list", + "prompt": "str", + "temperature": "float", + "remove_audio_tags": "str", + "keyterms_prompt": "list", + "keyterms_prompt_options": "json", + "speech_understanding": "json", + "domain": "str", +} + +STREAM_COERCE: dict[str, str] = { + "end_of_turn_confidence_threshold": "float", + "min_end_of_turn_silence_when_confident": "int", + "min_turn_silence": "int", + "max_turn_silence": "int", + "vad_threshold": "float", + "format_turns": "bool", + "keyterms_prompt": "list", + "filter_profanity": "bool", + "prompt": "str", + "sample_rate": "int", + "encoding": "str", + "speech_model": "str", + "language_detection": "bool", + "domain": "str", + "inactivity_timeout": "int", + "webhook_url": "str", + "webhook_auth_header_name": "str", + "webhook_auth_header_value": "str", + "llm_gateway": "json", + "speaker_labels": "bool", + "max_speakers": "int", + "voice_focus": "str", + "voice_focus_threshold": "float", + "noise_suppression_model": "str", + "noise_suppression_threshold": "float", + "continuous_partials": "bool", + "customer_support_audio_capture": "bool", + "include_partial_turns": "bool", + "redact_pii": "bool", + "redact_pii_policies": "list", + "redact_pii_sub": "str", +} + +TRANSCRIBE_FIELDS = TRANSCRIBE_COERCE +STREAM_FIELDS = STREAM_COERCE + +_TRUE = {"1", "true", "yes", "on"} +_FALSE = {"0", "false", "no", "off"} + + +def coerce_value(field: str, raw: str) -> object: + """Coerce a string --config value to the type expected by `field`.""" + kind = TRANSCRIBE_COERCE.get(field) or STREAM_COERCE.get(field, "str") + if kind == "bool": + low = raw.strip().lower() + if low in _TRUE: + return True + if low in _FALSE: + return False + raise UsageError(f"{field} expects a boolean (true/false), got {raw!r}.") + if kind == "int": + try: + return int(raw) + except ValueError as exc: + raise UsageError(f"{field} expects an integer, got {raw!r}.") from exc + if kind == "float": + try: + return float(raw) + except ValueError as exc: + raise UsageError(f"{field} expects a number, got {raw!r}.") from exc + if kind == "list": + return [part.strip() for part in raw.split(",") if part.strip()] + if kind == "json": + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise UsageError(f"{field} expects a JSON value, got {raw!r}.") from exc + return raw + + +def parse_config_overrides(fields: dict[str, str], pairs: list[str]) -> dict[str, object]: + """Parse repeated KEY=VALUE strings into a coerced, validated dict.""" + out: dict[str, object] = {} + for pair in pairs: + if "=" not in pair: + raise UsageError(f"--config expects KEY=VALUE, got {pair!r}.") + key, raw = pair.split("=", 1) + key = key.strip() + if key not in fields: + valid = ", ".join(sorted(fields)) + raise UsageError(f"Unknown config field {key!r}. Valid fields: {valid}.") + out[key] = coerce_value(key, raw) + return out + + +def load_config_file(path: str | Path, fields: dict[str, str]) -> dict[str, object]: + """Load a JSON config file and validate its keys against `fields`.""" + try: + data = json.loads(Path(path).read_text()) + except FileNotFoundError as exc: + raise UsageError(f"Config file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise UsageError(f"Config file is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise UsageError("Config file must contain a JSON object.") + unknown = [k for k in data if k not in fields] + if unknown: + valid = ", ".join(sorted(fields)) + raise UsageError(f"Unknown config field(s) {unknown}. Valid fields: {valid}.") + return data + + +def _merge( + fields: dict[str, str], + flags: dict[str, object], + overrides: list[str], + config_file: str | None, +) -> dict[str, object]: + data: dict[str, object] = {} + if config_file: + data.update(load_config_file(config_file, fields)) + data.update(parse_config_overrides(fields, overrides)) + data.update({k: v for k, v in flags.items() if v is not None}) + return data + + +def build_transcription_config( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> aai.TranscriptionConfig: + merged = _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) + try: + return aai.TranscriptionConfig(**merged) + except UsageError: + raise + except Exception as exc: # noqa: BLE001 - surface SDK validation as a usage error + raise UsageError(f"Invalid transcription config: {exc}") from exc + + +def build_streaming_params( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> StreamingParameters: + merged = _merge(STREAM_FIELDS, flags, overrides, config_file) + try: + return StreamingParameters(**merged) + except UsageError: + raise + except Exception as exc: # noqa: BLE001 + raise UsageError(f"Invalid streaming config: {exc}") from exc diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py new file mode 100644 index 00000000..89f6f768 --- /dev/null +++ b/tests/test_config_builder.py @@ -0,0 +1,67 @@ +import json + +import pytest + +from assemblyai_cli import config_builder as cb +from assemblyai_cli.errors import UsageError + + +def test_coerce_bool_int_float_list(): + assert cb.coerce_value("speaker_labels", "true") is True + assert cb.coerce_value("speaker_labels", "false") is False + assert cb.coerce_value("speakers_expected", "2") == 2 + assert cb.coerce_value("speech_threshold", "0.5") == 0.5 + assert cb.coerce_value("redact_pii_policies", "person_name, phone_number") == [ + "person_name", + "phone_number", + ] + + +def test_coerce_str_passthrough_and_json(): + assert cb.coerce_value("language_code", "en_us") == "en_us" + assert cb.coerce_value("custom_spelling", '{"AssemblyAI": ["assembly ai"]}') == { + "AssemblyAI": ["assembly ai"] + } + + +def test_coerce_bad_bool_and_int_raise_usageerror(): + with pytest.raises(UsageError): + cb.coerce_value("speaker_labels", "maybe") + with pytest.raises(UsageError): + cb.coerce_value("speakers_expected", "two") + + +def test_parse_config_overrides_unknown_key_lists_valid(): + with pytest.raises(UsageError) as exc: + cb.parse_config_overrides(cb.TRANSCRIBE_FIELDS, ["not_a_field=1"]) + assert "not_a_field" in str(exc.value) + assert "speaker_labels" in str(exc.value) # error lists valid fields + + +def test_parse_config_overrides_requires_equals(): + with pytest.raises(UsageError): + cb.parse_config_overrides(cb.TRANSCRIBE_FIELDS, ["speaker_labels"]) + + +def test_build_transcription_config_layer_precedence(tmp_path): + cfg = tmp_path / "c.json" + cfg.write_text(json.dumps({"speaker_labels": False, "speakers_expected": 5})) + tc = cb.build_transcription_config( + flags={"speaker_labels": True}, # flag beats file + overrides=["speakers_expected=3"], # --config beats file + config_file=str(cfg), + ) + assert tc.speaker_labels is True + assert tc.raw.speakers_expected == 3 + + +def test_build_transcription_config_ignores_unset_flags(): + tc = cb.build_transcription_config(flags={"speaker_labels": None}, overrides=[], config_file=None) + assert tc.speaker_labels is None # None means "not set", does not override + + +def test_load_config_file_rejects_non_object(tmp_path): + bad = tmp_path / "bad.json" + bad.write_text("[1, 2, 3]") + with pytest.raises(UsageError): + cb.load_config_file(bad, cb.TRANSCRIBE_FIELDS) From 559db036e0541d2829f3f5a2ec0dd699eeef0a9d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:43:51 -0700 Subject: [PATCH 30/87] fix(config): satisfy ruff lint/format gates Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/config_builder.py | 5 +++-- tests/test_config_builder.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/assemblyai_cli/config_builder.py b/assemblyai_cli/config_builder.py index 57e73b11..f32da208 100644 --- a/assemblyai_cli/config_builder.py +++ b/assemblyai_cli/config_builder.py @@ -187,7 +187,8 @@ def build_transcription_config( return aai.TranscriptionConfig(**merged) except UsageError: raise - except Exception as exc: # noqa: BLE001 - surface SDK validation as a usage error + # surface SDK validation as a usage error + except Exception as exc: raise UsageError(f"Invalid transcription config: {exc}") from exc @@ -199,5 +200,5 @@ def build_streaming_params( return StreamingParameters(**merged) except UsageError: raise - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise UsageError(f"Invalid streaming config: {exc}") from exc diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index 89f6f768..24e1c668 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -56,7 +56,9 @@ def test_build_transcription_config_layer_precedence(tmp_path): def test_build_transcription_config_ignores_unset_flags(): - tc = cb.build_transcription_config(flags={"speaker_labels": None}, overrides=[], config_file=None) + tc = cb.build_transcription_config( + flags={"speaker_labels": None}, overrides=[], config_file=None + ) assert tc.speaker_labels is None # None means "not set", does not override From 678c7198ecf613ccca5c1269176753d01862bfa4 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:45:59 -0700 Subject: [PATCH 31/87] feat(config): add flag-normalization helpers + streaming build Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/config_builder.py | 47 +++++++++++++++++++++++++++++++- tests/test_config_builder.py | 35 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/assemblyai_cli/config_builder.py b/assemblyai_cli/config_builder.py index f32da208..281fdedc 100644 --- a/assemblyai_cli/config_builder.py +++ b/assemblyai_cli/config_builder.py @@ -4,7 +4,7 @@ from pathlib import Path import assemblyai as aai -from assemblyai.streaming.v3 import StreamingParameters +from assemblyai.streaming.v3 import SpeechModel, StreamingParameters from assemblyai_cli.errors import UsageError @@ -196,9 +196,54 @@ def build_streaming_params( *, flags: dict[str, object], overrides: list[str], config_file: str | None ) -> StreamingParameters: merged = _merge(STREAM_FIELDS, flags, overrides, config_file) + raw_model = merged.get("speech_model") + if isinstance(raw_model, str): + try: + merged["speech_model"] = SpeechModel[raw_model] + except KeyError: + try: + merged["speech_model"] = SpeechModel(raw_model) + except ValueError as exc: + raise UsageError(f"Invalid streaming config: {exc}") from exc try: return StreamingParameters(**merged) except UsageError: raise except Exception as exc: raise UsageError(f"Invalid streaming config: {exc}") from exc + + +def split_csv(value: str | None) -> list[str] | None: + """Split a comma-separated flag value into a list, or None if empty.""" + if not value: + return None + parts = [p.strip() for p in value.split(",") if p.strip()] + return parts or None + + +def parse_auth_header(value: str | None) -> tuple[str, str] | None: + """Parse a `NAME:VALUE` webhook auth header flag.""" + if value is None: + return None + if ":" not in value: + raise UsageError("--webhook-auth-header expects NAME:VALUE.") + name, header_value = value.split(":", 1) + return name.strip(), header_value.strip() + + +def load_custom_spelling(path: str) -> dict[str, object]: + """Load a custom-spelling JSON map (e.g. {"AssemblyAI": ["assembly ai"]}).""" + try: + data = json.loads(Path(path).read_text()) + except FileNotFoundError as exc: + raise UsageError(f"Custom spelling file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise UsageError(f"Custom spelling file is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise UsageError("Custom spelling file must contain a JSON object.") + return data + + +def translation_request(languages: list[str]) -> dict[str, object]: + """Build a Speech-Understanding translation payload for `speech_understanding`.""" + return {"request": {"translation": {"target_languages": list(languages)}}} diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index 24e1c668..0e3485fe 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -67,3 +67,38 @@ def test_load_config_file_rejects_non_object(tmp_path): bad.write_text("[1, 2, 3]") with pytest.raises(UsageError): cb.load_config_file(bad, cb.TRANSCRIBE_FIELDS) + + +def test_split_csv(): + assert cb.split_csv("a, b ,c") == ["a", "b", "c"] + assert cb.split_csv(None) is None + assert cb.split_csv("") is None + + +def test_parse_auth_header(): + assert cb.parse_auth_header("Authorization:Bearer x") == ("Authorization", "Bearer x") + assert cb.parse_auth_header(None) is None + with pytest.raises(UsageError): + cb.parse_auth_header("no-colon") + + +def test_load_custom_spelling(tmp_path): + p = tmp_path / "spell.json" + p.write_text('{"AssemblyAI": ["assembly ai", "assemblyai"]}') + assert cb.load_custom_spelling(str(p)) == {"AssemblyAI": ["assembly ai", "assemblyai"]} + + +def test_translation_request_shape(): + su = cb.translation_request(["es", "fr"]) + # target languages must be reachable from the payload regardless of dict/obj form. + assert "es" in json.dumps(su, default=lambda o: getattr(o, "__dict__", str(o))) + + +def test_build_streaming_params_minimal(): + sp = cb.build_streaming_params( + flags={"sample_rate": 16000, "speech_model": "universal_streaming_multilingual"}, + overrides=["max_turn_silence=400"], + config_file=None, + ) + assert sp.sample_rate == 16000 + assert sp.max_turn_silence == 400 From f90b6f4d17e18a761db8357beefba385c0169012 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 13:57:16 -0700 Subject: [PATCH 32/87] refactor(client): accept prebuilt TranscriptionConfig/StreamingParameters Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/client.py | 21 +---- assemblyai_cli/commands/stream.py | 9 +- assemblyai_cli/commands/transcribe.py | 9 +- tests/test_client.py | 113 +++++++++++++++++++------- tests/test_stream_command.py | 26 +++--- tests/test_transcribe.py | 4 +- 6 files changed, 115 insertions(+), 67 deletions(-) diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index dd6fb636..5518cf66 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -5,7 +5,6 @@ import assemblyai as aai from assemblyai.streaming.v3 import ( - SpeechModel, StreamingClient, StreamingClientOptions, StreamingEvents, @@ -60,11 +59,8 @@ def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object] return [item.model_dump(mode="json") for item in resp.transcripts] -def transcribe( - api_key: str, audio: str, *, speaker_labels: bool, prompt: str | None = None -) -> aai.Transcript: +def transcribe(api_key: str, audio: str, *, config: aai.TranscriptionConfig) -> aai.Transcript: _configure(api_key) - config = aai.TranscriptionConfig(speaker_labels=speaker_labels, prompt=prompt) try: transcript = aai.Transcriber().transcribe(audio, config=config) except APIError: @@ -98,17 +94,15 @@ def stream_audio( api_key: str, source: Iterable[bytes], *, - sample_rate: int, + params: StreamingParameters, on_begin: Callable[[Any], Any] | None = None, on_turn: Callable[[Any], Any] | None = None, on_termination: Callable[[Any], Any] | None = None, - prompt: str | None = None, - speech_model: SpeechModel = SpeechModel.u3_rt_pro, ) -> None: """Stream `source` (an iterable of PCM bytes) through the v3 realtime API. Forwards Begin/Turn/Termination events to the callbacks; raises APIError on a stream error. - `prompt` biases the speech model (the realtime `prompt` parameter). + `params` is a fully-built StreamingParameters (sample_rate/speech_model/etc). """ sc = StreamingClient( StreamingClientOptions(api_key=api_key, api_host="streaming.assemblyai.com") @@ -123,14 +117,7 @@ def stream_audio( sc.on(StreamingEvents.Error, lambda _client, error: errors.append(error)) try: - sc.connect( - StreamingParameters( - sample_rate=sample_rate, - format_turns=True, - speech_model=speech_model, - prompt=prompt, - ) - ) + sc.connect(params) except CLIError: raise except Exception as exc: diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 99bb29df..9f6482fb 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -4,6 +4,7 @@ from pathlib import Path import typer +from assemblyai.streaming.v3 import SpeechModel, StreamingParameters from assemblyai_cli import client, config, llm, youtube from assemblyai_cli.context import AppState, run_command @@ -73,11 +74,15 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: client.stream_audio( api_key, audio, - sample_rate=rate, + params=StreamingParameters( + sample_rate=rate, + format_turns=True, + speech_model=SpeechModel.universal_streaming_multilingual, + prompt=prompt, + ), on_begin=renderer.begin, on_turn=on_turn, on_termination=renderer.termination, - prompt=prompt, ) except KeyboardInterrupt: # Ctrl-C is a normal "user stopped" signal -> exit 0 (still transform below). diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index ef250ca0..d23b376a 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -3,6 +3,7 @@ import tempfile from pathlib import Path +import assemblyai as aai import typer from rich.markup import escape from rich.text import Text @@ -69,11 +70,15 @@ def body(state: AppState, json_mode: bool) -> None: with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: local = youtube.download_audio(audio, Path(td)) transcript = client.transcribe( - api_key, str(local), speaker_labels=speaker_labels, prompt=prompt + api_key, + str(local), + config=aai.TranscriptionConfig(speaker_labels=speaker_labels, prompt=prompt), ) else: transcript = client.transcribe( - api_key, audio, speaker_labels=speaker_labels, prompt=prompt + api_key, + audio, + config=aai.TranscriptionConfig(speaker_labels=speaker_labels, prompt=prompt), ) if llm_gateway_prompt: diff --git a/tests/test_client.py b/tests/test_client.py index 6091d9b3..ff363d45 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,6 +8,16 @@ from assemblyai_cli.errors import APIError +def _stream_params(sample_rate: int = 16000): + from assemblyai.streaming.v3 import SpeechModel, StreamingParameters + + return StreamingParameters( + sample_rate=sample_rate, + format_turns=True, + speech_model=SpeechModel.universal_streaming_multilingual, + ) + + def test_validate_key_true_on_success(): with patch.object(client.aai, "Transcriber") as T: T.return_value.list_transcripts.return_value = MagicMock() @@ -81,14 +91,11 @@ def test_transcribe_blocks_and_returns_transcript(): fake_transcriber = MagicMock() fake_transcriber.transcribe.return_value = fake_transcript - with ( - patch.object(client.aai, "Transcriber", return_value=fake_transcriber), - patch.object(client.aai, "TranscriptionConfig") as cfg, - ): - result = client.transcribe("sk", "audio.mp3", speaker_labels=True) + cfg = aai.TranscriptionConfig(speaker_labels=True) + with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): + result = client.transcribe("sk", "audio.mp3", config=cfg) - cfg.assert_called_once_with(speaker_labels=True, prompt=None) - fake_transcriber.transcribe.assert_called_once() + fake_transcriber.transcribe.assert_called_once_with("audio.mp3", config=cfg) assert result is fake_transcript @@ -100,12 +107,9 @@ def test_transcribe_raises_on_error_status(): fake_transcriber = MagicMock() fake_transcriber.transcribe.return_value = fake_transcript - with ( - patch.object(client.aai, "Transcriber", return_value=fake_transcriber), - patch.object(client.aai, "TranscriptionConfig"), - ): + with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): with pytest.raises(APIError) as exc: - client.transcribe("sk", "audio.mp3", speaker_labels=False) + client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) assert exc.value.transcript_id == "t_err" @@ -136,12 +140,9 @@ def test_get_transcript_auth_error_becomes_not_authenticated(): def test_transcribe_network_error_becomes_apierror(): fake_transcriber = MagicMock() fake_transcriber.transcribe.side_effect = RuntimeError("connection reset") - with ( - patch.object(client.aai, "Transcriber", return_value=fake_transcriber), - patch.object(client.aai, "TranscriptionConfig"), - ): + with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): with pytest.raises(APIError): - client.transcribe("sk", "audio.mp3", speaker_labels=False) + client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) def test_transcribe_auth_error_becomes_not_authenticated(): @@ -149,12 +150,9 @@ def test_transcribe_auth_error_becomes_not_authenticated(): fake_transcriber = MagicMock() fake_transcriber.transcribe.side_effect = RuntimeError("Invalid API key") - with ( - patch.object(client.aai, "Transcriber", return_value=fake_transcriber), - patch.object(client.aai, "TranscriptionConfig"), - ): + with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): with pytest.raises(NotAuthenticated): - client.transcribe("sk_bad", "audio.mp3", speaker_labels=False) + client.transcribe("sk_bad", "audio.mp3", config=aai.TranscriptionConfig()) class _FakeStreamingClient: @@ -189,7 +187,7 @@ def test_stream_audio_wires_handlers_and_streams(monkeypatch): monkeypatch.setattr(client, "StreamingClient", _FakeStreamingClient) turns = [] client.stream_audio( - "sk", [b"\x00"], sample_rate=16000, on_turn=lambda e: turns.append(e.transcript) + "sk", [b"\x00"], params=_stream_params(), on_turn=lambda e: turns.append(e.transcript) ) assert turns == ["hi"] assert _FakeStreamingClient.last.connected @@ -208,7 +206,7 @@ def stream(self, source): monkeypatch.setattr(client, "StreamingClient", ErrClient) with pytest.raises(APIError): - client.stream_audio("sk", [b"\x00"], sample_rate=16000) + client.stream_audio("sk", [b"\x00"], params=_stream_params()) def test_stream_audio_forwards_termination(monkeypatch): @@ -225,7 +223,7 @@ def stream(self, source): client.stream_audio( "sk", [b"\x00"], - sample_rate=16000, + params=_stream_params(), on_termination=lambda e: seen.append(e.audio_duration_seconds), ) assert seen == [3.0] @@ -238,7 +236,7 @@ def connect(self, params): monkeypatch.setattr(client, "StreamingClient", ConnectFails) with pytest.raises(APIError): - client.stream_audio("sk", [b"\x00"], sample_rate=16000) + client.stream_audio("sk", [b"\x00"], params=_stream_params()) def test_stream_audio_connect_auth_error_becomes_not_authenticated(monkeypatch): @@ -250,7 +248,7 @@ def connect(self, params): monkeypatch.setattr(client, "StreamingClient", ConnectUnauthorized) with pytest.raises(NotAuthenticated): - client.stream_audio("sk_bad", [b"\x00"], sample_rate=16000) + client.stream_audio("sk_bad", [b"\x00"], params=_stream_params()) def test_stream_audio_auth_error_event_becomes_not_authenticated(monkeypatch): @@ -264,7 +262,7 @@ def stream(self, source): monkeypatch.setattr(client, "StreamingClient", AuthErrClient) with pytest.raises(NotAuthenticated): - client.stream_audio("sk_bad", [b"\x00"], sample_rate=16000) + client.stream_audio("sk_bad", [b"\x00"], params=_stream_params()) def test_stream_audio_mid_stream_error_becomes_apierror(monkeypatch): @@ -274,7 +272,7 @@ def stream(self, source): monkeypatch.setattr(client, "StreamingClient", StreamFails) with pytest.raises(APIError): - client.stream_audio("sk", [b"\x00"], sample_rate=16000) + client.stream_audio("sk", [b"\x00"], params=_stream_params()) assert StreamFails.last.disconnected # still disconnected in finally @@ -287,10 +285,63 @@ def stream(self, source): monkeypatch.setattr(client, "StreamingClient", StreamRaisesCLIError) with pytest.raises(CLIError) as exc: - client.stream_audio("sk", [b"\x00"], sample_rate=16000) + client.stream_audio("sk", [b"\x00"], params=_stream_params()) assert exc.value.exit_code == 2 # not rewrapped into APIError +def test_transcribe_passes_prebuilt_config(monkeypatch): + import assemblyai as aai + + from assemblyai_cli import client + + captured = {} + + class FakeTranscriber: + def transcribe(self, audio, config=None): + captured["audio"] = audio + captured["config"] = config + t = MagicMock() + t.status = aai.TranscriptStatus.completed + return t + + monkeypatch.setattr(aai, "Transcriber", lambda: FakeTranscriber()) + cfg = aai.TranscriptionConfig(speaker_labels=True) + client.transcribe("sk", "audio.mp3", config=cfg) + assert captured["audio"] == "audio.mp3" + assert captured["config"] is cfg + + +def test_stream_audio_accepts_params(monkeypatch): + from assemblyai.streaming.v3 import SpeechModel, StreamingParameters + + from assemblyai_cli import client + + captured = {} + + class FakeSC: + def __init__(self, *a, **k): + pass + + def on(self, *a, **k): + pass + + def connect(self, params): + captured["params"] = params + + def stream(self, source): + pass + + def disconnect(self, terminate=True): + pass + + monkeypatch.setattr("assemblyai_cli.client.StreamingClient", FakeSC) + params = StreamingParameters( + sample_rate=16000, speech_model=SpeechModel.universal_streaming_multilingual + ) + client.stream_audio("sk", iter([b""]), params=params) + assert captured["params"] is params + + def test_stream_audio_flushes_termination_on_disconnect(monkeypatch): class DeferredTermClient(_FakeStreamingClient): def stream(self, source): @@ -311,7 +362,7 @@ def disconnect(self, terminate=False): client.stream_audio( "sk", [b"\x00"], - sample_rate=16000, + params=_stream_params(), on_termination=lambda e: seen.append(e.audio_duration_seconds), ) assert seen == [5.0] diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index fdac54f0..ddcc3e00 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -10,7 +10,7 @@ def _drive_turns( - api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs + api_key, source, *, params, on_begin=None, on_turn=None, on_termination=None, **_kwargs ): # Simulate the streaming client driving the renderer callbacks. if on_begin: @@ -39,10 +39,10 @@ def test_stream_file_uses_filesource(monkeypatch, tmp_path): seen = {} def fake_stream_audio( - api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs + api_key, source, *, params, on_begin=None, on_turn=None, on_termination=None, **_kwargs ): seen["source_type"] = type(source).__name__ - seen["rate"] = sample_rate + seen["rate"] = params.sample_rate monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) import wave @@ -78,7 +78,7 @@ def __iter__(self): order = [] - def fake_stream_audio(api_key, source, *, sample_rate, on_begin=None, **_kwargs): + def fake_stream_audio(api_key, source, *, params, on_begin=None, **_kwargs): if on_begin: on_begin(types.SimpleNamespace(id="x")) # Begin must NOT print "Listening…" order.append("begin") @@ -96,7 +96,7 @@ def test_stream_file_shows_no_listening_notice(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - def fake(api_key, source, *, sample_rate, on_begin=None, **_kwargs): + def fake(api_key, source, *, params, on_begin=None, **_kwargs): if on_begin: on_begin(types.SimpleNamespace(id="x")) @@ -121,10 +121,10 @@ def test_stream_unauthenticated_exits_2(): def _capture_source(seen): def fake( - api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs + api_key, source, *, params, on_begin=None, on_turn=None, on_termination=None, **_kwargs ): seen["source"] = source - seen["rate"] = sample_rate + seen["rate"] = params.sample_rate return fake @@ -220,7 +220,7 @@ def test_stream_file_json_output(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") def fake( - api_key, source, *, sample_rate, on_begin=None, on_turn=None, on_termination=None, **_kwargs + api_key, source, *, params, on_begin=None, on_turn=None, on_termination=None, **_kwargs ): if on_turn: on_turn(types.SimpleNamespace(transcript="from file", end_of_turn=True)) @@ -242,7 +242,7 @@ def test_stream_prompt_transforms_accumulated_transcript(monkeypatch): config.set_api_key("default", "sk_live") seen = {} - def fake(api_key, source, *, sample_rate, on_turn=None, **kwargs): + def fake(api_key, source, *, params, on_turn=None, **kwargs): if on_turn: on_turn(types.SimpleNamespace(transcript="hola", end_of_turn=True)) on_turn(types.SimpleNamespace(transcript="mundo", end_of_turn=True)) @@ -283,7 +283,7 @@ def test_stream_without_prompt_does_not_transform(monkeypatch): config.set_api_key("default", "sk_live") called = {"ran": False} - def fake(api_key, source, *, sample_rate, on_turn=None, **kwargs): + def fake(api_key, source, *, params, on_turn=None, **kwargs): if on_turn: on_turn(types.SimpleNamespace(transcript="hi", end_of_turn=True)) @@ -302,8 +302,8 @@ def test_stream_prompt_biases_speech_model(monkeypatch): config.set_api_key("default", "sk_live") seen = {} - def fake(api_key, source, *, sample_rate, prompt=None, **kwargs): - seen["prompt"] = prompt + def fake(api_key, source, *, params, **kwargs): + seen["prompt"] = params.prompt monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) result = runner.invoke(app, ["stream", "--prompt", "expect crypto jargon", "--json"]) @@ -327,7 +327,7 @@ def test_stream_youtube_url_downloads_then_streams(monkeypatch, tmp_path): ) seen = {} - def fake_stream(api_key, source, *, sample_rate, **kwargs): + def fake_stream(api_key, source, *, params, **kwargs): seen["source_type"] = type(source).__name__ seen["src"] = getattr(source, "source", None) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 77df8871..7b537272 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -77,7 +77,7 @@ def test_transcribe_passes_speaker_labels(): "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) - assert tx.call_args.kwargs["speaker_labels"] is True + assert tx.call_args.kwargs["config"].speaker_labels is True def test_transcribe_json_output(): @@ -176,7 +176,7 @@ def test_transcribe_prompt_biases_speech_model(): result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "expect medical terms"]) assert result.exit_code == 0 # --prompt is the speech-model prompt, forwarded to the transcription call. - assert tx.call_args.kwargs["prompt"] == "expect medical terms" + assert tx.call_args.kwargs["config"].prompt == "expect medical terms" def test_render_transcript_colors_speaker_labels(): From cee88637e387918069eec0603212891d1d115d45 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:01:55 -0700 Subject: [PATCH 33/87] feat(render): add transcribe analysis section renderers Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/transcribe_render.py | 92 +++++++++++++++++++++++++++++ tests/test_transcribe_render.py | 63 ++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 assemblyai_cli/transcribe_render.py create mode 100644 tests/test_transcribe_render.py diff --git a/assemblyai_cli/transcribe_render.py b/assemblyai_cli/transcribe_render.py new file mode 100644 index 00000000..6859e6d7 --- /dev/null +++ b/assemblyai_cli/transcribe_render.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections import Counter + +from rich.console import Console + + +def _fmt_ms(ms: int) -> str: + total = int(ms) // 1000 + return f"{total // 60:02d}:{total % 60:02d}" + + +def _enum_value(obj: object) -> str: + return str(getattr(obj, "value", obj)) + + +def render_transcript_result(transcript: object, console: Console) -> None: + """Print the transcript text, then a section per analysis feature present.""" + console.print(getattr(transcript, "text", "") or "") + _render_summary(transcript, console) + _render_chapters(transcript, console) + _render_highlights(transcript, console) + _render_sentiment(transcript, console) + _render_entities(transcript, console) + _render_topics(transcript, console) + _render_content_safety(transcript, console) + + +def _render_summary(transcript: object, console: Console) -> None: + summary = getattr(transcript, "summary", None) + if summary: + console.print("\n[bold]Summary:[/bold]") + console.print(str(summary)) + + +def _render_chapters(transcript: object, console: Console) -> None: + chapters = getattr(transcript, "chapters", None) + if not chapters: + return + console.print("\n[bold]Chapters:[/bold]") + for ch in chapters: + span = f"{_fmt_ms(ch.start)}-{_fmt_ms(ch.end)}" + console.print(f" {span} {ch.headline}") + + +def _render_highlights(transcript: object, console: Console) -> None: + highlights = getattr(transcript, "auto_highlights", None) + results = getattr(highlights, "results", None) if highlights else None + if not results: + return + console.print("\n[bold]Highlights:[/bold]") + for h in results: + console.print(f" ({h.count}x) {h.text}") + + +def _render_sentiment(transcript: object, console: Console) -> None: + results = getattr(transcript, "sentiment_analysis", None) + if not results: + return + counts = Counter(_enum_value(r.sentiment).lower() for r in results) + total = sum(counts.values()) or 1 + parts = [f"{pct * 100 // total}% {label}" for label, pct in counts.items()] + console.print("\n[bold]Sentiment:[/bold] " + ", ".join(parts)) + + +def _render_entities(transcript: object, console: Console) -> None: + entities = getattr(transcript, "entities", None) + if not entities: + return + console.print("\n[bold]Entities:[/bold]") + for ent in entities: + console.print(f" {_enum_value(ent.entity_type)}: {ent.text}") + + +def _render_topics(transcript: object, console: Console) -> None: + iab = getattr(transcript, "iab_categories", None) + summary = getattr(iab, "summary", None) if iab else None + if not summary: + return + console.print("\n[bold]Topics:[/bold]") + for label, relevance in sorted(summary.items(), key=lambda kv: kv[1], reverse=True): + console.print(f" {label} ({float(relevance):.2f})") + + +def _render_content_safety(transcript: object, console: Console) -> None: + safety = getattr(transcript, "content_safety", None) + summary = getattr(safety, "summary", None) if safety else None + if not summary: + return + console.print("\n[bold]Content Safety:[/bold]") + for label, confidence in sorted(summary.items(), key=lambda kv: kv[1], reverse=True): + console.print(f" {_enum_value(label)} ({float(confidence):.2f})") diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py new file mode 100644 index 00000000..d72da5ee --- /dev/null +++ b/tests/test_transcribe_render.py @@ -0,0 +1,63 @@ +from types import SimpleNamespace + +from rich.console import Console + +from assemblyai_cli import transcribe_render as tr + + +def _render(transcript) -> str: + console = Console(width=80, force_terminal=False) + with console.capture() as cap: + tr.render_transcript_result(transcript, console) + return cap.get() + + +def test_renders_text_only_when_no_analysis(): + out = _render(SimpleNamespace(text="hello world")) + assert "hello world" in out + assert "Summary" not in out + + +def test_renders_summary_and_chapters(): + transcript = SimpleNamespace( + text="t", + summary="A short summary.", + chapters=[SimpleNamespace(start=0, end=133000, headline="Intro", gist="i", summary="s")], + ) + out = _render(transcript) + assert "Summary:" in out + assert "A short summary." in out + assert "Chapters:" in out + assert "Intro" in out + assert "00:00" in out and "02:13" in out # 133000ms -> 02:13 + + +def test_renders_sentiment_aggregate(): + transcript = SimpleNamespace( + text="t", + sentiment_analysis=[ + SimpleNamespace(text="a", sentiment=SimpleNamespace(value="POSITIVE")), + SimpleNamespace(text="b", sentiment=SimpleNamespace(value="POSITIVE")), + SimpleNamespace(text="c", sentiment=SimpleNamespace(value="NEGATIVE")), + ], + ) + out = _render(transcript) + assert "Sentiment:" in out + assert "positive" in out.lower() + + +def test_renders_entities_topics_content_safety_highlights(): + transcript = SimpleNamespace( + text="t", + entities=[SimpleNamespace(entity_type=SimpleNamespace(value="person_name"), text="Ada")], + iab_categories=SimpleNamespace(summary={"Technology": 0.91}), + content_safety=SimpleNamespace(summary={"profanity": 0.4}), + auto_highlights=SimpleNamespace( + results=[SimpleNamespace(text="key phrase", count=3, rank=0.9)] + ), + ) + out = _render(transcript) + assert "Entities:" in out and "Ada" in out + assert "Topics:" in out and "Technology" in out + assert "Content Safety:" in out and "profanity" in out + assert "Highlights:" in out and "key phrase" in out From ecc05323891733e1536edbdccea50cd889eb31e8 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:05:02 -0700 Subject: [PATCH 34/87] chore(release): add PyPI publish-readiness gate to check.sh Build sdist+wheel and run `twine check --strict` as the final step of scripts/check.sh so CI fails before a broken package reaches PyPI. Fill in the project metadata the strict check requires: readme, license (MIT + LICENSE file), authors, keywords, classifiers, and project URLs. The README now renders as the PyPI long description. Co-Authored-By: Claude Opus 4.8 (1M context) --- LICENSE | 21 +++++++++++++++++++++ pyproject.toml | 24 ++++++++++++++++++++++++ scripts/check.sh | 7 +++++++ 3 files changed, 52 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..04c73a6f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AssemblyAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml index 03a484d6..4c1ae19d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,26 @@ build-backend = "hatchling.build" name = "assemblyai-cli" version = "0.1.0" description = "Command-line interface for AssemblyAI" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "AssemblyAI", email = "support@assemblyai.com" }] requires-python = ">=3.10" +keywords = ["assemblyai", "transcription", "speech-to-text", "cli", "audio", "streaming"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Sound/Audio :: Speech", + "Topic :: Software Development :: Libraries :: Python Modules", +] + dependencies = [ "typer>=0.12", "assemblyai>=0.34", @@ -20,6 +39,11 @@ dependencies = [ "yt-dlp>=2024.0", ] +[project.urls] +Homepage = "https://github.com/AssemblyAI/cli" +Repository = "https://github.com/AssemblyAI/cli" +Issues = "https://github.com/AssemblyAI/cli/issues" + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/scripts/check.sh b/scripts/check.sh index 47e334ba..83d02c0a 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -21,4 +21,11 @@ echo "==> pytest (with branch-coverage gate)" # a live API key + kokoro. Run them with: pytest -m e2e pytest -q -m "not e2e" --cov=assemblyai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +echo "==> build + twine check (PyPI publish readiness)" +# Build sdist + wheel into ./dist, then validate the metadata and README render +# the way PyPI requires. --strict fails on any warning (e.g. a missing readme). +rm -rf dist +uv build +uvx twine check --strict dist/* + echo "All checks passed." From 9d6bf4695e374bab82979006a0a6792966275062 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:08:33 -0700 Subject: [PATCH 35/87] feat(transcribe): expose full TranscriptionConfig via flags + escape hatch Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/transcribe.py | 213 +++++++++++++++++--------- tests/test_config_builder.py | 12 ++ tests/test_transcribe.py | 176 +++++++++++++-------- 3 files changed, 264 insertions(+), 137 deletions(-) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index d23b376a..3090f475 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -1,85 +1,164 @@ from __future__ import annotations -import tempfile -from pathlib import Path +import json -import assemblyai as aai import typer -from rich.markup import escape -from rich.text import Text -from assemblyai_cli import client, config, llm, output, theme, youtube +from assemblyai_cli import client, config, config_builder, llm, output, transcribe_render from assemblyai_cli.context import AppState, run_command app = typer.Typer() -def _utterances(transcript: object) -> list[dict[str, object]]: - """Speaker-labeled utterances ({speaker, text, start, end}), empty if none.""" - items = getattr(transcript, "utterances", None) or [] - return [{"speaker": u.speaker, "text": u.text, "start": u.start, "end": u.end} for u in items] - - -def _render_transcript(data: dict[str, object]) -> str | Text: - """Human view: speaker-labeled lines when diarized, otherwise the plain text.""" - utterances = data.get("utterances") - if isinstance(utterances, list) and utterances: - line = Text() - for i, u in enumerate(utterances): - if i: - line.append("\n") - line.append(f"Speaker {u['speaker']}: ", style=theme.speaker_style(u["speaker"])) - line.append(str(u["text"])) - return line - return escape(str(data["text"])) - - @app.command() def transcribe( ctx: typer.Context, - source: str = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."), + source: str = typer.Argument(None, help="Audio file path or public URL."), sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), + # model & language + speech_model: str = typer.Option(None, "--speech-model", help="best, nano, slam-1, universal."), + language_code: str = typer.Option( + None, "--language-code", help="Force a language (e.g. en_us)." + ), + language_detection: bool = typer.Option( + None, "--language-detection", help="Auto-detect the spoken language." + ), + keyterms_prompt: list[str] = typer.Option( + None, "--keyterms-prompt", help="Boost a key term (repeatable)." + ), + temperature: float = typer.Option(None, "--temperature", help="Speech model temperature."), + prompt: str = typer.Option(None, "--prompt", help="Bias the speech model (u3-pro)."), + # formatting + punctuate: bool = typer.Option(None, "--punctuate/--no-punctuate", help="Add punctuation."), + format_text: bool = typer.Option(None, "--format-text/--no-format-text", help="Format text."), + disfluencies: bool = typer.Option(None, "--disfluencies", help="Keep filler words."), + # speakers & channels speaker_labels: bool = typer.Option(False, "--speaker-labels", help="Enable diarization."), - prompt: str = typer.Option( - None, "--prompt", help="Bias the speech model with this prompt (u3-pro)." + speakers_expected: int = typer.Option(None, "--speakers-expected", help="Hint speaker count."), + multichannel: bool = typer.Option(None, "--multichannel", help="Transcribe each channel."), + # guardrails + redact_pii: bool = typer.Option(None, "--redact-pii", help="Redact PII from the transcript."), + redact_pii_policy: str = typer.Option( + None, "--redact-pii-policy", help="Comma-separated PII policies (e.g. person_name,...)." ), - llm_gateway_prompt: str = typer.Option( - None, - "--llm-gateway-prompt", - help="Transform the finished transcript through LLM Gateway with this instruction.", + redact_pii_sub: str = typer.Option( + None, "--redact-pii-sub", help="Substitution: hash or entity_name." + ), + redact_pii_audio: bool = typer.Option(None, "--redact-pii-audio", help="Also redact audio."), + filter_profanity: bool = typer.Option(None, "--filter-profanity", help="Mask profanity."), + content_safety: bool = typer.Option(None, "--content-safety", help="Detect sensitive content."), + content_safety_confidence: int = typer.Option( + None, "--content-safety-confidence", help="Confidence threshold 25-100." + ), + speech_threshold: float = typer.Option( + None, "--speech-threshold", help="Minimum speech proportion 0-1." + ), + # analysis + summarization: bool = typer.Option(None, "--summarization", help="Summarize the transcript."), + summary_model: str = typer.Option( + None, "--summary-model", help="informative/conversational/catchy." + ), + summary_type: str = typer.Option( + None, "--summary-type", help="bullets/gist/headline/paragraph." + ), + auto_chapters: bool = typer.Option(None, "--auto-chapters", help="Generate chapters."), + sentiment_analysis: bool = typer.Option( + None, "--sentiment-analysis", help="Analyze sentiment." ), - model: str = typer.Option( - llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --llm-gateway-prompt." + entity_detection: bool = typer.Option(None, "--entity-detection", help="Detect entities."), + auto_highlights: bool = typer.Option(None, "--auto-highlights", help="Detect key phrases."), + topic_detection: bool = typer.Option(None, "--topic-detection", help="Detect IAB topics."), + # customization + word_boost: list[str] = typer.Option(None, "--word-boost", help="Boost a word (repeatable)."), + custom_spelling_file: str = typer.Option( + None, "--custom-spelling-file", help="JSON map of custom spellings." ), - max_tokens: int = typer.Option( - llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the LLM Gateway transform." + audio_start: int = typer.Option(None, "--audio-start", help="Start offset in ms."), + audio_end: int = typer.Option(None, "--audio-end", help="End offset in ms."), + # webhooks + webhook_url: str = typer.Option(None, "--webhook-url", help="Webhook URL for completion."), + webhook_auth_header: str = typer.Option( + None, "--webhook-auth-header", help="Webhook auth header as NAME:VALUE." ), + # speech understanding + translate_to: list[str] = typer.Option( + None, "--translate-to", help="Translate transcript to a language (repeatable)." + ), + # escape hatch + config_kv: list[str] = typer.Option( + None, "--config", help="Set any TranscriptionConfig field as KEY=VALUE (repeatable)." + ), + config_file: str = typer.Option(None, "--config-file", help="JSON file of config fields."), + # llm gateway transform (existing) + llm_gateway_prompt: str = typer.Option( + None, "--llm-gateway-prompt", help="Transform the finished transcript through LLM Gateway." + ), + model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), + max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Transcribe an audio file, URL, or YouTube URL and print the result. + """Transcribe an audio file or URL with the full TranscriptionConfig surface. - --prompt biases the speech model. --llm-gateway-prompt transforms the - finished transcript through LLM Gateway (e.g. "summarize in three bullets"). + Curated flags cover common features; --config KEY=VALUE and --config-file reach + every other field. Analysis results (summary, chapters, sentiment, ...) render + automatically in human mode. """ def body(state: AppState, json_mode: bool) -> None: + flags: dict[str, object] = { + "speech_model": speech_model, + "language_code": language_code, + "language_detection": language_detection, + "keyterms_prompt": list(keyterms_prompt) if keyterms_prompt else None, + "temperature": temperature, + "prompt": prompt, + "punctuate": punctuate, + "format_text": format_text, + "disfluencies": disfluencies, + "speaker_labels": speaker_labels or None, + "speakers_expected": speakers_expected, + "multichannel": multichannel, + "redact_pii": redact_pii, + "redact_pii_policies": config_builder.split_csv(redact_pii_policy), + "redact_pii_sub": redact_pii_sub, + "redact_pii_audio": redact_pii_audio, + "filter_profanity": filter_profanity, + "content_safety": content_safety, + "content_safety_confidence": content_safety_confidence, + "speech_threshold": speech_threshold, + "summarization": summarization, + "summary_model": summary_model, + "summary_type": summary_type, + "auto_chapters": auto_chapters, + "sentiment_analysis": sentiment_analysis, + "entity_detection": entity_detection, + "auto_highlights": auto_highlights, + "iab_categories": topic_detection, + "word_boost": list(word_boost) if word_boost else None, + "custom_spelling": ( + config_builder.load_custom_spelling(custom_spelling_file) + if custom_spelling_file + else None + ), + "audio_start_from": audio_start, + "audio_end_at": audio_end, + "webhook_url": webhook_url, + "speech_understanding": ( + config_builder.translation_request(list(translate_to)) if translate_to else None + ), + } + header = config_builder.parse_auth_header(webhook_auth_header) + if header is not None: + flags["webhook_auth_header_name"] = header[0] + flags["webhook_auth_header_value"] = header[1] + + tc = config_builder.build_transcription_config( + flags=flags, overrides=list(config_kv or []), config_file=config_file + ) + audio = client.resolve_audio_source(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) - if youtube.is_youtube_url(audio): - # Fetch the audio first; AssemblyAI can't read a YouTube watch URL itself. - with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: - local = youtube.download_audio(audio, Path(td)) - transcript = client.transcribe( - api_key, - str(local), - config=aai.TranscriptionConfig(speaker_labels=speaker_labels, prompt=prompt), - ) - else: - transcript = client.transcribe( - api_key, - audio, - config=aai.TranscriptionConfig(speaker_labels=speaker_labels, prompt=prompt), - ) + transcript = client.transcribe(api_key, audio, config=tc) if llm_gateway_prompt: transformed = llm.transform_transcript( @@ -89,7 +168,6 @@ def body(state: AppState, json_mode: bool) -> None: transcript_id=transcript.id, max_tokens=max_tokens, ) - # Human mode shows just the transform; JSON keeps the raw transcript too. output.emit( { "id": transcript.id, @@ -101,22 +179,19 @@ def body(state: AppState, json_mode: bool) -> None: "output": transformed, }, }, - lambda d: escape(str(d["transform"]["output"])), + lambda d: str(d["transform"]["output"]), json_mode=json_mode, ) return - data: dict[str, object] = { - "id": transcript.id, - "status": client.status_str(transcript), - "text": transcript.text, - } - # Surface diarization: --speaker-labels asks for it, so render the per-speaker - # utterances instead of silently dropping them into the flat .text. - if speaker_labels: - utterances = _utterances(transcript) - if utterances: - data["utterances"] = utterances - output.emit(data, _render_transcript, json_mode=json_mode) + if json_mode: + payload = getattr(transcript, "json_response", None) or { + "id": transcript.id, + "status": client.status_str(transcript), + "text": transcript.text, + } + print(json.dumps(payload, default=str)) + else: + transcribe_render.render_transcript_result(transcript, output.console) run_command(ctx, body, json=json_out) diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index 0e3485fe..89a6de70 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -94,6 +94,18 @@ def test_translation_request_shape(): assert "es" in json.dumps(su, default=lambda o: getattr(o, "__dict__", str(o))) +def test_build_transcription_config_with_translate_payload(): + # The SDK must accept the translation payload for speech_understanding without raising. + tc = cb.build_transcription_config( + flags={"speech_understanding": cb.translation_request(["es", "fr"])}, + overrides=[], + config_file=None, + ) + assert "es" in json.dumps( + tc.raw.speech_understanding, default=lambda o: getattr(o, "__dict__", str(o)) + ) + + def test_build_streaming_params_minimal(): sp = cb.build_streaming_params( flags={"sample_rate": 16000, "speech_model": "universal_streaming_multilingual"}, diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 7b537272..d7fd0917 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -18,10 +18,24 @@ def _fake_transcript(): t.id = "t_1" t.text = "hello world" t.status = "completed" - t.utterances = [] + t.json_response = {"id": "t_1", "text": "hello world", "status": "completed"} + for attr in ( + "summary", + "chapters", + "auto_highlights", + "sentiment_analysis", + "entities", + "iab_categories", + "content_safety", + ): + setattr(t, attr, None) return t +def _enum_or_str(value): + return getattr(value, "value", value) + + def test_transcribe_sample_prints_text(): _auth() with patch( @@ -40,37 +54,6 @@ def test_transcribe_requires_source(): assert result.exit_code == 2 -def test_transcribe_speaker_labels_renders_utterances(monkeypatch): - import types - - _auth() - t = _fake_transcript() - t.utterances = [ - types.SimpleNamespace(speaker="A", text="hi there", start=0, end=500), - types.SimpleNamespace(speaker="B", text="hello back", start=500, end=900), - ] - # human mode -> "Speaker A: ..." lines - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) - assert result.exit_code == 0 - assert "Speaker A: hi there" in result.output - assert "Speaker B: hello back" in result.output - - -def test_transcribe_speaker_labels_json_includes_utterances(): - import types - - _auth() - t = _fake_transcript() - t.utterances = [types.SimpleNamespace(speaker="A", text="hi there", start=0, end=500)] - with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels", "--json"]) - assert result.exit_code == 0 - data = json.loads(result.output) - assert data["utterances"] == [{"speaker": "A", "text": "hi there", "start": 0, "end": 500}] - - def test_transcribe_passes_speaker_labels(): _auth() with patch( @@ -100,6 +83,7 @@ def test_transcribe_status_renders_enum_value(): _auth() t = _fake_transcript() t.status = aai.TranscriptStatus.completed + t.json_response = None with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) assert result.exit_code == 0 @@ -153,57 +137,113 @@ def test_transcribe_prompt_human_shows_only_transform(monkeypatch): assert "hello world" not in result.output # human mode shows the transform only -def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path): +def test_transcribe_prompt_biases_speech_model(): _auth() - fake = tmp_path / "vid.m4a" - fake.write_bytes(b"x") - monkeypatch.setattr( - "assemblyai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake - ) with patch( "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: - result = runner.invoke(app, ["transcribe", "https://youtu.be/abc", "--json"]) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "expect medical terms"]) assert result.exit_code == 0 - assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file + # --prompt is the speech-model prompt, forwarded to the transcription call. + assert tx.call_args.kwargs["config"].prompt == "expect medical terms" -def test_transcribe_prompt_biases_speech_model(): +def test_transcribe_maps_analysis_flags(): _auth() with patch( "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: - result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "expect medical terms"]) - assert result.exit_code == 0 - # --prompt is the speech-model prompt, forwarded to the transcription call. - assert tx.call_args.kwargs["config"].prompt == "expect medical terms" + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--summarization", + "--summary-type", + "bullets", + "--sentiment-analysis", + "--topic-detection", + ], + ) + cfg = tx.call_args.kwargs["config"] + assert cfg.raw.summarization is True + assert cfg.raw.summary_type == "bullets" + assert cfg.raw.sentiment_analysis is True + assert cfg.raw.iab_categories is True -def test_render_transcript_colors_speaker_labels(): - import io +def test_transcribe_redact_pii_policy_csv(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--redact-pii", + "--redact-pii-policy", + "person_name,phone_number", + ], + ) + cfg = tx.call_args.kwargs["config"] + assert cfg.raw.redact_pii is True + assert [_enum_or_str(p) for p in cfg.raw.redact_pii_policies] == [ + "person_name", + "phone_number", + ] - from assemblyai_cli import theme - from assemblyai_cli.commands.transcribe import _render_transcript - data = { - "text": "ignored when utterances present", - "utterances": [ - {"speaker": "A", "text": "hello", "start": 0, "end": 1}, - {"speaker": "B", "text": "hi there", "start": 1, "end": 2}, - ], - } - rendered = _render_transcript(data) - buf = io.StringIO() - console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor") - console.print(rendered) - out = buf.getvalue() - assert "Speaker A:" in out - assert "hello" in out - assert "Speaker B:" in out - assert "\x1b[" in out # speaker labels are styled +def test_transcribe_config_escape_hatch(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke(app, ["transcribe", "audio.mp3", "--config", "speech_threshold=0.5"]) + assert tx.call_args.kwargs["config"].raw.speech_threshold == 0.5 -def test_render_transcript_plain_text_unchanged(): - from assemblyai_cli.commands.transcribe import _render_transcript +def test_transcribe_unknown_config_field_exits_2(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--config", "bogus=1"]) + assert result.exit_code == 2 + assert "bogus" in result.output + + +def test_transcribe_webhook_auth_header(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--webhook-url", + "https://example.com/hook", + "--webhook-auth-header", + "X-Token:secret", + ], + ) + cfg = tx.call_args.kwargs["config"] + assert cfg.raw.webhook_url == "https://example.com/hook" + assert cfg.raw.webhook_auth_header_name == "X-Token" + assert cfg.raw.webhook_auth_header_value == "secret" + - assert _render_transcript({"text": "just the words"}) == "just the words" +def test_transcribe_renders_summary_human(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + t = _fake_transcript() + t.summary = "three bullet summary" + t.chapters = [] + with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--summarization"]) + assert result.exit_code == 0 + assert "Summary:" in result.output + assert "three bullet summary" in result.output From a972e3a54c9d01e7b3d637849b76d4774774143e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:16:22 -0700 Subject: [PATCH 36/87] fix(transcribe): restore YouTube URL support and per-speaker rendering Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/transcribe.py | 20 ++++++++++++++++++-- assemblyai_cli/transcribe_render.py | 20 +++++++++++++++++++- tests/test_transcribe.py | 16 ++++++++++++++++ tests/test_transcribe_render.py | 14 ++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 3090f475..f93fd36e 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -1,10 +1,20 @@ from __future__ import annotations import json +import tempfile +from pathlib import Path import typer -from assemblyai_cli import client, config, config_builder, llm, output, transcribe_render +from assemblyai_cli import ( + client, + config, + config_builder, + llm, + output, + transcribe_render, + youtube, +) from assemblyai_cli.context import AppState, run_command app = typer.Typer() @@ -158,7 +168,13 @@ def body(state: AppState, json_mode: bool) -> None: audio = client.resolve_audio_source(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) - transcript = client.transcribe(api_key, audio, config=tc) + if youtube.is_youtube_url(audio): + # Fetch the audio first; AssemblyAI can't read a YouTube watch URL itself. + with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: + local = youtube.download_audio(audio, Path(td)) + transcript = client.transcribe(api_key, str(local), config=tc) + else: + transcript = client.transcribe(api_key, audio, config=tc) if llm_gateway_prompt: transformed = llm.transform_transcript( diff --git a/assemblyai_cli/transcribe_render.py b/assemblyai_cli/transcribe_render.py index 6859e6d7..632da6ac 100644 --- a/assemblyai_cli/transcribe_render.py +++ b/assemblyai_cli/transcribe_render.py @@ -3,6 +3,9 @@ from collections import Counter from rich.console import Console +from rich.text import Text + +from assemblyai_cli import theme def _fmt_ms(ms: int) -> str: @@ -16,7 +19,7 @@ def _enum_value(obj: object) -> str: def render_transcript_result(transcript: object, console: Console) -> None: """Print the transcript text, then a section per analysis feature present.""" - console.print(getattr(transcript, "text", "") or "") + _render_text(transcript, console) _render_summary(transcript, console) _render_chapters(transcript, console) _render_highlights(transcript, console) @@ -26,6 +29,21 @@ def render_transcript_result(transcript: object, console: Console) -> None: _render_content_safety(transcript, console) +def _render_text(transcript: object, console: Console) -> None: + """Print per-speaker utterances when present, else the flat transcript text.""" + utterances = getattr(transcript, "utterances", None) + if isinstance(utterances, list) and utterances: + line = Text() + for i, u in enumerate(utterances): + if i: + line.append("\n") + line.append(f"Speaker {u.speaker}: ", style=theme.speaker_style(u.speaker)) + line.append(str(u.text)) + console.print(line) + return + console.print(getattr(transcript, "text", "") or "") + + def _render_summary(transcript: object, console: Console) -> None: summary = getattr(transcript, "summary", None) if summary: diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index d7fd0917..74ab9680 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -29,6 +29,7 @@ def _fake_transcript(): "content_safety", ): setattr(t, attr, None) + t.utterances = None return t @@ -236,6 +237,21 @@ def test_transcribe_webhook_auth_header(): assert cfg.raw.webhook_auth_header_value == "secret" +def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path): + _auth() + fake = tmp_path / "vid.m4a" + fake.write_bytes(b"x") + monkeypatch.setattr( + "assemblyai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake + ) + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + result = runner.invoke(app, ["transcribe", "https://youtu.be/abc", "--json"]) + assert result.exit_code == 0 + assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file + + def test_transcribe_renders_summary_human(monkeypatch): _auth() monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py index d72da5ee..c0100494 100644 --- a/tests/test_transcribe_render.py +++ b/tests/test_transcribe_render.py @@ -18,6 +18,20 @@ def test_renders_text_only_when_no_analysis(): assert "Summary" not in out +def test_renders_per_speaker_utterances(): + transcript = SimpleNamespace( + text="flat", + utterances=[ + SimpleNamespace(speaker="A", text="hello"), + SimpleNamespace(speaker="B", text="hi"), + ], + ) + out = _render(transcript) + assert "Speaker A: hello" in out + assert "Speaker B: hi" in out + assert "flat" not in out + + def test_renders_summary_and_chapters(): transcript = SimpleNamespace( text="t", From 7facf081bfe5bb33bde58d8ed8184e29bc3b683f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:22:22 -0700 Subject: [PATCH 37/87] fix(transcribe): advertise YouTube URLs in help; markup-safe text render Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/transcribe.py | 10 +++++----- assemblyai_cli/transcribe_render.py | 3 ++- tests/test_transcribe_render.py | 24 ++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index f93fd36e..1444d26c 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -23,7 +23,7 @@ @app.command() def transcribe( ctx: typer.Context, - source: str = typer.Argument(None, help="Audio file path or public URL."), + source: str = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."), sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), # model & language speech_model: str = typer.Option(None, "--speech-model", help="best, nano, slam-1, universal."), @@ -107,11 +107,11 @@ def transcribe( max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Transcribe an audio file or URL with the full TranscriptionConfig surface. + """Transcribe an audio file, URL, or YouTube URL with the full TranscriptionConfig surface. - Curated flags cover common features; --config KEY=VALUE and --config-file reach - every other field. Analysis results (summary, chapters, sentiment, ...) render - automatically in human mode. + A YouTube URL is downloaded first, then transcribed. Curated flags cover common + features; --config KEY=VALUE and --config-file reach every other field. Analysis + results (summary, chapters, sentiment, ...) render automatically in human mode. """ def body(state: AppState, json_mode: bool) -> None: diff --git a/assemblyai_cli/transcribe_render.py b/assemblyai_cli/transcribe_render.py index 632da6ac..297f63da 100644 --- a/assemblyai_cli/transcribe_render.py +++ b/assemblyai_cli/transcribe_render.py @@ -41,7 +41,8 @@ def _render_text(transcript: object, console: Console) -> None: line.append(str(u.text)) console.print(line) return - console.print(getattr(transcript, "text", "") or "") + # Wrap in Text so transcript content with [brackets] is not parsed as Rich markup. + console.print(Text(getattr(transcript, "text", "") or "")) def _render_summary(transcript: object, console: Console) -> None: diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py index c0100494..41013341 100644 --- a/tests/test_transcribe_render.py +++ b/tests/test_transcribe_render.py @@ -2,6 +2,7 @@ from rich.console import Console +from assemblyai_cli import theme from assemblyai_cli import transcribe_render as tr @@ -12,6 +13,29 @@ def _render(transcript) -> str: return cap.get() +def _render_styled(transcript) -> str: + # Themed console (as the CLI uses) + force_terminal so Rich emits ANSI to assert on. + console = theme.make_console(force_terminal=True, color_system="truecolor") + with console.capture() as cap: + tr.render_transcript_result(transcript, console) + return cap.get() + + +def test_per_speaker_lines_are_styled(): + transcript = SimpleNamespace( + text="flat", + utterances=[SimpleNamespace(speaker="A", text="hello")], + ) + out = _render_styled(transcript) + assert "\x1b[" in out # speaker label is color-styled, not plain text + + +def test_flat_text_with_brackets_is_not_parsed_as_markup(): + # Transcript content containing [tags] must render literally, not as Rich markup. + out = _render(SimpleNamespace(text="say [bold]hi[/bold] now")) + assert "[bold]hi[/bold]" in out + + def test_renders_text_only_when_no_analysis(): out = _render(SimpleNamespace(text="hello world")) assert "hello world" in out From 4312baca3da5bc673266d01ea57b446a332e790a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:25:36 -0700 Subject: [PATCH 38/87] feat(stream): expose full StreamingParameters via flags + escape hatch Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/stream.py | 110 +++++++++++++++++++++++++----- tests/test_stream_command.py | 62 +++++++++++++++++ 2 files changed, 155 insertions(+), 17 deletions(-) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 9f6482fb..2dca68e3 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -4,9 +4,9 @@ from pathlib import Path import typer -from assemblyai.streaming.v3 import SpeechModel, StreamingParameters +from assemblyai.streaming.v3 import SpeechModel -from assemblyai_cli import client, config, llm, youtube +from assemblyai_cli import client, config, config_builder, llm, youtube from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError from assemblyai_cli.microphone import MicrophoneSource @@ -15,6 +15,8 @@ app = typer.Typer() +DEFAULT_SPEECH_MODEL = SpeechModel.universal_streaming_multilingual.value + @app.command() def stream( @@ -30,23 +32,68 @@ def stream( help="Force a microphone capture rate in Hz (default: device native).", ), device: int | None = typer.Option(None, "--device", help="Microphone device index."), - prompt: str = typer.Option( - None, "--prompt", help="Bias the speech model with this prompt (u3-rt-pro)." + # model & input + speech_model: str = typer.Option( + DEFAULT_SPEECH_MODEL, "--speech-model", help="Streaming speech model." + ), + encoding: str = typer.Option(None, "--encoding", help="pcm_s16le or pcm_mulaw."), + language_detection: bool = typer.Option( + None, "--language-detection", help="Auto-detect the spoken language." + ), + domain: str = typer.Option(None, "--domain", help="Domain preset (e.g. medical)."), + # turn detection + end_of_turn_confidence_threshold: float = typer.Option( + None, "--end-of-turn-confidence-threshold", help="0-1 end-of-turn confidence." + ), + min_turn_silence: int = typer.Option(None, "--min-turn-silence", help="Min turn silence (ms)."), + max_turn_silence: int = typer.Option(None, "--max-turn-silence", help="Max turn silence (ms)."), + vad_threshold: float = typer.Option(None, "--vad-threshold", help="Voice-activity threshold."), + format_turns: bool = typer.Option( + None, "--format-turns/--no-format-turns", help="Punctuate/format finalized turns." + ), + include_partial_turns: bool = typer.Option( + None, "--include-partial-turns", help="Emit partial turns." + ), + # features + keyterms_prompt: list[str] = typer.Option( + None, "--keyterms-prompt", help="Boost a key term (repeatable)." + ), + filter_profanity: bool = typer.Option(None, "--filter-profanity", help="Mask profanity."), + speaker_labels: bool = typer.Option(None, "--speaker-labels", help="Label speakers."), + max_speakers: int = typer.Option(None, "--max-speakers", help="Max speakers."), + voice_focus: str = typer.Option(None, "--voice-focus", help="near_field or far_field."), + voice_focus_threshold: float = typer.Option( + None, "--voice-focus-threshold", help="Voice-focus threshold." + ), + redact_pii: bool = typer.Option(None, "--redact-pii", help="Redact PII from turns."), + redact_pii_policy: str = typer.Option( + None, "--redact-pii-policy", help="Comma-separated PII policies." ), + redact_pii_sub: str = typer.Option(None, "--redact-pii-sub", help="hash or entity_name."), + inactivity_timeout: int = typer.Option( + None, "--inactivity-timeout", help="Auto-close after N seconds idle." + ), + webhook_url: str = typer.Option(None, "--webhook-url", help="Webhook URL."), + webhook_auth_header: str = typer.Option( + None, "--webhook-auth-header", help="Webhook auth header as NAME:VALUE." + ), + # escape hatch + config_kv: list[str] = typer.Option( + None, "--config", help="Set any StreamingParameters field as KEY=VALUE (repeatable)." + ), + config_file: str = typer.Option(None, "--config-file", help="JSON file of streaming fields."), + # existing + prompt: str = typer.Option(None, "--prompt", help="Bias the speech model (u3-pro)."), llm_gateway_prompt: str = typer.Option( None, "--llm-gateway-prompt", help="After streaming, transform the full transcript through LLM Gateway.", ), - model: str = typer.Option( - llm.DEFAULT_MODEL, "--model", help="LLM Gateway model for --llm-gateway-prompt." - ), - max_tokens: int = typer.Option( - llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens for the LLM Gateway transform." - ), + model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), + max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), ) -> None: - """Transcribe live audio from the microphone, a file, a URL, or YouTube in real time. + """Transcribe live audio in real time with the full StreamingParameters surface. --prompt biases the speech model. --llm-gateway-prompt transforms the full transcript through LLM Gateway once the stream ends (e.g. "summarize the call"). @@ -70,16 +117,45 @@ def on_turn(event: object) -> None: turns.append(text) def run(audio: FileSource | MicrophoneSource, rate: int) -> None: + flags: dict[str, object] = { + "sample_rate": rate, + "speech_model": speech_model, + "format_turns": format_turns if format_turns is not None else True, + "encoding": encoding, + "language_detection": language_detection, + "domain": domain, + "end_of_turn_confidence_threshold": end_of_turn_confidence_threshold, + "min_turn_silence": min_turn_silence, + "max_turn_silence": max_turn_silence, + "vad_threshold": vad_threshold, + "include_partial_turns": include_partial_turns, + "keyterms_prompt": list(keyterms_prompt) if keyterms_prompt else None, + "filter_profanity": filter_profanity, + "speaker_labels": speaker_labels, + "max_speakers": max_speakers, + "voice_focus": voice_focus, + "voice_focus_threshold": voice_focus_threshold, + "redact_pii": redact_pii, + "redact_pii_policies": config_builder.split_csv(redact_pii_policy), + "redact_pii_sub": redact_pii_sub, + "inactivity_timeout": inactivity_timeout, + "webhook_url": webhook_url, + "prompt": prompt, + } + header = config_builder.parse_auth_header(webhook_auth_header) + if header is not None: + flags["webhook_auth_header_name"] = header[0] + flags["webhook_auth_header_value"] = header[1] + + params = config_builder.build_streaming_params( + flags=flags, overrides=list(config_kv or []), config_file=config_file + ) + try: client.stream_audio( api_key, audio, - params=StreamingParameters( - sample_rate=rate, - format_turns=True, - speech_model=SpeechModel.universal_streaming_multilingual, - prompt=prompt, - ), + params=params, on_begin=renderer.begin, on_turn=on_turn, on_termination=renderer.termination, diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index ddcc3e00..a4e87351 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -336,3 +336,65 @@ def fake_stream(api_key, source, *, params, **kwargs): assert result.exit_code == 0 assert seen["source_type"] == "FileSource" # streamed the downloaded local file assert seen["src"] == str(fake) + + +def test_stream_maps_turn_detection_flags(monkeypatch): + config.set_api_key("default", "sk_live") + captured = {} + + def fake_stream_audio(api_key, source, *, params, **kw): + captured["params"] = params + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + + runner.invoke( + app, + [ + "stream", + "--sample", + "--max-turn-silence", + "400", + "--filter-profanity", + "--speaker-labels", + ], + ) + params = captured["params"] + assert params.max_turn_silence == 400 + assert params.filter_profanity is True + assert params.speaker_labels is True + + +def test_stream_config_escape_hatch(monkeypatch): + config.set_api_key("default", "sk_live") + captured = {} + monkeypatch.setattr( + "assemblyai_cli.commands.stream.client.stream_audio", + lambda api_key, source, *, params, **kw: captured.update(params=params), + ) + + runner.invoke(app, ["stream", "--sample", "--config", "vad_threshold=0.7"]) + assert captured["params"].vad_threshold == 0.7 + + +def test_stream_maps_webhook_auth_header(monkeypatch): + config.set_api_key("default", "sk_live") + captured = {} + monkeypatch.setattr( + "assemblyai_cli.commands.stream.client.stream_audio", + lambda api_key, source, *, params, **kw: captured.update(params=params), + ) + + runner.invoke( + app, + [ + "stream", + "--sample", + "--webhook-url", + "https://example.com/hook", + "--webhook-auth-header", + "Authorization:Bearer xyz", + ], + ) + params = captured["params"] + assert params.webhook_auth_header_name == "Authorization" + assert params.webhook_auth_header_value == "Bearer xyz" From d2c8809811701adfd7c730bb056be36f9987fbb4 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:29:13 -0700 Subject: [PATCH 39/87] test(stream): pin --format-turns/--no-format-turns tristate Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_stream_command.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index a4e87351..b2ca1820 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -398,3 +398,18 @@ def test_stream_maps_webhook_auth_header(monkeypatch): params = captured["params"] assert params.webhook_auth_header_name == "Authorization" assert params.webhook_auth_header_value == "Bearer xyz" + + +def test_stream_format_turns_tristate(monkeypatch): + config.set_api_key("default", "sk_live") + captured = {} + monkeypatch.setattr( + "assemblyai_cli.commands.stream.client.stream_audio", + lambda api_key, source, *, params, **kw: captured.update(params=params), + ) + + runner.invoke(app, ["stream", "--sample"]) + assert captured["params"].format_turns is True # unset defaults to True + + runner.invoke(app, ["stream", "--sample", "--no-format-turns"]) + assert captured["params"].format_turns is False From 20be4dfc4f4c9004dbb906e3d45cc2216df95006 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:30:53 -0700 Subject: [PATCH 40/87] docs: document full SDK option flags and the --config escape hatch Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 61 +++++++++++++++++++++ assemblyai_cli/templates/stream.py.tmpl | 3 + assemblyai_cli/templates/transcribe.py.tmpl | 10 +++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c101d060..17607742 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,44 @@ output is piped or run by an agent). Auth problems surface as a clean > aai transcribe "https://www.youtube.com/watch?v=VIDEO_ID" > ``` +## Transcribe options + +`aai transcribe` exposes the full `TranscriptionConfig` surface as curated flags, +grouped by purpose: + +- **Model & language:** `--speech-model`, `--language-code`, `--language-detection`, + `--keyterms-prompt`, `--prompt`, `--temperature`. +- **Formatting:** `--punctuate` / `--no-punctuate`, `--format-text` / + `--no-format-text`, `--disfluencies`. +- **Speakers & channels:** `--speaker-labels`, `--speakers-expected`, + `--multichannel`. +- **Guardrails:** `--redact-pii`, `--redact-pii-policy`, `--redact-pii-sub`, + `--redact-pii-audio`, `--filter-profanity`, `--content-safety`, + `--content-safety-confidence`, `--speech-threshold`. +- **Analysis:** `--summarization` (`--summary-type`, `--summary-model`), + `--auto-chapters`, `--sentiment-analysis`, `--entity-detection`, + `--auto-highlights`, `--topic-detection`. Analysis results render automatically + in human mode (summary, chapters, sentiment, entities, topics, content safety, + highlights). +- **Customization:** `--word-boost`, `--custom-spelling-file`, `--audio-start`, + `--audio-end`, `--translate-to`. +- **Webhooks:** `--webhook-url`, `--webhook-auth-header` (`NAME:VALUE`). + +Anything without a curated flag is reachable through the escape hatch: +`--config KEY=VALUE` (repeatable) and `--config-file FILE` (a JSON object) accept +any SDK field by its exact name. Precedence is config file < `--config` < explicit +flags. + +```sh +aai transcribe call.mp3 \ + --speaker-labels --speakers-expected 2 \ + --redact-pii --redact-pii-policy person_name,phone_number \ + --summarization --summary-type bullets \ + --sentiment-analysis --auto-chapters \ + --config speech_threshold=0.5 \ + --config-file extra.json +``` + ## Streaming ```sh @@ -63,6 +101,29 @@ aai stream https://…/clip.mp3 # a URL works too (decoded via ffmpeg) aai stream # from the microphone; Ctrl-C to stop ``` +`aai stream` exposes the full `StreamingParameters` surface as curated flags: + +- **Model & input:** `--speech-model`, `--encoding`, `--language-detection`, + `--domain`. +- **Turn detection:** `--end-of-turn-confidence-threshold`, `--min-turn-silence`, + `--max-turn-silence`, `--vad-threshold`, `--format-turns` / `--no-format-turns`, + `--include-partial-turns`. +- **Features:** `--keyterms-prompt`, `--filter-profanity`, `--speaker-labels`, + `--max-speakers`, `--voice-focus`, `--voice-focus-threshold`, `--redact-pii`, + `--redact-pii-policy`, `--redact-pii-sub`, `--inactivity-timeout`, + `--webhook-url`, `--webhook-auth-header`. + +The same escape hatch applies — `--config KEY=VALUE` (repeatable) and +`--config-file FILE` (JSON object) reach any other `StreamingParameters` field, +with precedence config file < `--config` < explicit flags: + +```sh +aai stream --sample \ + --max-turn-silence 400 --format-turns \ + --keyterms-prompt "AssemblyAI" \ + --config vad_threshold=0.7 +``` + ## Voice agent Have a live, two-way voice conversation: diff --git a/assemblyai_cli/templates/stream.py.tmpl b/assemblyai_cli/templates/stream.py.tmpl index 9cbcfdfa..3a5f6a1b 100644 --- a/assemblyai_cli/templates/stream.py.tmpl +++ b/assemblyai_cli/templates/stream.py.tmpl @@ -28,6 +28,9 @@ client.connect( sample_rate=16000, format_turns=True, speech_model=SpeechModel.u3_rt_pro, + # Extra features (all optional): + # max_turn_silence=400, # tune end-of-turn detection (ms) + # filter_profanity=True, ) ) diff --git a/assemblyai_cli/templates/transcribe.py.tmpl b/assemblyai_cli/templates/transcribe.py.tmpl index 521206d9..597da7ea 100644 --- a/assemblyai_cli/templates/transcribe.py.tmpl +++ b/assemblyai_cli/templates/transcribe.py.tmpl @@ -2,8 +2,16 @@ import assemblyai as aai aai.settings.api_key = "{{API_KEY}}" +# TranscriptionConfig enables extra features. A few examples (all optional): +config = aai.TranscriptionConfig( + # speaker_labels=True, # diarize who said what + # summarization=True, summary_type="bullets", + # sentiment_analysis=True, + # auto_chapters=True, +) + transcriber = aai.Transcriber() -transcript = transcriber.transcribe("https://assembly.ai/wildfires.mp3") +transcript = transcriber.transcribe("https://assembly.ai/wildfires.mp3", config=config) if transcript.status == aai.TranscriptStatus.error: raise RuntimeError(transcript.error) From c2267cc11ff7b7599317ce61bcbfde83458c1653 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:35:20 -0700 Subject: [PATCH 41/87] test: exhaustive coercion property tests + analysis e2e Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/test_cli_e2e.py | 14 ++++++++++++++ tests/test_config_builder.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_properties.py | 25 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/tests/e2e/test_cli_e2e.py b/tests/e2e/test_cli_e2e.py index 027b6b4b..7838028a 100644 --- a/tests/e2e/test_cli_e2e.py +++ b/tests/e2e/test_cli_e2e.py @@ -149,6 +149,20 @@ def test_transcribe_prompt_transforms_via_gateway(real_api_key): assert data["transform"]["output"].strip(), f"gateway returned no transform: {data!r}" +def test_e2e_transcribe_analysis(real_api_key): + # Drives a full analysis run through the real API using the hosted --sample + # clip, so summarization + auto-chapters are exercised end to end. + proc = _run_cli( + ["transcribe", "--sample", "--summarization", "--auto-chapters", "--json"], + real_api_key, + timeout=180, + ) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + payload = json.loads(proc.stdout) + # The full transcript object is returned; at least one analysis field is present. + assert payload.get("summary") or payload.get("chapters"), f"no analysis fields: {payload!r}" + + def test_stream_prompt_transforms_at_end(real_api_key, kokoro_pipeline, tmp_path): spoken = "the quick brown fox jumps over the lazy dog" wav = _synthesize_wav(kokoro_pipeline, spoken, tmp_path / "fox.wav") diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index 89a6de70..b80eb015 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -114,3 +114,39 @@ def test_build_streaming_params_minimal(): ) assert sp.sample_rate == 16000 assert sp.max_turn_silence == 400 + + +@pytest.mark.parametrize( + "field,raw,expected,extra", + [ + ("punctuate", "false", False, []), + ("multichannel", "true", True, []), + ("audio_start_from", "1500", 1500, []), + ("temperature", "0.2", 0.2, []), + # summary_type is only applied by the SDK when summarization is enabled. + ("summary_type", "bullets", "bullets", ["summarization=true"]), + ("keyterms_prompt", "a,b", ["a", "b"], []), + ], +) +def test_transcribe_field_coercion_matrix(field, raw, expected, extra): + tc = cb.build_transcription_config( + flags={}, overrides=[f"{field}={raw}", *extra], config_file=None + ) + assert getattr(tc.raw, field) == expected + + +@pytest.mark.parametrize("field", sorted(cb.STREAM_FIELDS)) +def test_every_stream_field_is_a_valid_param(field): + # Each declared field must be a real StreamingParameters attribute. + from assemblyai.streaming.v3 import StreamingParameters + + assert field in StreamingParameters.model_fields + + +@pytest.mark.parametrize("field", sorted(cb.TRANSCRIBE_FIELDS)) +def test_every_transcribe_field_is_a_valid_param(field): + # Each declared field must be a real TranscriptionConfig request attribute. + import assemblyai as aai + + raw_cls = type(aai.TranscriptionConfig().raw) + assert field in raw_cls.model_fields diff --git a/tests/test_properties.py b/tests/test_properties.py index 475b3642..8ef02baa 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -8,6 +8,7 @@ from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st +from assemblyai_cli import config_builder as cb from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.streaming import sources from assemblyai_cli.streaming.render import StreamRenderer @@ -49,3 +50,27 @@ def test_wav_chunks_reassemble_and_stay_bounded(pcm, tmp_path): chunks = list(sources.FileSource(str(clip), sleep=lambda _s: None)) assert b"".join(chunks) == pcm # streamed audio is byte-exact assert all(len(c) <= sources.CHUNK_BYTES for c in chunks) # chunking respects the cap + + +# --- config-builder coercion round-trips ---------------------------------- + + +@given(value=st.integers(min_value=0, max_value=10_000_000)) +def test_int_coercion_roundtrips(value): + assert cb.coerce_value("speakers_expected", str(value)) == value + + +@given(value=st.lists(st.text(alphabet="abcdefghijklmnop", min_size=1, max_size=6), max_size=5)) +def test_list_coercion_roundtrips(value): + raw = ",".join(value) + assert cb.coerce_value("word_boost", raw) == [v for v in value if v] + + +@given(value=st.booleans()) +def test_bool_coercion_roundtrips(value): + assert cb.coerce_value("speaker_labels", str(value).lower()) is value + + +@given(value=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)) +def test_float_coercion_roundtrips(value): + assert cb.coerce_value("speech_threshold", repr(value)) == value From 815c4c58189f70ecda9f87bf8dec9295f5801741 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:51:21 -0700 Subject: [PATCH 42/87] refactor(config): split build_* into merge_* + construct_* helpers Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/config_builder.py | 40 +++++++++++++++++++++++------ tests/test_config_builder.py | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/assemblyai_cli/config_builder.py b/assemblyai_cli/config_builder.py index 281fdedc..257f197e 100644 --- a/assemblyai_cli/config_builder.py +++ b/assemblyai_cli/config_builder.py @@ -179,22 +179,35 @@ def _merge( return data -def build_transcription_config( +def merge_transcribe_config( *, flags: dict[str, object], overrides: list[str], config_file: str | None -) -> aai.TranscriptionConfig: - merged = _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) +) -> dict[str, object]: + """Merge config-file + --config overrides + curated flags into a kwargs dict.""" + return _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) + + +def construct_transcription_config(merged: dict[str, object]) -> aai.TranscriptionConfig: + """Build a TranscriptionConfig from a merged kwargs dict, surfacing errors as usage.""" try: return aai.TranscriptionConfig(**merged) except UsageError: raise - # surface SDK validation as a usage error - except Exception as exc: + except Exception as exc: # surface SDK validation as a usage error raise UsageError(f"Invalid transcription config: {exc}") from exc -def build_streaming_params( +def build_transcription_config( *, flags: dict[str, object], overrides: list[str], config_file: str | None -) -> StreamingParameters: +) -> aai.TranscriptionConfig: + return construct_transcription_config( + merge_transcribe_config(flags=flags, overrides=overrides, config_file=config_file) + ) + + +def merge_streaming_params( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> dict[str, object]: + """Merge streaming config into a kwargs dict, coercing speech_model to a SpeechModel.""" merged = _merge(STREAM_FIELDS, flags, overrides, config_file) raw_model = merged.get("speech_model") if isinstance(raw_model, str): @@ -205,6 +218,11 @@ def build_streaming_params( merged["speech_model"] = SpeechModel(raw_model) except ValueError as exc: raise UsageError(f"Invalid streaming config: {exc}") from exc + return merged + + +def construct_streaming_params(merged: dict[str, object]) -> StreamingParameters: + """Build StreamingParameters from a merged kwargs dict, surfacing errors as usage.""" try: return StreamingParameters(**merged) except UsageError: @@ -213,6 +231,14 @@ def build_streaming_params( raise UsageError(f"Invalid streaming config: {exc}") from exc +def build_streaming_params( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> StreamingParameters: + return construct_streaming_params( + merge_streaming_params(flags=flags, overrides=overrides, config_file=config_file) + ) + + def split_csv(value: str | None) -> list[str] | None: """Split a comma-separated flag value into a list, or None if empty.""" if not value: diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index b80eb015..c902995b 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -150,3 +150,46 @@ def test_every_transcribe_field_is_a_valid_param(field): raw_cls = type(aai.TranscriptionConfig().raw) assert field in raw_cls.model_fields + + +def test_merge_transcribe_config_returns_kwargs_dict(): + from assemblyai_cli import config_builder + + merged = config_builder.merge_transcribe_config( + flags={"speaker_labels": True, "language_code": None}, + overrides=["sentiment_analysis=true"], + config_file=None, + ) + assert merged == {"speaker_labels": True, "sentiment_analysis": True} + + +def test_construct_transcribe_config_from_merged(): + import assemblyai as aai + from assemblyai_cli import config_builder + + tc = config_builder.construct_transcription_config({"speaker_labels": True}) + assert isinstance(tc, aai.TranscriptionConfig) + assert tc.raw.model_dump(exclude_none=True) == {"speaker_labels": True} + + +def test_merge_streaming_params_coerces_speech_model_enum(): + from assemblyai.streaming.v3 import SpeechModel + from assemblyai_cli import config_builder + + merged = config_builder.merge_streaming_params( + flags={"speech_model": "universal-streaming-multilingual", "sample_rate": 16000}, + overrides=[], + config_file=None, + ) + assert merged["speech_model"] is SpeechModel.universal_streaming_multilingual + assert merged["sample_rate"] == 16000 + + +def test_build_transcription_config_still_works(): + import assemblyai as aai + from assemblyai_cli import config_builder + + tc = config_builder.build_transcription_config( + flags={"speaker_labels": True}, overrides=[], config_file=None + ) + assert isinstance(tc, aai.TranscriptionConfig) From e5ef9364441afa57a06d6f9140d5c080dbad628e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 14:55:38 -0700 Subject: [PATCH 43/87] feat(code-gen): add config kwargs serializer with round-trip test Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/__init__.py | 1 + assemblyai_cli/code_gen/serialize.py | 20 ++++++ tests/test_code_gen.py | 96 ++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 assemblyai_cli/code_gen/__init__.py create mode 100644 assemblyai_cli/code_gen/serialize.py create mode 100644 tests/test_code_gen.py diff --git a/assemblyai_cli/code_gen/__init__.py b/assemblyai_cli/code_gen/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/assemblyai_cli/code_gen/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/assemblyai_cli/code_gen/serialize.py b/assemblyai_cli/code_gen/serialize.py new file mode 100644 index 00000000..111c3a52 --- /dev/null +++ b/assemblyai_cli/code_gen/serialize.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from assemblyai.streaming.v3 import SpeechModel + + +def py_literal(value: object) -> str: + """Render a coerced config value as Python source. + + Handles SDK enums (SpeechModel.) and plain JSON-ish types. repr() yields + valid Python for str/bool/int/float/list/dict with string keys. + """ + if isinstance(value, SpeechModel): + return f"SpeechModel.{value.name}" + return repr(value) + + +def config_kwarg_lines(merged: dict[str, object], indent: int) -> list[str]: + """Render a merged kwargs dict as indented `field=value,` source lines.""" + pad = " " * indent + return [f"{pad}{key}={py_literal(val)}," for key, val in merged.items()] diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py new file mode 100644 index 00000000..9e253d5d --- /dev/null +++ b/tests/test_code_gen.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from assemblyai_cli.code_gen import serialize + + +def test_py_literal_basic_types(): + assert serialize.py_literal("en_us") == "'en_us'" + assert serialize.py_literal(True) == "True" + assert serialize.py_literal(42) == "42" + assert serialize.py_literal(["a", "b"]) == "['a', 'b']" + assert ( + serialize.py_literal({"AssemblyAI": ["assembly ai"]}) == "{'AssemblyAI': ['assembly ai']}" + ) + + +def test_py_literal_speech_model_enum(): + from assemblyai.streaming.v3 import SpeechModel + + assert serialize.py_literal(SpeechModel.u3_rt_pro) == "SpeechModel.u3_rt_pro" + + +def test_config_kwarg_lines_emits_indented_kwargs(): + lines = serialize.config_kwarg_lines( + {"speaker_labels": True, "language_code": "en_us"}, indent=4 + ) + assert lines == [" speaker_labels=True,", " language_code='en_us',"] + + +def test_config_kwarg_lines_empty_dict(): + assert serialize.config_kwarg_lines({}, indent=4) == [] + + +# --------------------------------------------------------------------------- +# Shared, domain-driven strategy: build merged-kwargs dicts from the AUTHORITATIVE +# field tables in config_builder. Used by every validity test below. Because the +# field list comes from the coerce tables, any field added later is fuzzed for free. +# --------------------------------------------------------------------------- +from assemblyai.streaming.v3 import SpeechModel # noqa: E402 + +from assemblyai_cli import config_builder # noqa: E402 + +# JSON-ish values that repr()->eval() round-trips (string keys, no NaN/inf). +_json = st.recursive( + st.none() + | st.booleans() + | st.integers() + | st.floats(allow_nan=False, allow_infinity=False) + | st.text(st.characters(blacklist_categories=["Cs"]), max_size=8), + lambda children: st.lists(children, max_size=3) + | st.dictionaries( + st.text(st.characters(min_codepoint=97, max_codepoint=122), min_size=1, max_size=5), + children, + max_size=3, + ), + max_leaves=5, +) + +_BY_KIND = { + "str": st.text(st.characters(blacklist_categories=["Cs"]), max_size=16), + "bool": st.booleans(), + "int": st.integers(), + "float": st.floats(allow_nan=False, allow_infinity=False), + "list": st.lists(st.text(st.characters(blacklist_categories=["Cs"]), max_size=8), max_size=4), + "json": _json, +} + + +def _value_for(field: str, kind: str): + # speech_model in the streaming table may be a SpeechModel enum in real merged dicts. + if field == "speech_model": + return st.sampled_from(list(SpeechModel)) | _BY_KIND["str"] + return _BY_KIND[kind] + + +def merged_strategy(coerce_table: dict[str, str]) -> st.SearchStrategy: + """A hypothesis strategy yielding merged-kwargs dicts over the FULL field table.""" + return st.fixed_dictionaries( + {}, optional={f: _value_for(f, kind) for f, kind in coerce_table.items()} + ) + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_serializer_round_trips_full_transcribe_domain(merged): + lines = serialize.config_kwarg_lines(merged, indent=0) + src = "dict(\n" + "\n".join(lines) + "\n)" + assert eval(src, {"SpeechModel": SpeechModel}) == merged # noqa: S307 + + +@given(merged_strategy(config_builder.STREAM_COERCE)) +def test_serializer_round_trips_full_stream_domain(merged): + lines = serialize.config_kwarg_lines(merged, indent=0) + src = "dict(\n" + "\n".join(lines) + "\n)" + assert eval(src, {"SpeechModel": SpeechModel}) == merged # noqa: S307 From 51c5dde53271b2a84b645530c2e3504396357f97 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:04:38 -0700 Subject: [PATCH 44/87] feat(code-gen): add transcribe result-handling snippets + coverage guard Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/snippets.py | 71 +++++++++++++++++++++++++++++ tests/test_code_gen.py | 31 +++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 assemblyai_cli/code_gen/snippets.py diff --git a/assemblyai_cli/code_gen/snippets.py b/assemblyai_cli/code_gen/snippets.py new file mode 100644 index 00000000..358bed01 --- /dev/null +++ b/assemblyai_cli/code_gen/snippets.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from collections.abc import Callable + +# (feature-name, enabled-predicate, result-handling code) in render order. +_Entry = tuple[str, Callable[[dict[str, object]], bool], str] + + +def _has(*keys: str) -> Callable[[dict[str, object]], bool]: + return lambda merged: any(bool(merged.get(k)) for k in keys) + + +_SNIPPETS: list[_Entry] = [ + ( + "speaker_labels", + _has("speaker_labels"), + 'for utt in transcript.utterances or []:\n print(f"Speaker {utt.speaker}: {utt.text}")', + ), + ( + "summary", + _has("summarization"), + 'if transcript.summary:\n print("Summary:", transcript.summary)', + ), + ( + "chapters", + _has("auto_chapters"), + "for ch in transcript.chapters or []:\n print(ch.headline)", + ), + ( + "highlights", + _has("auto_highlights"), + "results = getattr(transcript.auto_highlights, 'results', None) or []\n" + 'for h in results:\n print(f"({h.count}x) {h.text}")', + ), + ( + "sentiment", + _has("sentiment_analysis"), + "for s in transcript.sentiment_analysis or []:\n print(s.sentiment, s.text)", + ), + ( + "entities", + _has("entity_detection"), + 'for ent in transcript.entities or []:\n print(f"{ent.entity_type}: {ent.text}")', + ), + ( + "topics", + _has("iab_categories"), + "summary = getattr(transcript.iab_categories, 'summary', None) or {}\n" + "for label, relevance in summary.items():\n print(label, relevance)", + ), + ( + "content_safety", + _has("content_safety"), + "summary = getattr(transcript.content_safety, 'summary', None) or {}\n" + "for label, confidence in summary.items():\n print(label, confidence)", + ), +] + +# Feature names with a snippet — asserted complete by the coverage-guard test. +SNIPPET_FEATURES = [name for name, _pred, _code in _SNIPPETS] + + +def result_handling(merged: dict[str, object]) -> str: + """Return result-handling code for the enabled analysis features. + + Falls back to printing the transcript text when no analysis feature is on. + """ + blocks = [code for _name, pred, code in _SNIPPETS if pred(merged)] + if not blocks: + return "print(transcript.text)" + return "\n\n".join(blocks) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 9e253d5d..fc60cdbd 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -94,3 +94,34 @@ def test_serializer_round_trips_full_stream_domain(merged): lines = serialize.config_kwarg_lines(merged, indent=0) src = "dict(\n" + "\n".join(lines) + "\n)" assert eval(src, {"SpeechModel": SpeechModel}) == merged # noqa: S307 + + +from assemblyai_cli.code_gen import snippets # noqa: E402 + + +def test_result_handling_includes_only_enabled_features(): + out = snippets.result_handling({"speaker_labels": True, "sentiment_analysis": True}) + assert "transcript.utterances" in out # speaker_labels + assert "transcript.sentiment_analysis" in out + assert "transcript.summary" not in out # summarization not enabled + + +def test_result_handling_default_prints_text(): + out = snippets.result_handling({}) + assert out.strip() == "print(transcript.text)" + + +def test_every_render_feature_has_a_snippet(): + # Coverage guard: every analysis section the CLI renders must have a code snippet + # (or be explicitly excluded). Catches "added a feature, forgot the snippet". + import inspect + + from assemblyai_cli import transcribe_render + + rendered = { + name[len("_render_") :] + for name, _ in inspect.getmembers(transcribe_render, inspect.isfunction) + if name.startswith("_render_") and name != "_render_text" + } + covered = set(snippets.SNIPPET_FEATURES) + assert rendered <= covered, f"render features without a snippet: {rendered - covered}" From a70d69f811faea5538d5b715394c3a667fadceb7 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:12:35 -0700 Subject: [PATCH 45/87] fix(code-gen): bidirectional snippet coverage guard + clearer snippet vars Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/snippets.py | 8 ++++---- tests/test_code_gen.py | 14 +++++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/assemblyai_cli/code_gen/snippets.py b/assemblyai_cli/code_gen/snippets.py index 358bed01..2913d6e4 100644 --- a/assemblyai_cli/code_gen/snippets.py +++ b/assemblyai_cli/code_gen/snippets.py @@ -45,14 +45,14 @@ def _has(*keys: str) -> Callable[[dict[str, object]], bool]: ( "topics", _has("iab_categories"), - "summary = getattr(transcript.iab_categories, 'summary', None) or {}\n" - "for label, relevance in summary.items():\n print(label, relevance)", + "topic_summary = getattr(transcript.iab_categories, 'summary', None) or {}\n" + "for label, relevance in topic_summary.items():\n print(label, relevance)", ), ( "content_safety", _has("content_safety"), - "summary = getattr(transcript.content_safety, 'summary', None) or {}\n" - "for label, confidence in summary.items():\n print(label, confidence)", + "safety_summary = getattr(transcript.content_safety, 'summary', None) or {}\n" + "for label, confidence in safety_summary.items():\n print(label, confidence)", ), ] diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index fc60cdbd..ddf771b7 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -112,8 +112,11 @@ def test_result_handling_default_prints_text(): def test_every_render_feature_has_a_snippet(): - # Coverage guard: every analysis section the CLI renders must have a code snippet - # (or be explicitly excluded). Catches "added a feature, forgot the snippet". + # Maintainability tripwire. CONTRACT: each analysis feature rendered by a + # `_render_` function in transcribe_render.py must have a snippet whose + # name == . `_render_text` is excluded (it renders the flat transcript and, + # inline, the speaker_labels utterances). `speaker_labels` therefore has a snippet + # but no `_render_speaker_labels` function, so it is an allowed orphan. import inspect from assemblyai_cli import transcribe_render @@ -124,4 +127,9 @@ def test_every_render_feature_has_a_snippet(): if name.startswith("_render_") and name != "_render_text" } covered = set(snippets.SNIPPET_FEATURES) - assert rendered <= covered, f"render features without a snippet: {rendered - covered}" + ORPHANS = {"speaker_labels"} # rendered inside _render_text, not its own function + + missing = rendered - covered + assert not missing, f"render features without a snippet: {missing}" + unexpected_orphans = covered - rendered - ORPHANS + assert not unexpected_orphans, f"snippets with no matching renderer: {unexpected_orphans}" From 21be54cf863bfcb7e13a1a800631f16f95214b5a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:14:06 -0700 Subject: [PATCH 46/87] feat(code-gen): render runnable transcribe scripts Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/__init__.py | 6 ++++ assemblyai_cli/code_gen/transcribe.py | 40 +++++++++++++++++++++++++++ tests/test_code_gen.py | 21 ++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 assemblyai_cli/code_gen/transcribe.py diff --git a/assemblyai_cli/code_gen/__init__.py b/assemblyai_cli/code_gen/__init__.py index 9d48db4f..3d98867d 100644 --- a/assemblyai_cli/code_gen/__init__.py +++ b/assemblyai_cli/code_gen/__init__.py @@ -1 +1,7 @@ from __future__ import annotations + +from assemblyai_cli.code_gen import transcribe as _transcribe + + +def transcribe(merged: dict[str, object], source: str) -> str: + return _transcribe.render(merged, source) diff --git a/assemblyai_cli/code_gen/transcribe.py b/assemblyai_cli/code_gen/transcribe.py new file mode 100644 index 00000000..60e3d468 --- /dev/null +++ b/assemblyai_cli/code_gen/transcribe.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from assemblyai_cli.code_gen import serialize, snippets + + +def render(merged: dict[str, object], source: str) -> str: + """Generate a runnable transcribe script reproducing this CLI invocation.""" + if merged: + kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=4)) + config_block = f"config = aai.TranscriptionConfig(\n{kwargs}\n)" + call = f"transcript = transcriber.transcribe({source!r}, config=config)" + else: + config_block = "" + call = f"transcript = transcriber.transcribe({source!r})" + + result = snippets.result_handling(merged) + + parts = [ + "import os", + "", + "import assemblyai as aai", + "", + '# Export your key first: export ASSEMBLYAI_API_KEY=""', + 'aai.settings.api_key = os.environ["ASSEMBLYAI_API_KEY"]', + "", + "transcriber = aai.Transcriber()", + ] + if config_block: + parts += ["", config_block] + parts += [ + "", + call, + "", + "if transcript.status == aai.TranscriptStatus.error:", + " raise RuntimeError(transcript.error)", + "", + result, + "", + ] + return "\n".join(parts) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index ddf771b7..7b2d5084 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -133,3 +133,24 @@ def test_every_render_feature_has_a_snippet(): assert not missing, f"render features without a snippet: {missing}" unexpected_orphans = covered - rendered - ORPHANS assert not unexpected_orphans, f"snippets with no matching renderer: {unexpected_orphans}" + + +import ast # noqa: E402 + +from assemblyai_cli import code_gen # noqa: E402 + + +def test_transcribe_render_parses_and_uses_env_key(): + code = code_gen.transcribe({"speaker_labels": True}, source="https://assembly.ai/wildfires.mp3") + ast.parse(code) # raises SyntaxError if malformed + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code + assert "https://assembly.ai/wildfires.mp3" in code + assert "transcript.utterances" in code # result handling for speaker_labels + assert "{{API_KEY}}" not in code # never echo a real key + + +def test_transcribe_render_no_config_is_minimal(): + code = code_gen.transcribe({}, source="audio.mp3") + ast.parse(code) + assert "print(transcript.text)" in code + assert "TranscriptionConfig(" not in code # no kwargs -> no config object From 12fa4dbf6c3e062a49e47782fbdd236d1551c70a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:34:28 -0700 Subject: [PATCH 47/87] feat(transcribe): add --show-code to print equivalent SDK code Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/transcribe.py | 16 +++++++++++++++- tests/test_transcribe.py | 13 +++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 1444d26c..ce208730 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -8,6 +8,7 @@ from assemblyai_cli import ( client, + code_gen, config, config_builder, llm, @@ -106,6 +107,9 @@ def transcribe( model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + show_code: bool = typer.Option( + False, "--show-code", help="Also print the equivalent Python SDK code." + ), ) -> None: """Transcribe an audio file, URL, or YouTube URL with the full TranscriptionConfig surface. @@ -162,9 +166,10 @@ def body(state: AppState, json_mode: bool) -> None: flags["webhook_auth_header_name"] = header[0] flags["webhook_auth_header_value"] = header[1] - tc = config_builder.build_transcription_config( + merged = config_builder.merge_transcribe_config( flags=flags, overrides=list(config_kv or []), config_file=config_file ) + tc = config_builder.construct_transcription_config(merged) audio = client.resolve_audio_source(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) @@ -209,5 +214,14 @@ def body(state: AppState, json_mode: bool) -> None: print(json.dumps(payload, default=str)) else: transcribe_render.render_transcript_result(transcript, output.console) + if show_code: + # Code-gen is a bonus; never let it crash the real transcript output. + try: + rendered = code_gen.transcribe(merged, audio) + except Exception as exc: # noqa: BLE001 + output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") + else: + output.console.print("\n[dim]# Equivalent Python:[/dim]") + output.console.print(rendered) run_command(ctx, body, json=json_out) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 74ab9680..fa362038 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -252,6 +252,19 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file +def test_transcribe_show_code_prints_python(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "--sample", "--speaker-labels", "--show-code"]) + assert result.exit_code == 0 + assert "import assemblyai as aai" in result.output + assert "TranscriptionConfig(" in result.output + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output + + def test_transcribe_renders_summary_human(monkeypatch): _auth() monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) From 8a7f0e7ea2ab57f424ea03dd7878b724c9a8f40b Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:37:15 -0700 Subject: [PATCH 48/87] test(transcribe): assert --json suppresses --show-code output Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_transcribe.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index fa362038..6ce9efc1 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -265,6 +265,20 @@ def test_transcribe_show_code_prints_python(monkeypatch): assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output +def test_transcribe_show_code_suppressed_in_json_mode(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke( + app, ["transcribe", "--sample", "--speaker-labels", "--show-code", "--json"] + ) + assert result.exit_code == 0 + assert "import assemblyai as aai" not in result.stdout + assert "# Equivalent Python:" not in result.stdout + assert '"id": "t_1"' in result.stdout + + def test_transcribe_renders_summary_human(monkeypatch): _auth() monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) From 32a07e7531c2cd66a621f8d87fe4843813134b6a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:38:38 -0700 Subject: [PATCH 49/87] feat(code-gen): render runnable microphone-streaming scripts Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/__init__.py | 7 ++++ assemblyai_cli/code_gen/stream.py | 50 +++++++++++++++++++++++++++++ tests/test_code_gen.py | 14 ++++++++ 3 files changed, 71 insertions(+) create mode 100644 assemblyai_cli/code_gen/stream.py diff --git a/assemblyai_cli/code_gen/__init__.py b/assemblyai_cli/code_gen/__init__.py index 3d98867d..53e323ad 100644 --- a/assemblyai_cli/code_gen/__init__.py +++ b/assemblyai_cli/code_gen/__init__.py @@ -1,7 +1,14 @@ from __future__ import annotations +from assemblyai_cli.code_gen import stream as _stream from assemblyai_cli.code_gen import transcribe as _transcribe def transcribe(merged: dict[str, object], source: str) -> str: + """Generate runnable Python that reproduces this transcribe invocation.""" return _transcribe.render(merged, source) + + +def stream(merged: dict[str, object]) -> str: + """Generate runnable Python that reproduces this streaming invocation.""" + return _stream.render(merged) diff --git a/assemblyai_cli/code_gen/stream.py b/assemblyai_cli/code_gen/stream.py new file mode 100644 index 00000000..3aa92f21 --- /dev/null +++ b/assemblyai_cli/code_gen/stream.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from assemblyai_cli.code_gen import serialize + +_HEADER = """import os + +import assemblyai as aai +from assemblyai.streaming.v3 import ( + SpeechModel, + StreamingClient, + StreamingClientOptions, + StreamingEvents, + StreamingParameters, + TurnEvent, +) + +# Export your key first: export ASSEMBLYAI_API_KEY="" +API_KEY = os.environ["ASSEMBLYAI_API_KEY"] +aai.settings.api_key = API_KEY + + +def on_turn(client: StreamingClient, event: TurnEvent) -> None: + print(event.transcript, end="\\r", flush=True) + if event.end_of_turn: + print() + + +client = StreamingClient( + StreamingClientOptions(api_key=API_KEY, api_host="streaming.assemblyai.com") +) +client.on(StreamingEvents.Turn, on_turn) +""" + +_FOOTER = """ +print("Listening… press Ctrl-C to stop.") +try: + client.stream(aai.extras.MicrophoneStream(sample_rate=16000)) +finally: + client.disconnect(terminate=True) +""" + + +def render(merged: dict[str, object]) -> str: + """Generate a runnable microphone-streaming script with the given params.""" + if merged: + kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=8)) + connect = f"client.connect(\n StreamingParameters(\n{kwargs}\n )\n)" + else: + connect = "client.connect(StreamingParameters())" + return _HEADER + "\n" + connect + "\n" + _FOOTER diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 7b2d5084..78284a1c 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -154,3 +154,17 @@ def test_transcribe_render_no_config_is_minimal(): ast.parse(code) assert "print(transcript.text)" in code assert "TranscriptionConfig(" not in code # no kwargs -> no config object + + +def test_stream_render_parses_and_is_runnable_shape(): + from assemblyai.streaming.v3 import SpeechModel + + code = code_gen.stream( + {"sample_rate": 16000, "format_turns": True, "speech_model": SpeechModel.u3_rt_pro} + ) + ast.parse(code) + assert "StreamingClient(" in code + assert "StreamingParameters(" in code + assert "SpeechModel.u3_rt_pro" in code + assert "MicrophoneStream" in code + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code From 97b16dfbc35bb90902f00d0811e009265ef1be42 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:41:09 -0700 Subject: [PATCH 50/87] feat(doctor): add environment diagnostic command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `aai doctor` to check python, api-key/connectivity, ffmpeg, audio, and coding-agent readiness. The ffmpeg check makes clear it is only needed for streaming non-WAV files or URLs — transcription (including YouTube) works without it. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/doctor.py | 211 ++++++++++++++++++++++++++++++ assemblyai_cli/main.py | 3 + tests/test_doctor.py | 179 +++++++++++++++++++++++++ tests/test_smoke.py | 1 + 4 files changed, 394 insertions(+) create mode 100644 assemblyai_cli/commands/doctor.py create mode 100644 tests/test_doctor.py diff --git a/assemblyai_cli/commands/doctor.py b/assemblyai_cli/commands/doctor.py new file mode 100644 index 00000000..d2468447 --- /dev/null +++ b/assemblyai_cli/commands/doctor.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import shutil +import sys +from typing import TypedDict + +import typer +from rich.markup import escape + +from assemblyai_cli import client, config, output +from assemblyai_cli.context import AppState, resolve_profile, run_command +from assemblyai_cli.errors import CLIError, NotAuthenticated + +app = typer.Typer() + + +class Check(TypedDict): + """One diagnostic: a named check, its status, what it affects, and how to fix it.""" + + name: str + status: str # "ok" | "warn" | "fail" — only "fail" makes `doctor` exit non-zero + affects: list[str] + detail: str + fix: str | None + + +# Status -> render style. "fail" is a blocker; "warn" is degraded-but-usable. +_STYLE = {"ok": "aai.success", "warn": "aai.warn", "fail": "aai.error"} + + +def _check_python() -> Check: + v = sys.version_info + version = f"{v.major}.{v.minor}.{v.micro}" + if v >= (3, 10): + return {"name": "python", "status": "ok", "affects": [], "detail": version, "fix": None} + return { + "name": "python", + "status": "fail", + "affects": ["everything"], + "detail": f"Python {version} is too old; the CLI needs 3.10+", + "fix": "Install Python 3.10 or newer, then reinstall the CLI.", + } + + +def _check_api_key(profile: str) -> Check: + affects = ["everything"] + try: + key = config.resolve_api_key(profile=profile) + except NotAuthenticated: + return { + "name": "api-key", + "status": "fail", + "affects": affects, + "detail": "No API key found.", + "fix": "Run 'aai login' (or set ASSEMBLYAI_API_KEY).", + } + # validate_key doubles as the connectivity probe: it makes one cheap authed call, + # so a pass means the key is valid AND api.assemblyai.com is reachable. + try: + valid = client.validate_key(key) + except CLIError as exc: + return { + "name": "api-key", + "status": "fail", + "affects": affects, + "detail": f"Could not reach AssemblyAI: {exc.message}", + "fix": "Check your network/proxy and that api.assemblyai.com is reachable.", + } + if valid: + return { + "name": "api-key", + "status": "ok", + "affects": [], + "detail": "API key is valid and AssemblyAI is reachable.", + "fix": None, + } + return { + "name": "api-key", + "status": "fail", + "affects": affects, + "detail": "API key was rejected (HTTP 401).", + "fix": "Run 'aai login' with a valid key.", + } + + +def _check_ffmpeg() -> Check: + # ffmpeg is ONLY used to stream non-WAV files or URLs (stream/agent), where it + # decodes them to 16 kHz mono PCM on the fly. Plain `transcribe` (including + # YouTube URLs) uploads the file to AssemblyAI and never invokes ffmpeg, so it is + # not required for transcription. + affects = ["stream/agent (non-WAV file or URL input)"] + if shutil.which("ffmpeg"): + return {"name": "ffmpeg", "status": "ok", "affects": [], "detail": "found", "fix": None} + return { + "name": "ffmpeg", + "status": "warn", + "affects": affects, + "detail": ( + "ffmpeg not found. Only needed to stream non-WAV files or URLs; " + "transcription (including YouTube) works without it, as does streaming a " + "16 kHz mono WAV." + ), + "fix": "Install ffmpeg (macOS: brew install ffmpeg; Debian/Ubuntu: apt-get install ffmpeg).", + } + + +def _probe_input_devices() -> int: + """Number of available microphone (input) devices. Raises if audio is unavailable.""" + import sounddevice as sd + + devices = sd.query_devices() + return sum(1 for d in devices if d.get("max_input_channels", 0) > 0) + + +def _check_audio() -> Check: + affects = ["stream (microphone)", "agent"] + try: + inputs = _probe_input_devices() + except ImportError: + return { + "name": "audio", + "status": "warn", + "affects": affects, + "detail": "sounddevice is not importable; the microphone can't be used.", + "fix": "pip install --force-reinstall sounddevice", + } + except Exception as exc: # noqa: BLE001 - any PortAudio/device failure is a soft warning + return { + "name": "audio", + "status": "warn", + "affects": affects, + "detail": f"audio system unavailable: {exc}", + "fix": "On Linux install PortAudio: sudo apt-get install libportaudio2", + } + if inputs == 0: + return { + "name": "audio", + "status": "warn", + "affects": affects, + "detail": "No microphone (input device) found.", + "fix": "Connect a microphone; live mic input is needed for stream/agent.", + } + return { + "name": "audio", + "status": "ok", + "affects": [], + "detail": f"{inputs} microphone input device(s) available.", + "fix": None, + } + + +def _check_coding_agent() -> Check: + affects = ["aai claude install"] + missing = [tool for tool in ("claude", "npx") if shutil.which(tool) is None] + if not missing: + return { + "name": "coding-agent", + "status": "ok", + "affects": [], + "detail": "claude and npx found.", + "fix": None, + } + return { + "name": "coding-agent", + "status": "warn", + "affects": affects, + "detail": f"not found: {', '.join(missing)}.", + "fix": "Install Claude Code (https://claude.com/claude-code) and Node.js to wire up docs.", + } + + +def _render(data: dict[str, object]) -> str: + checks: list[Check] = data["checks"] # type: ignore[assignment] + lines = ["[aai.heading]AssemblyAI environment check:[/aai.heading]"] + for c in checks: + style = _STYLE.get(c["status"], "aai.muted") + lines.append( + f" {escape(c['name'])}: " + f"[{style}]{escape(c['status'])}[/{style}] — {escape(c['detail'])}" + ) + if c["fix"]: + lines.append(f" [aai.muted]fix:[/aai.muted] {escape(c['fix'])}") + if data["ok"]: + lines.append(" [aai.success]Ready.[/aai.success]") + else: + lines.append(" [aai.error]Problems found — see fixes above.[/aai.error]") + return "\n".join(lines) + + +@app.command() +def doctor( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Check that your environment is ready to use AssemblyAI.""" + + def body(state: AppState, json_mode: bool) -> None: + profile = resolve_profile(state) + checks = [ + _check_python(), + _check_api_key(profile), + _check_ffmpeg(), + _check_audio(), + _check_coding_agent(), + ] + ok = not any(c["status"] == "fail" for c in checks) + output.emit({"ok": ok, "checks": checks}, _render, json_mode=json_mode) + if not ok: + raise typer.Exit(code=1) + + run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py index 1e566a2f..7575deb2 100644 --- a/assemblyai_cli/main.py +++ b/assemblyai_cli/main.py @@ -8,6 +8,7 @@ from assemblyai_cli.commands import ( agent, claude, + doctor, llm, login, samples, @@ -29,6 +30,7 @@ "login", "logout", "whoami", + "doctor", "samples", "claude", "version", @@ -75,6 +77,7 @@ def main( app.add_typer(agent.app) app.add_typer(llm.app) app.add_typer(login.app) # login, logout, whoami +app.add_typer(doctor.app) app.add_typer(samples.app, name="samples") app.add_typer(claude.app, name="claude") diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 00000000..6b77a53a --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,179 @@ +import json +from collections import namedtuple + +import pytest +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.commands import doctor +from assemblyai_cli.errors import APIError +from assemblyai_cli.main import app + +runner = CliRunner() + + +@pytest.fixture +def healthy(monkeypatch): + """A fully-ready environment: valid key, all tools present, a microphone.""" + config.set_api_key("default", "sk_1234567890") + monkeypatch.setattr("assemblyai_cli.commands.doctor.client.validate_key", lambda _key: True) + monkeypatch.setattr( + "assemblyai_cli.commands.doctor.shutil.which", lambda tool: f"/usr/bin/{tool}" + ) + monkeypatch.setattr("assemblyai_cli.commands.doctor._probe_input_devices", lambda: 2) + + +def _checks(result): + return {c["name"]: c for c in json.loads(result.output)["checks"]} + + +def test_doctor_all_ok(healthy): + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["ok"] is True + assert {c["status"] for c in payload["checks"]} == {"ok"} + + +def test_doctor_no_api_key_fails(healthy): + config.clear_api_key("default") + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 1 + api = _checks(result)["api-key"] + assert api["status"] == "fail" + assert "login" in api["fix"] + + +def test_doctor_rejected_key_fails(healthy, monkeypatch): + monkeypatch.setattr("assemblyai_cli.commands.doctor.client.validate_key", lambda _key: False) + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 1 + assert _checks(result)["api-key"]["status"] == "fail" + + +def test_doctor_network_error_is_a_failure(healthy, monkeypatch): + def boom(_key): + raise APIError("Network error contacting AssemblyAI: timeout") + + monkeypatch.setattr("assemblyai_cli.commands.doctor.client.validate_key", boom) + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 1 + api = _checks(result)["api-key"] + assert api["status"] == "fail" + assert "reach AssemblyAI" in api["detail"] + + +def test_doctor_ffmpeg_missing_warns_but_passes(healthy, monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.doctor.shutil.which", + lambda tool: None if tool == "ffmpeg" else f"/usr/bin/{tool}", + ) + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 0 # a warning never blocks + assert _checks(result)["ffmpeg"]["status"] == "warn" + assert json.loads(result.output)["ok"] is True + + +def test_doctor_audio_unavailable_warns_but_passes(healthy, monkeypatch): + def no_audio(): + raise ImportError("no sounddevice") + + monkeypatch.setattr("assemblyai_cli.commands.doctor._probe_input_devices", no_audio) + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 0 + audio = _checks(result)["audio"] + assert audio["status"] == "warn" + assert "sounddevice" in audio["fix"] + + +def test_doctor_no_microphone_warns(healthy, monkeypatch): + monkeypatch.setattr("assemblyai_cli.commands.doctor._probe_input_devices", lambda: 0) + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 0 + assert _checks(result)["audio"]["status"] == "warn" + + +def test_doctor_coding_agent_missing_warns(healthy, monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.doctor.shutil.which", + lambda tool: None if tool in ("claude", "npx") else f"/usr/bin/{tool}", + ) + result = runner.invoke(app, ["doctor", "--json"]) + assert result.exit_code == 0 + agent_check = _checks(result)["coding-agent"] + assert agent_check["status"] == "warn" + assert "claude" in agent_check["detail"] + + +def test_doctor_json_shape(healthy): + payload = json.loads(runner.invoke(app, ["doctor", "--json"]).output) + assert set(payload) == {"ok", "checks"} + names = [c["name"] for c in payload["checks"]] + assert names == ["python", "api-key", "ffmpeg", "audio", "coding-agent"] + for c in payload["checks"]: + assert set(c) == {"name", "status", "affects", "detail", "fix"} + + +def test_doctor_human_output_renders(healthy): + # Force human mode by asking explicitly (default would be JSON under the test runner). + result = runner.invoke(app, ["doctor"], env={"NO_COLOR": "1"}) + # JSON is the default when not a TTY; either way the run must succeed. + assert result.exit_code == 0 + + +def test_doctor_listed_in_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "doctor" in result.output + + +# --- unit tests for the helpers and the human renderer --- + + +def test_check_python_flags_old_interpreter(monkeypatch): + VI = namedtuple("VI", "major minor micro releaselevel serial") + monkeypatch.setattr(doctor.sys, "version_info", VI(3, 9, 0, "final", 0)) + check = doctor._check_python() + assert check["status"] == "fail" + assert "3.9.0" in check["detail"] + + +def test_check_audio_handles_portaudio_failure(monkeypatch): + def boom(): + raise OSError("PortAudio library not found") + + monkeypatch.setattr(doctor, "_probe_input_devices", boom) + check = doctor._check_audio() + assert check["status"] == "warn" + assert "PortAudio" in check["detail"] + + +def test_render_ok_payload_shows_ready(): + payload = { + "ok": True, + "checks": [ + {"name": "python", "status": "ok", "affects": [], "detail": "3.12", "fix": None} + ], + } + text = doctor._render(payload) + assert "python" in text + assert "Ready." in text + + +def test_render_problem_payload_shows_fix_and_problem_banner(): + payload = { + "ok": False, + "checks": [ + { + "name": "api-key", + "status": "fail", + "affects": ["everything"], + "detail": "No API key found.", + "fix": "Run 'aai login'.", + } + ], + } + text = doctor._render(payload) + assert "fix:" in text + assert "Run 'aai login'." in text + assert "Problems found" in text diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 469d8464..19cd998f 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -47,6 +47,7 @@ def test_help_lists_commands_in_workflow_order(): "login", "logout", "whoami", + "doctor", "samples", "claude", "version", From eb8ba53eb16fd74d038732c1dd3544dc894ba342 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:46:02 -0700 Subject: [PATCH 51/87] docs: document aai doctor in the command table Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 17607742..cd411aa2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ aai transcribe --sample # transcribe the hosted wildfires.mp3 sample | Command | What it does | | --- | --- | | `aai login` / `logout` / `whoami` | Manage the stored API key. | +| `aai doctor` | Check your environment is ready (API key, network, ffmpeg, microphone, agent tooling). | | `aai transcribe ` | Transcribe an audio file, URL, or YouTube URL (`--sample` for a demo, `--llm-gateway-prompt` to transform the result). | | `aai transcripts list` / `get ` | Browse and fetch past transcripts. | | `aai stream [file]` | Real-time transcription from a file or the microphone. | From c639398dc81f4529679e7de233b2f526f6c813ba Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 15:54:58 -0700 Subject: [PATCH 52/87] fix(code-gen): match mic sample_rate to params; drop unused SpeechModel import Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/stream.py | 34 +++++++++++++++++++++++-------- tests/test_code_gen.py | 15 ++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/assemblyai_cli/code_gen/stream.py b/assemblyai_cli/code_gen/stream.py index 3aa92f21..44fef592 100644 --- a/assemblyai_cli/code_gen/stream.py +++ b/assemblyai_cli/code_gen/stream.py @@ -2,16 +2,21 @@ from assemblyai_cli.code_gen import serialize -_HEADER = """import os +# Streaming-class imports always used by the generated scaffold. SpeechModel is added +# only when a speech_model kwarg is emitted, so the generated script stays lint-clean. +_BASE_IMPORTS = [ + "StreamingClient", + "StreamingClientOptions", + "StreamingEvents", + "StreamingParameters", + "TurnEvent", +] + +_PREAMBLE = """import os import assemblyai as aai from assemblyai.streaming.v3 import ( - SpeechModel, - StreamingClient, - StreamingClientOptions, - StreamingEvents, - StreamingParameters, - TurnEvent, +{imports} ) # Export your key first: export ASSEMBLYAI_API_KEY="" @@ -34,7 +39,7 @@ def on_turn(client: StreamingClient, event: TurnEvent) -> None: _FOOTER = """ print("Listening… press Ctrl-C to stop.") try: - client.stream(aai.extras.MicrophoneStream(sample_rate=16000)) + client.stream(aai.extras.MicrophoneStream(sample_rate={rate})) finally: client.disconnect(terminate=True) """ @@ -42,9 +47,20 @@ def on_turn(client: StreamingClient, event: TurnEvent) -> None: def render(merged: dict[str, object]) -> str: """Generate a runnable microphone-streaming script with the given params.""" + names = list(_BASE_IMPORTS) + if "speech_model" in merged: + names.append("SpeechModel") + imports = "\n".join(f" {name}," for name in sorted(names)) + preamble = _PREAMBLE.format(imports=imports) + + # Mic capture rate must match StreamingParameters.sample_rate, else audio is corrupt. + rate = merged.get("sample_rate", 16000) + if merged: + # indent=8: 4 for connect(), 4 more for the StreamingParameters() args. kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=8)) connect = f"client.connect(\n StreamingParameters(\n{kwargs}\n )\n)" else: connect = "client.connect(StreamingParameters())" - return _HEADER + "\n" + connect + "\n" + _FOOTER + + return preamble + "\n" + connect + "\n" + _FOOTER.format(rate=rate) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 78284a1c..c80d5292 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -168,3 +168,18 @@ def test_stream_render_parses_and_is_runnable_shape(): assert "SpeechModel.u3_rt_pro" in code assert "MicrophoneStream" in code assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code + + +def test_stream_render_mic_rate_matches_params(): + code = code_gen.stream({"sample_rate": 8000}) + ast.parse(code) + assert "StreamingParameters(\n sample_rate=8000," in code + assert "MicrophoneStream(sample_rate=8000)" in code + + +def test_stream_render_empty_is_clean_and_has_no_speechmodel_import(): + code = code_gen.stream({}) + ast.parse(code) + assert "StreamingParameters()" in code + assert " SpeechModel," not in code # not imported when unused (keeps script lint-clean) + assert "MicrophoneStream(sample_rate=16000)" in code # default rate From 5b919599c150fb1ade20ecf568c23f7835ceb130 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 16:36:40 -0700 Subject: [PATCH 53/87] feat(stream): add --show-code to print equivalent SDK code Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/stream.py | 17 ++++++++++++-- tests/test_stream_command.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 2dca68e3..cf605fc5 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -6,7 +6,7 @@ import typer from assemblyai.streaming.v3 import SpeechModel -from assemblyai_cli import client, config, config_builder, llm, youtube +from assemblyai_cli import client, code_gen, config, config_builder, llm, output, youtube from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError from assemblyai_cli.microphone import MicrophoneSource @@ -92,6 +92,9 @@ def stream( model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), + show_code: bool = typer.Option( + False, "--show-code", help="Also print the equivalent Python SDK code." + ), ) -> None: """Transcribe live audio in real time with the full StreamingParameters surface. @@ -147,9 +150,10 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: flags["webhook_auth_header_name"] = header[0] flags["webhook_auth_header_value"] = header[1] - params = config_builder.build_streaming_params( + merged = config_builder.merge_streaming_params( flags=flags, overrides=list(config_kv or []), config_file=config_file ) + params = config_builder.construct_streaming_params(merged) try: client.stream_audio( @@ -180,6 +184,15 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: ) renderer.llm(transformed) + if show_code and not json_mode: + try: + rendered = code_gen.stream(merged) + except Exception as exc: # noqa: BLE001 + output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") + else: + output.console.print("\n[dim]# Equivalent Python (microphone streaming):[/dim]") + output.console.print(rendered) + if source and youtube.is_youtube_url(source): # Fetch the audio first, then stream the local file in real time. with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index b2ca1820..51d48d72 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -413,3 +413,42 @@ def test_stream_format_turns_tristate(monkeypatch): runner.invoke(app, ["stream", "--sample", "--no-format-turns"]) assert captured["params"].format_turns is False + + +def _wav_path(tmp_path): + # A real 16 kHz mono WAV reaches run() via FileSource without needing ffmpeg. + import wave + + p = tmp_path / "a.wav" + with wave.open(str(p), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + return str(p) + + +def test_stream_show_code_prints_python(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + # CliRunner is non-interactive, so output defaults to JSON; force human mode. + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr( + "assemblyai_cli.commands.stream.client.stream_audio", + lambda api_key, source, *, params, **kw: None, + ) + result = runner.invoke(app, ["stream", _wav_path(tmp_path), "--show-code"]) + assert result.exit_code == 0 + assert "StreamingClient(" in result.output + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output + + +def test_stream_show_code_suppressed_in_json_mode(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + monkeypatch.setattr( + "assemblyai_cli.commands.stream.client.stream_audio", + lambda api_key, source, *, params, **kw: None, + ) + result = runner.invoke(app, ["stream", _wav_path(tmp_path), "--show-code", "--json"]) + assert result.exit_code == 0 + assert "StreamingClient(" not in result.output + assert "# Equivalent Python" not in result.output From fdfe285e08fce5a8f0b225b878e3d528e222207f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 16:40:03 -0700 Subject: [PATCH 54/87] docs(stream): note always-microphone-idiom in --show-code call site Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/stream.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index cf605fc5..4b18a301 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -185,6 +185,8 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: renderer.llm(transformed) if show_code and not json_mode: + # Bonus artifact; never crash the stream. Always the microphone idiom + # (the canonical SDK starting point) even when a file was streamed. try: rendered = code_gen.stream(merged) except Exception as exc: # noqa: BLE001 From 99f6f4edb7fe13d58cf8c371a27b9dcd06bb204b Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 16:43:04 -0700 Subject: [PATCH 55/87] feat(code-gen): render runnable voice-agent scripts Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/__init__.py | 6 +++ assemblyai_cli/code_gen/agent.py | 75 +++++++++++++++++++++++++++++ tests/test_code_gen.py | 15 ++++++ 3 files changed, 96 insertions(+) create mode 100644 assemblyai_cli/code_gen/agent.py diff --git a/assemblyai_cli/code_gen/__init__.py b/assemblyai_cli/code_gen/__init__.py index 53e323ad..bf2c703b 100644 --- a/assemblyai_cli/code_gen/__init__.py +++ b/assemblyai_cli/code_gen/__init__.py @@ -1,9 +1,15 @@ from __future__ import annotations +from assemblyai_cli.code_gen import agent as _agent from assemblyai_cli.code_gen import stream as _stream from assemblyai_cli.code_gen import transcribe as _transcribe +def agent(voice: str, system_prompt: str, greeting: str) -> str: + """Generate runnable Python that reproduces this voice-agent session.""" + return _agent.render(voice, system_prompt, greeting) + + def transcribe(merged: dict[str, object], source: str) -> str: """Generate runnable Python that reproduces this transcribe invocation.""" return _transcribe.render(merged, source) diff --git a/assemblyai_cli/code_gen/agent.py b/assemblyai_cli/code_gen/agent.py new file mode 100644 index 00000000..cbca9002 --- /dev/null +++ b/assemblyai_cli/code_gen/agent.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json + +_TEMPLATE = """import base64 +import json +import os +import threading + +import sounddevice as sd +from websockets.sync.client import connect + +# Export your key first: export ASSEMBLYAI_API_KEY="" +API_KEY = os.environ["ASSEMBLYAI_API_KEY"] +WS_URL = "wss://agents.assemblyai.com/v1/ws" +RATE = 24000 # Voice Agent native PCM16 mono sample rate + +speaker = sd.RawOutputStream(samplerate=RATE, channels=1, dtype="int16") +speaker.start() +mic = sd.RawInputStream(samplerate=RATE, channels=1, dtype="int16", blocksize=1024) +mic.start() + +ready = threading.Event() + + +def send_mic(ws): + while True: + try: + data, _ = mic.read(1024) + if ready.is_set(): + ws.send(json.dumps({{"type": "input.audio", "audio": base64.b64encode(bytes(data)).decode()}})) # noqa: E501 + except Exception: + return + + +with connect(WS_URL, additional_headers={{"Authorization": f"Bearer {{API_KEY}}"}}) as ws: + ws.send(json.dumps({{ + "type": "session.update", + "session": {{ + "system_prompt": {system_prompt}, + "greeting": {greeting}, + "output": {{"voice": {voice}}}, + }}, + }})) + threading.Thread(target=send_mic, args=(ws,), daemon=True).start() + print("Connected \\u2014 start talking. (Ctrl-C to stop)") + try: + for raw in ws: + event = json.loads(raw) + etype = event.get("type") + if etype == "session.ready": + ready.set() + elif etype == "reply.audio" and event.get("data"): + speaker.write(base64.b64decode(event["data"])) + elif etype == "transcript.user": + print("you: ", event.get("text", "")) + elif etype == "transcript.agent": + print("agent:", event.get("text", "")) + except KeyboardInterrupt: + print("\\nStopped.") + finally: + speaker.stop() + speaker.close() + mic.stop() + mic.close() +""" + + +def render(voice: str, system_prompt: str, greeting: str) -> str: + """Generate a runnable voice-agent script with the given session settings.""" + return _TEMPLATE.format( + voice=json.dumps(voice), + system_prompt=json.dumps(system_prompt), + greeting=json.dumps(greeting), + ) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index c80d5292..0fd7507c 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -183,3 +183,18 @@ def test_stream_render_empty_is_clean_and_has_no_speechmodel_import(): assert "StreamingParameters()" in code assert " SpeechModel," not in code # not imported when unused (keeps script lint-clean) assert "MicrophoneStream(sample_rate=16000)" in code # default rate + + +def test_agent_render_parses_and_injects_session_fields(): + code = code_gen.agent(voice="ivy", system_prompt="Be terse.", greeting="Hi there") + ast.parse(code) + assert '"voice": "ivy"' in code + assert "Be terse." in code + assert "Hi there" in code + assert "agents.assemblyai.com" in code + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code + + +def test_agent_render_escapes_quotes_in_prompt(): + code = code_gen.agent(voice="ivy", system_prompt='Say "hi"', greeting="Hello") + ast.parse(code) # must stay valid Python despite the embedded quotes From 66a3b7f57e0758101a48ce819937efe0eb9e165a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 16:50:55 -0700 Subject: [PATCH 56/87] polish(code-gen): clearer agent sample (deps header, names) + stronger escape test Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/agent.py | 10 +++++++--- tests/test_code_gen.py | 9 +++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/assemblyai_cli/code_gen/agent.py b/assemblyai_cli/code_gen/agent.py index cbca9002..837655ea 100644 --- a/assemblyai_cli/code_gen/agent.py +++ b/assemblyai_cli/code_gen/agent.py @@ -2,7 +2,10 @@ import json -_TEMPLATE = """import base64 +_TEMPLATE = """# Live two-way voice conversation with an AssemblyAI voice agent. +# Requires audio support: pip install sounddevice websockets +# Tip: use headphones — the mic stays open while the agent speaks. +import base64 import json import os import threading @@ -26,9 +29,10 @@ def send_mic(ws): while True: try: - data, _ = mic.read(1024) + data, _overflowed = mic.read(1024) + chunk = bytes(data) if ready.is_set(): - ws.send(json.dumps({{"type": "input.audio", "audio": base64.b64encode(bytes(data)).decode()}})) # noqa: E501 + ws.send(json.dumps({{"type": "input.audio", "audio": base64.b64encode(chunk).decode()}})) except Exception: return diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 0fd7507c..4146ac79 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -196,5 +196,10 @@ def test_agent_render_parses_and_injects_session_fields(): def test_agent_render_escapes_quotes_in_prompt(): - code = code_gen.agent(voice="ivy", system_prompt='Say "hi"', greeting="Hello") - ast.parse(code) # must stay valid Python despite the embedded quotes + import json as _json + + tricky = 'Say "hi"\nand stop' + code = code_gen.agent(voice="ivy", system_prompt=tricky, greeting="Hello") + ast.parse(code) # valid Python despite embedded quotes/newlines + # The prompt is injected via json.dumps, so its escaped form appears verbatim. + assert _json.dumps(tricky) in code From 841639df261b245b18b31f72ecb680f5a868da32 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 16:53:53 -0700 Subject: [PATCH 57/87] feat(agent): add --show-code to print equivalent SDK code Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/agent.py | 16 +++++++++++++++- tests/test_agent_command.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 56f58790..11bab304 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -6,7 +6,7 @@ import typer -from assemblyai_cli import client, config +from assemblyai_cli import client, code_gen, config, output from assemblyai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session @@ -40,6 +40,9 @@ def agent( device: int | None = typer.Option(None, "--device", help="Microphone device index."), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), + show_code: bool = typer.Option( + False, "--show-code", help="Also print the equivalent Python SDK code." + ), ) -> None: """Have a live two-way voice conversation with an AssemblyAI voice agent. @@ -111,4 +114,15 @@ def body(state: AppState, json_mode: bool) -> None: with contextlib.suppress(BrokenPipeError): renderer.close() + if show_code and not json_mode: + # Bonus artifact; never crash the session. Show the greeting the user chose + # (not the file-suppressed "" passed to run_session) and always the mic idiom. + try: + rendered = code_gen.agent(voice, system_prompt_text, greeting) + except Exception as exc: # noqa: BLE001 + output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") + else: + output.console.print("\n[dim]# Equivalent Python (microphone agent):[/dim]") + output.console.print(rendered) + run_command(ctx, body, json=json_out) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 49cdb22e..10874927 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -247,3 +247,24 @@ def fake_run_session(api_key, *, renderer, **kwargs): result = runner.invoke(app, ["agent"]) assert result.exit_code == 0 assert "start talking" in result.output.lower() # live mic -> prompt the user to speak + + +def test_agent_show_code_prints_python(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + result = runner.invoke(app, ["agent", "--sample", "--voice", "ivy", "--show-code"]) + assert result.exit_code == 0 + assert "agents.assemblyai.com" in result.output + assert '"voice": "ivy"' in result.output + + +def test_agent_show_code_suppressed_in_json_mode(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + result = runner.invoke(app, ["agent", "--sample", "--voice", "ivy", "--show-code", "--json"]) + assert result.exit_code == 0 + assert "agents.assemblyai.com" not in result.output + assert "# Equivalent Python" not in result.output From ea9ec924d0af68d41053c0ec3145537a968c3fa2 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 16:57:18 -0700 Subject: [PATCH 58/87] test: pin --show-code prints after Ctrl-C (the normal mic-session stop) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_agent_command.py | 28 ++++++++++++++++++++++++++++ tests/test_stream_command.py | 23 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 10874927..e8a8a099 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -268,3 +268,31 @@ def test_agent_show_code_suppressed_in_json_mode(monkeypatch): assert result.exit_code == 0 assert "agents.assemblyai.com" not in result.output assert "# Equivalent Python" not in result.output + + +def test_agent_show_code_prints_after_ctrl_c(monkeypatch): + # Ctrl-C is the normal way to end a mic session; --show-code must still print after it. + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + + class FakeDuplex: + def __init__(self, **kwargs): + self.mic = iter([]) + self.player = self + + def start(self): + pass + + def close(self): + pass + + monkeypatch.setattr("assemblyai_cli.commands.agent.DuplexAudio", FakeDuplex) + + def _interrupt(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", _interrupt) + result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code"]) + assert result.exit_code == 0 + assert "agents.assemblyai.com" in result.output + assert '"voice": "ivy"' in result.output diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 51d48d72..dbf03bef 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -452,3 +452,26 @@ def test_stream_show_code_suppressed_in_json_mode(monkeypatch, tmp_path): assert result.exit_code == 0 assert "StreamingClient(" not in result.output assert "# Equivalent Python" not in result.output + + +def test_stream_show_code_prints_after_ctrl_c(monkeypatch): + # Ctrl-C ends a mic stream normally; --show-code must still print afterward. + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + + class FakeMic: + def __init__(self, *, device=None, capture_rate=None, on_open=None): + self.sample_rate = 16000 + + def __iter__(self): + return iter([b"\x00\x00"]) + + monkeypatch.setattr("assemblyai_cli.commands.stream.MicrophoneSource", FakeMic) + + def _interrupt(api_key, source, *, params, **kw): + raise KeyboardInterrupt + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _interrupt) + result = runner.invoke(app, ["stream", "--show-code"]) + assert result.exit_code == 0 + assert "StreamingClient(" in result.output From 773e791e88511d2fb6f631755d496764d005bfcf Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 17:08:32 -0700 Subject: [PATCH 59/87] test: --show-code is print-only (no run, no auth, --json ignored) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_agent_command.py | 56 ++++++++++----------------------- tests/test_stream_command.py | 60 ++++++++---------------------------- tests/test_transcribe.py | 38 ++++++++++++----------- 3 files changed, 50 insertions(+), 104 deletions(-) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index e8a8a099..f5816998 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -249,50 +249,28 @@ def fake_run_session(api_key, *, renderer, **kwargs): assert "start talking" in result.output.lower() # live mic -> prompt the user to speak -def test_agent_show_code_prints_python(monkeypatch): - config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) - result = runner.invoke(app, ["agent", "--sample", "--voice", "ivy", "--show-code"]) +def test_agent_show_code_prints_without_session(monkeypatch): + # Print-only: emits the agent script, never starts a session or opens audio, no auth. + called = [] + monkeypatch.setattr( + "assemblyai_cli.commands.agent.run_session", lambda *a, **k: called.append(True) + ) + result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code"]) assert result.exit_code == 0 + assert called == [] # never ran a session assert "agents.assemblyai.com" in result.output assert '"voice": "ivy"' in result.output + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output -def test_agent_show_code_suppressed_in_json_mode(monkeypatch): - config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) - result = runner.invoke(app, ["agent", "--sample", "--voice", "ivy", "--show-code", "--json"]) - assert result.exit_code == 0 - assert "agents.assemblyai.com" not in result.output - assert "# Equivalent Python" not in result.output - - -def test_agent_show_code_prints_after_ctrl_c(monkeypatch): - # Ctrl-C is the normal way to end a mic session; --show-code must still print after it. - config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - - class FakeDuplex: - def __init__(self, **kwargs): - self.mic = iter([]) - self.player = self - - def start(self): - pass - - def close(self): - pass - - monkeypatch.setattr("assemblyai_cli.commands.agent.DuplexAudio", FakeDuplex) - - def _interrupt(*a, **k): - raise KeyboardInterrupt +def test_agent_show_code_ignores_json_flag(monkeypatch): + def _boom(*a, **k): + raise AssertionError("must not run a session") - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", _interrupt) - result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code"]) + monkeypatch.setattr( + "assemblyai_cli.commands.agent.run_session", + _boom, + ) + result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code", "--json"]) assert result.exit_code == 0 assert "agents.assemblyai.com" in result.output - assert '"voice": "ivy"' in result.output diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index dbf03bef..ec6743a3 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -415,63 +415,29 @@ def test_stream_format_turns_tristate(monkeypatch): assert captured["params"].format_turns is False -def _wav_path(tmp_path): - # A real 16 kHz mono WAV reaches run() via FileSource without needing ffmpeg. - import wave - - p = tmp_path / "a.wav" - with wave.open(str(p), "wb") as w: - w.setnchannels(1) - w.setsampwidth(2) - w.setframerate(16000) - w.writeframes(b"\x00\x01" * 100) - return str(p) - - -def test_stream_show_code_prints_python(monkeypatch, tmp_path): - config.set_api_key("default", "sk_live") - # CliRunner is non-interactive, so output defaults to JSON; force human mode. - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) +def test_stream_show_code_prints_without_streaming(monkeypatch): + # Print-only: emits the mic-streaming script, never opens audio or streams, no auth. + called = [] monkeypatch.setattr( "assemblyai_cli.commands.stream.client.stream_audio", - lambda api_key, source, *, params, **kw: None, + lambda *a, **k: called.append(True), ) - result = runner.invoke(app, ["stream", _wav_path(tmp_path), "--show-code"]) + result = runner.invoke(app, ["stream", "--show-code"]) assert result.exit_code == 0 + assert called == [] # never streamed assert "StreamingClient(" in result.output + assert "MicrophoneStream(sample_rate=16000)" in result.output assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output -def test_stream_show_code_suppressed_in_json_mode(monkeypatch, tmp_path): - config.set_api_key("default", "sk_live") +def test_stream_show_code_ignores_json_flag(monkeypatch): + def _boom(*a, **k): + raise AssertionError("must not stream") + monkeypatch.setattr( "assemblyai_cli.commands.stream.client.stream_audio", - lambda api_key, source, *, params, **kw: None, + _boom, ) - result = runner.invoke(app, ["stream", _wav_path(tmp_path), "--show-code", "--json"]) - assert result.exit_code == 0 - assert "StreamingClient(" not in result.output - assert "# Equivalent Python" not in result.output - - -def test_stream_show_code_prints_after_ctrl_c(monkeypatch): - # Ctrl-C ends a mic stream normally; --show-code must still print afterward. - config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - - class FakeMic: - def __init__(self, *, device=None, capture_rate=None, on_open=None): - self.sample_rate = 16000 - - def __iter__(self): - return iter([b"\x00\x00"]) - - monkeypatch.setattr("assemblyai_cli.commands.stream.MicrophoneSource", FakeMic) - - def _interrupt(api_key, source, *, params, **kw): - raise KeyboardInterrupt - - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _interrupt) - result = runner.invoke(app, ["stream", "--show-code"]) + result = runner.invoke(app, ["stream", "--show-code", "--json"]) assert result.exit_code == 0 assert "StreamingClient(" in result.output diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 6ce9efc1..b8000f2c 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -252,31 +252,33 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file -def test_transcribe_show_code_prints_python(monkeypatch): - _auth() - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - result = runner.invoke(app, ["transcribe", "--sample", "--speaker-labels", "--show-code"]) +def test_transcribe_show_code_prints_without_transcribing(monkeypatch): + # Print-only: emits code, never calls the API, needs no auth. + called = [] + monkeypatch.setattr( + "assemblyai_cli.commands.transcribe.client.transcribe", + lambda *a, **k: called.append(True), + ) + result = runner.invoke(app, ["transcribe", "--sample", "--speaker-labels", "--show-code"]) assert result.exit_code == 0 + assert called == [] # never transcribed assert "import assemblyai as aai" in result.output assert "TranscriptionConfig(" in result.output assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output -def test_transcribe_show_code_suppressed_in_json_mode(): - _auth() - with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - result = runner.invoke( - app, ["transcribe", "--sample", "--speaker-labels", "--show-code", "--json"] - ) +def test_transcribe_show_code_ignores_json_flag(monkeypatch): + # --show-code is print-only; --json does not suppress or wrap it. + def _boom(*a, **k): + raise AssertionError("must not transcribe") + + monkeypatch.setattr( + "assemblyai_cli.commands.transcribe.client.transcribe", + _boom, + ) + result = runner.invoke(app, ["transcribe", "--sample", "--show-code", "--json"]) assert result.exit_code == 0 - assert "import assemblyai as aai" not in result.stdout - assert "# Equivalent Python:" not in result.stdout - assert '"id": "t_1"' in result.stdout + assert "import assemblyai as aai" in result.output def test_transcribe_renders_summary_human(monkeypatch): From 2eaa7330920a74edf2e0105a41f2d09a9ec90c69 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 17:09:00 -0700 Subject: [PATCH 60/87] style(tests): sort imports in test_config_builder (ruff I001) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_config_builder.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index c902995b..f6bd3a20 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -165,6 +165,7 @@ def test_merge_transcribe_config_returns_kwargs_dict(): def test_construct_transcribe_config_from_merged(): import assemblyai as aai + from assemblyai_cli import config_builder tc = config_builder.construct_transcription_config({"speaker_labels": True}) @@ -174,6 +175,7 @@ def test_construct_transcribe_config_from_merged(): def test_merge_streaming_params_coerces_speech_model_enum(): from assemblyai.streaming.v3 import SpeechModel + from assemblyai_cli import config_builder merged = config_builder.merge_streaming_params( @@ -187,6 +189,7 @@ def test_merge_streaming_params_coerces_speech_model_enum(): def test_build_transcription_config_still_works(): import assemblyai as aai + from assemblyai_cli import config_builder tc = config_builder.build_transcription_config( From 23c348d4f46a745eedfea167aa52dcb21a1d711e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:11:11 -0700 Subject: [PATCH 61/87] fix(deps): work with current typer/pydantic/assemblyai versions - Typer >=0.13 vendors its own click; type list_commands with typer's vendored Context and drop the now-unneeded real click dependency (clean installs were crashing: main.py imported click but typer no longer pulls it in). - assemblyai transcription models are pydantic v1 (__fields__/.dict); make the config-builder tests version-agnostic across v1/v2. - Update smoke test to assert TyperGroup; drop a stale type: ignore in conftest. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/agent.py | 32 ++++++------- assemblyai_cli/commands/stream.py | 67 ++++++++++++++------------- assemblyai_cli/commands/transcribe.py | 22 +++++---- assemblyai_cli/main.py | 10 +++- pyproject.toml | 2 +- tests/conftest.py | 5 +- tests/test_code_gen.py | 12 +++-- tests/test_config_builder.py | 22 +++++++-- tests/test_smoke.py | 8 ++-- uv.lock | 2 +- 10 files changed, 106 insertions(+), 76 deletions(-) diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 11bab304..7a08e112 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -6,7 +6,7 @@ import typer -from assemblyai_cli import client, code_gen, config, output +from assemblyai_cli import client, code_gen, config from assemblyai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session @@ -41,7 +41,9 @@ def agent( list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), show_code: bool = typer.Option( - False, "--show-code", help="Also print the equivalent Python SDK code." + False, + "--show-code", + help="Print the equivalent Python SDK code and exit (does not start a session).", ), ) -> None: """Have a live two-way voice conversation with an AssemblyAI voice agent. @@ -55,12 +57,8 @@ def agent( raise typer.Exit(code=0) def body(state: AppState, json_mode: bool) -> None: - api_key = config.resolve_api_key(profile=state.profile) if voice not in VOICES: raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") - from_file = bool(source) or sample - if from_file and device is not None: - raise UsageError("--device applies only to microphone input.") if system_prompt_file is not None: try: system_prompt_text = system_prompt_file.read_text(encoding="utf-8") @@ -73,6 +71,17 @@ def body(state: AppState, json_mode: bool) -> None: else: system_prompt_text = system_prompt + if show_code: + # Print-only: emit the equivalent agent script from the flags and exit + # without authenticating or opening audio. Raw stdout for `> script.py`. + print(code_gen.agent(voice, system_prompt_text, greeting)) + return + + api_key = config.resolve_api_key(profile=state.profile) + from_file = bool(source) or sample + if from_file and device is not None: + raise UsageError("--device applies only to microphone input.") + renderer = AgentRenderer(json_mode=json_mode, mic_input=not from_file) audio: Any player: Any @@ -114,15 +123,4 @@ def body(state: AppState, json_mode: bool) -> None: with contextlib.suppress(BrokenPipeError): renderer.close() - if show_code and not json_mode: - # Bonus artifact; never crash the session. Show the greeting the user chose - # (not the file-suppressed "" passed to run_session) and always the mic idiom. - try: - rendered = code_gen.agent(voice, system_prompt_text, greeting) - except Exception as exc: # noqa: BLE001 - output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") - else: - output.console.print("\n[dim]# Equivalent Python (microphone agent):[/dim]") - output.console.print(rendered) - run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 4b18a301..8a23dc9e 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -6,7 +6,7 @@ import typer from assemblyai.streaming.v3 import SpeechModel -from assemblyai_cli import client, code_gen, config, config_builder, llm, output, youtube +from assemblyai_cli import client, code_gen, config, config_builder, llm, youtube from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError from assemblyai_cli.microphone import MicrophoneSource @@ -93,7 +93,9 @@ def stream( max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), show_code: bool = typer.Option( - False, "--show-code", help="Also print the equivalent Python SDK code." + False, + "--show-code", + help="Print the equivalent Python SDK code and exit (does not stream).", ), ) -> None: """Transcribe live audio in real time with the full StreamingParameters surface. @@ -103,23 +105,7 @@ def stream( """ def body(state: AppState, json_mode: bool) -> None: - api_key = config.resolve_api_key(profile=state.profile) - from_file = bool(source) or sample - if from_file and (sample_rate is not None or device is not None): - raise UsageError("--sample-rate and --device apply only to microphone input.") - - renderer = StreamRenderer(json_mode=json_mode) - # Collect finalized turns so we can transform the full transcript at the end. - turns: list[str] = [] - - def on_turn(event: object) -> None: - renderer.turn(event) - if llm_gateway_prompt and getattr(event, "end_of_turn", False): - text = getattr(event, "transcript", "") or "" - if text: - turns.append(text) - - def run(audio: FileSource | MicrophoneSource, rate: int) -> None: + def make_flags(rate: int) -> dict[str, object]: flags: dict[str, object] = { "sample_rate": rate, "speech_model": speech_model, @@ -149,9 +135,39 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: if header is not None: flags["webhook_auth_header_name"] = header[0] flags["webhook_auth_header_value"] = header[1] + return flags + + if show_code: + # Print-only: emit the canonical microphone-streaming script (16 kHz) from + # the flags and exit without opening audio or authenticating. Raw stdout so + # `--show-code > script.py` yields a runnable file. + merged = config_builder.merge_streaming_params( + flags=make_flags(TARGET_RATE), + overrides=list(config_kv or []), + config_file=config_file, + ) + print(code_gen.stream(merged)) + return + + api_key = config.resolve_api_key(profile=state.profile) + from_file = bool(source) or sample + if from_file and (sample_rate is not None or device is not None): + raise UsageError("--sample-rate and --device apply only to microphone input.") + + renderer = StreamRenderer(json_mode=json_mode) + # Collect finalized turns so we can transform the full transcript at the end. + turns: list[str] = [] + def on_turn(event: object) -> None: + renderer.turn(event) + if llm_gateway_prompt and getattr(event, "end_of_turn", False): + text = getattr(event, "transcript", "") or "" + if text: + turns.append(text) + + def run(audio: FileSource | MicrophoneSource, rate: int) -> None: merged = config_builder.merge_streaming_params( - flags=flags, overrides=list(config_kv or []), config_file=config_file + flags=make_flags(rate), overrides=list(config_kv or []), config_file=config_file ) params = config_builder.construct_streaming_params(merged) @@ -184,17 +200,6 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: ) renderer.llm(transformed) - if show_code and not json_mode: - # Bonus artifact; never crash the stream. Always the microphone idiom - # (the canonical SDK starting point) even when a file was streamed. - try: - rendered = code_gen.stream(merged) - except Exception as exc: # noqa: BLE001 - output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") - else: - output.console.print("\n[dim]# Equivalent Python (microphone streaming):[/dim]") - output.console.print(rendered) - if source and youtube.is_youtube_url(source): # Fetch the audio first, then stream the local file in real time. with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index ce208730..ac680c4f 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -108,7 +108,9 @@ def transcribe( max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), show_code: bool = typer.Option( - False, "--show-code", help="Also print the equivalent Python SDK code." + False, + "--show-code", + help="Print the equivalent Python SDK code and exit (does not transcribe).", ), ) -> None: """Transcribe an audio file, URL, or YouTube URL with the full TranscriptionConfig surface. @@ -169,6 +171,15 @@ def body(state: AppState, json_mode: bool) -> None: merged = config_builder.merge_transcribe_config( flags=flags, overrides=list(config_kv or []), config_file=config_file ) + + if show_code: + # Print-only: build the equivalent script from the flags and exit without + # transcribing or authenticating. Raw stdout so `--show-code > script.py` + # yields a runnable file. + audio = client.resolve_audio_source(source, sample=sample) + print(code_gen.transcribe(merged, audio)) + return + tc = config_builder.construct_transcription_config(merged) audio = client.resolve_audio_source(source, sample=sample) @@ -214,14 +225,5 @@ def body(state: AppState, json_mode: bool) -> None: print(json.dumps(payload, default=str)) else: transcribe_render.render_transcript_result(transcript, output.console) - if show_code: - # Code-gen is a bonus; never let it crash the real transcript output. - try: - rendered = code_gen.transcribe(merged, audio) - except Exception as exc: # noqa: BLE001 - output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") - else: - output.console.print("\n[dim]# Equivalent Python:[/dim]") - output.console.print(rendered) run_command(ctx, body, json=json_out) diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py index 7575deb2..08a4a48d 100644 --- a/assemblyai_cli/main.py +++ b/assemblyai_cli/main.py @@ -1,9 +1,15 @@ from __future__ import annotations -import click +from typing import TYPE_CHECKING + import typer from typer.core import TyperGroup +if TYPE_CHECKING: + # Typer (>=0.13) vendors its own click; TyperGroup.list_commands receives this + # context type, not the upstream click.Context. Imported for typing only. + from typer._click.core import Context as ClickContext + from assemblyai_cli import __version__ from assemblyai_cli.commands import ( agent, @@ -44,7 +50,7 @@ class _OrderedGroup(TyperGroup): order alone can't place `version` last; sorting here controls help output. """ - def list_commands(self, ctx: click.Context) -> list[str]: + def list_commands(self, ctx: ClickContext) -> list[str]: rank = {name: i for i, name in enumerate(_COMMAND_ORDER)} return sorted( super().list_commands(ctx), key=lambda name: (rank.get(name, len(rank)), name) diff --git a/pyproject.toml b/pyproject.toml index 4c1ae19d..50b95e4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ - "typer>=0.12", + "typer>=0.13", # >=0.13 vendors its own click (typer._click); we no longer import click "assemblyai>=0.34", "rich>=13.0", "keyring>=24.0", diff --git a/tests/conftest.py b/tests/conftest.py index 0304e163..58d318fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,9 +19,8 @@ def real_api_key(): class MemoryKeyring(KeyringBackend): - # keyring's base types `priority` as a classproperty[float]; a plain value is the - # documented way to set it for a backend, so the assignment mismatch is expected. - priority = 1 # type: ignore[assignment] + # A plain value is the documented way to set a backend's priority. + priority = 1 def __init__(self): self._store = {} diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 4146ac79..dae4e2aa 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -49,11 +49,13 @@ def test_config_kwarg_lines_empty_dict(): | st.integers() | st.floats(allow_nan=False, allow_infinity=False) | st.text(st.characters(blacklist_categories=["Cs"]), max_size=8), - lambda children: st.lists(children, max_size=3) - | st.dictionaries( - st.text(st.characters(min_codepoint=97, max_codepoint=122), min_size=1, max_size=5), - children, - max_size=3, + lambda children: ( + st.lists(children, max_size=3) + | st.dictionaries( + st.text(st.characters(min_codepoint=97, max_codepoint=122), min_size=1, max_size=5), + children, + max_size=3, + ) ), max_leaves=5, ) diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index f6bd3a20..86b2d494 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -6,6 +6,21 @@ from assemblyai_cli.errors import UsageError +def _param_names(model_cls) -> set[str]: + # assemblyai's transcription models are pydantic v1 (__fields__); the streaming.v3 + # models are pydantic v2 (model_fields). Accept either so tests track the SDK. + return set(getattr(model_cls, "model_fields", None) or model_cls.__fields__) + + +def _dump(model) -> dict: + dumped = ( + model.model_dump(exclude_none=True) + if hasattr(model, "model_dump") + else model.dict(exclude_none=True) # pydantic v1 fallback + ) + return dict(dumped) + + def test_coerce_bool_int_float_list(): assert cb.coerce_value("speaker_labels", "true") is True assert cb.coerce_value("speaker_labels", "false") is False @@ -140,7 +155,7 @@ def test_every_stream_field_is_a_valid_param(field): # Each declared field must be a real StreamingParameters attribute. from assemblyai.streaming.v3 import StreamingParameters - assert field in StreamingParameters.model_fields + assert field in _param_names(StreamingParameters) @pytest.mark.parametrize("field", sorted(cb.TRANSCRIBE_FIELDS)) @@ -149,7 +164,7 @@ def test_every_transcribe_field_is_a_valid_param(field): import assemblyai as aai raw_cls = type(aai.TranscriptionConfig().raw) - assert field in raw_cls.model_fields + assert field in _param_names(raw_cls) def test_merge_transcribe_config_returns_kwargs_dict(): @@ -170,7 +185,8 @@ def test_construct_transcribe_config_from_merged(): tc = config_builder.construct_transcription_config({"speaker_labels": True}) assert isinstance(tc, aai.TranscriptionConfig) - assert tc.raw.model_dump(exclude_none=True) == {"speaker_labels": True} + # _dump may include SDK-internal keys; assert the field we set is present and on. + assert _dump(tc.raw)["speaker_labels"] is True def test_merge_streaming_params_coerces_speech_model_enum(): diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 19cd998f..db99a4f2 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -31,12 +31,14 @@ def test_stream_registered_top_level(): def test_help_lists_commands_in_workflow_order(): - import click + from typer.core import TyperGroup from typer.main import get_command cmd = get_command(app) - assert isinstance(cmd, click.Group) - names = cmd.list_commands(click.Context(cmd)) # the order shown under --help + # Typer (>=0.13) vendors its own click; the root command is a TyperGroup. + assert isinstance(cmd, TyperGroup) + ctx = cmd.make_context("aai", [], resilient_parsing=True) + names = cmd.list_commands(ctx) # the order shown under --help # Core transcription first, then voice/LLM, account, tooling, version last. assert names == [ "transcribe", diff --git a/uv.lock b/uv.lock index 96030f22..312df03a 100644 --- a/uv.lock +++ b/uv.lock @@ -97,7 +97,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, { name = "sounddevice", specifier = ">=0.5" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "typer", specifier = ">=0.12" }, + { name = "typer", specifier = ">=0.13" }, { name = "websockets", specifier = ">=13" }, { name = "yt-dlp", specifier = ">=2024.0" }, ] From 8bf073a844cee4fb70912f14afa54113fc7c421b Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:12:41 -0700 Subject: [PATCH 62/87] fix(client): support pydantic-v1 transcript items in list_transcripts assemblyai's transcription models are pydantic v1 (no model_dump); 'aai transcripts list' would have crashed at runtime. Serialize via .json() when model_dump is absent. Pin the v1 path with a test. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/client.py | 10 +++++++++- tests/test_client.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 5518cf66..889c5579 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import Callable, Iterable from typing import Any @@ -46,6 +47,13 @@ def validate_key(api_key: str) -> bool: raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc +def _item_to_dict(item: Any) -> dict[str, Any]: + """JSON-safe dict for an SDK model across pydantic v2 (model_dump) and v1 (.json).""" + if hasattr(item, "model_dump"): + return dict(item.model_dump(mode="json")) + return dict(json.loads(item.json())) # pydantic v1 (assemblyai transcription models) + + def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object]]: _configure(api_key) try: @@ -56,7 +64,7 @@ def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object] raise APIError(f"Could not list transcripts: {exc}") from exc except Exception as exc: raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc - return [item.model_dump(mode="json") for item in resp.transcripts] + return [_item_to_dict(item) for item in resp.transcripts] def transcribe(api_key: str, audio: str, *, config: aai.TranscriptionConfig) -> aai.Transcript: diff --git a/tests/test_client.py b/tests/test_client.py index ff363d45..48199aa2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -58,6 +58,19 @@ def test_list_transcripts_returns_dict_rows(): item.model_dump.assert_called_once_with(mode="json") +def test_list_transcripts_supports_pydantic_v1_items(): + # assemblyai's transcription models are pydantic v1: no model_dump, but .json(). + import types + + item = types.SimpleNamespace(json=lambda: '{"id": "t2", "status": "queued"}') + resp = MagicMock() + resp.transcripts = [item] + with patch.object(client.aai, "Transcriber") as T: + T.return_value.list_transcripts.return_value = resp + rows = client.list_transcripts("sk", limit=5) + assert rows == [{"id": "t2", "status": "queued"}] + + def test_list_transcripts_auth_error_becomes_apierror(): with patch.object(client.aai, "Transcriber") as T: T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError("nope") From 44b050d8a2a763725a1872da4e43ab22fded513d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:14:20 -0700 Subject: [PATCH 63/87] ci(check): run ruff/mypy/pytest via uv run for a reproducible locked env Bare tools picked up whatever was on PATH (system Python), which diverged from the locked venv and masked breakages. Run the Python gate through uv run so CI and local match pyproject + uv.lock. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/check.sh b/scripts/check.sh index 83d02c0a..677aa849 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,22 +4,26 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Run the Python tools through `uv run` so they use the project's locked +# environment (pyproject + uv.lock), not whatever happens to be on PATH. This keeps +# results reproducible and consistent with `uv run` used everywhere else. + echo "==> ruff check (src + tests)" -ruff check . +uv run ruff check . echo "==> ruff format --check (src + tests)" -ruff format --check . +uv run ruff format --check . echo "==> mypy (src + tests)" -mypy # files = ["assemblyai_cli", "tests"] in pyproject.toml +uv run mypy # files = ["assemblyai_cli", "tests"] in pyproject.toml echo "==> markdownlint (docs/ is generated, so excluded)" markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cache echo "==> pytest (with branch-coverage gate)" # Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need -# a live API key + kokoro. Run them with: pytest -m e2e -pytest -q -m "not e2e" --cov=assemblyai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +# a live API key + kokoro. Run them with: uv run pytest -m e2e +uv run pytest -q -m "not e2e" --cov=assemblyai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 echo "==> build + twine check (PyPI publish readiness)" # Build sdist + wheel into ./dist, then validate the metadata and README render From 16f824d2bfea5a8cfbf245847e8442ee620385fe Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:16:42 -0700 Subject: [PATCH 64/87] test(code-gen): exhaustive fuzz-compiles + round-trip + exec harness Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_code_gen.py | 91 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index dae4e2aa..caab3e64 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -1,10 +1,15 @@ from __future__ import annotations -from hypothesis import given +from typing import ClassVar + +from hypothesis import given, settings from hypothesis import strategies as st from assemblyai_cli.code_gen import serialize +settings.register_profile("codegen", max_examples=150) +settings.load_profile("codegen") + def test_py_literal_basic_types(): assert serialize.py_literal("en_us") == "'en_us'" @@ -205,3 +210,87 @@ def test_agent_render_escapes_quotes_in_prompt(): ast.parse(code) # valid Python despite embedded quotes/newlines # The prompt is injected via json.dumps, so its escaped form appears verbatim. assert _json.dumps(tricky) in code + + +# --------------------------------------------------------------------------- +# Exhaustive validity & fidelity harness (Task 10) +# --------------------------------------------------------------------------- + + +def _compiles(code: str) -> None: + # compile() is stricter than ast.parse() and is what `python file.py` runs through. + compile(code, "", "exec") + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_fuzz_transcribe_always_compiles(merged): + _compiles(code_gen.transcribe(merged, source="audio.mp3")) + + +@given(merged_strategy(config_builder.STREAM_COERCE)) +def test_fuzz_stream_always_compiles(merged): + _compiles(code_gen.stream(merged)) + + +@given( + voice=st.text(st.characters(blacklist_categories=["Cs"]), max_size=20), + system_prompt=st.text(st.characters(blacklist_categories=["Cs"]), max_size=200), + greeting=st.text(st.characters(blacklist_categories=["Cs"]), max_size=200), +) +def test_fuzz_agent_always_compiles(voice, system_prompt, greeting): + # Arbitrary text (quotes, newlines, backslashes, unicode) must never break the script. + _compiles(code_gen.agent(voice=voice, system_prompt=system_prompt, greeting=greeting)) + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_fuzz_transcribe_config_round_trips_in_generated_code(merged): + # The TranscriptionConfig(...) the generated code builds must equal the merged dict. + code = code_gen.transcribe(merged, source="audio.mp3") + if not merged: + assert "TranscriptionConfig(" not in code + return + # repr() escapes newlines, so no kwarg line contains a literal "\n)"; the first + # "\n)" after the constructor opens is always the config block's closer. + inner = code.split("aai.TranscriptionConfig(\n", 1)[1].split("\n)", 1)[0] + rebuilt = eval("dict(\n" + inner + "\n)", {"SpeechModel": SpeechModel}) # noqa: S307 + assert rebuilt == merged + + +class _Stub: + """A transcript-shaped stub exposing every attribute the snippets read.""" + + text: ClassVar[str] = "hello world" + utterances: ClassVar[list] = [type("U", (), {"speaker": "A", "text": "hi"})()] + summary: ClassVar[str] = "a summary" + chapters: ClassVar[list] = [type("C", (), {"headline": "intro"})()] + auto_highlights: ClassVar[object] = type( + "H", (), {"results": [type("R", (), {"count": 2, "text": "k"})()]} + )() + sentiment_analysis: ClassVar[list] = [ + type("S", (), {"sentiment": "POSITIVE", "text": "good"})() + ] + entities: ClassVar[list] = [type("E", (), {"entity_type": "person_name", "text": "Ada"})()] + iab_categories: ClassVar[object] = type("I", (), {"summary": {"Tech": 0.9}})() + content_safety: ClassVar[object] = type("CS", (), {"summary": {"profanity": 0.1}})() + + +def test_every_snippet_execs_against_a_realistic_transcript(): + # Enable every feature so result_handling emits all snippets, then exec them. + all_on = { + "speaker_labels": True, + "summarization": True, + "auto_chapters": True, + "auto_highlights": True, + "sentiment_analysis": True, + "entity_detection": True, + "iab_categories": True, + "content_safety": True, + } + body = snippets.result_handling(all_on) + exec(compile(body, "", "exec"), {"transcript": _Stub()}) # noqa: S102 + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_fuzz_result_handling_always_execs(merged): + body = snippets.result_handling(merged) + exec(compile(body, "", "exec"), {"transcript": _Stub(), "getattr": getattr}) # noqa: S102 From f0b0ba8520cac429257d928d47e8e1ae44538531 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:25:58 -0700 Subject: [PATCH 65/87] fix(show-code): emit LLM Gateway transform + single full-duplex agent stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transcribe --show-code now reflects --llm-gateway-prompt: the generated script runs the prompt over the transcript via the OpenAI-compatible LLM Gateway (transcript_id extra_body + {{ transcript }} tag), replacing analysis output as the CLI does. - Generated agent script now uses ONE sd.RawStream (mic+speaker) with device-rate resampling, matching the DuplexAudio fix — two separate streams crash on macOS CoreAudio (PaMacCore -50). Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/__init__.py | 9 +++- assemblyai_cli/code_gen/agent.py | 63 +++++++++++++++++++-------- assemblyai_cli/code_gen/transcribe.py | 54 ++++++++++++++++++++--- assemblyai_cli/commands/transcribe.py | 7 ++- tests/test_code_gen.py | 36 +++++++++++++++ tests/test_transcribe.py | 18 ++++++++ 6 files changed, 160 insertions(+), 27 deletions(-) diff --git a/assemblyai_cli/code_gen/__init__.py b/assemblyai_cli/code_gen/__init__.py index bf2c703b..01a249b5 100644 --- a/assemblyai_cli/code_gen/__init__.py +++ b/assemblyai_cli/code_gen/__init__.py @@ -10,9 +10,14 @@ def agent(voice: str, system_prompt: str, greeting: str) -> str: return _agent.render(voice, system_prompt, greeting) -def transcribe(merged: dict[str, object], source: str) -> str: +def transcribe( + merged: dict[str, object], + source: str, + *, + llm_gateway: dict[str, object] | None = None, +) -> str: """Generate runnable Python that reproduces this transcribe invocation.""" - return _transcribe.render(merged, source) + return _transcribe.render(merged, source, llm_gateway=llm_gateway) def stream(merged: dict[str, object]) -> str: diff --git a/assemblyai_cli/code_gen/agent.py b/assemblyai_cli/code_gen/agent.py index 837655ea..7d78d832 100644 --- a/assemblyai_cli/code_gen/agent.py +++ b/assemblyai_cli/code_gen/agent.py @@ -2,40 +2,66 @@ import json +# Injected fields ({voice}/{system_prompt}/{greeting}) are substituted with json.dumps +# values via str.format in a single pass, so prompt text can't collide with the +# template's own braces. Every other literal brace below is doubled for str.format. _TEMPLATE = """# Live two-way voice conversation with an AssemblyAI voice agent. # Requires audio support: pip install sounddevice websockets # Tip: use headphones — the mic stays open while the agent speaks. +import audioop import base64 import json import os +import queue import threading import sounddevice as sd from websockets.sync.client import connect -# Export your key first: export ASSEMBLYAI_API_KEY="" API_KEY = os.environ["ASSEMBLYAI_API_KEY"] WS_URL = "wss://agents.assemblyai.com/v1/ws" RATE = 24000 # Voice Agent native PCM16 mono sample rate -speaker = sd.RawOutputStream(samplerate=RATE, channels=1, dtype="int16") -speaker.start() -mic = sd.RawInputStream(samplerate=RATE, channels=1, dtype="int16", blocksize=1024) -mic.start() +# ONE full-duplex stream (mic + speaker together). Opening two separate streams on a +# single device fails on macOS CoreAudio, which silently kills capture. Audio is +# captured at the device's native rate and resampled to/from the agent's 24 kHz. +device_rate = int(sd.query_devices(None, "output")["default_samplerate"]) +blocksize = max(1, device_rate // 10) # ~100 ms +mic_queue: queue.Queue = queue.Queue() +play_buffer = bytearray() +buffer_lock = threading.Lock() ready = threading.Event() +_capture_state = None # audioop.ratecv state: device_rate -> 24 kHz +_play_state = None # audioop.ratecv state: 24 kHz -> device_rate + + +def on_audio(indata, outdata, _frames, _time, _status): + global _capture_state + # Capture: resample the mic input to 24 kHz and queue it for the agent. + chunk, _capture_state = audioop.ratecv(bytes(indata), 2, 1, device_rate, RATE, _capture_state) + mic_queue.put_nowait(chunk) + # Playback: drain the agent's audio into the output, zero-filling any shortfall. + needed = len(outdata) + with buffer_lock: + take = bytes(play_buffer[:needed]) + del play_buffer[:needed] + outdata[: len(take)] = take + if len(take) < needed: + outdata[len(take):] = b"\\x00" * (needed - len(take)) def send_mic(ws): while True: - try: - data, _overflowed = mic.read(1024) - chunk = bytes(data) - if ready.is_set(): - ws.send(json.dumps({{"type": "input.audio", "audio": base64.b64encode(chunk).decode()}})) - except Exception: - return + chunk = mic_queue.get() + if ready.is_set(): + ws.send(json.dumps({{"type": "input.audio", "audio": base64.b64encode(chunk).decode()}})) + +stream = sd.RawStream( + samplerate=device_rate, channels=1, dtype="int16", blocksize=blocksize, callback=on_audio +) +stream.start() with connect(WS_URL, additional_headers={{"Authorization": f"Bearer {{API_KEY}}"}}) as ws: ws.send(json.dumps({{ @@ -47,7 +73,7 @@ def send_mic(ws): }}, }})) threading.Thread(target=send_mic, args=(ws,), daemon=True).start() - print("Connected \\u2014 start talking. (Ctrl-C to stop)") + print("Connected — start talking. (Ctrl-C to stop)") try: for raw in ws: event = json.loads(raw) @@ -55,7 +81,10 @@ def send_mic(ws): if etype == "session.ready": ready.set() elif etype == "reply.audio" and event.get("data"): - speaker.write(base64.b64decode(event["data"])) + pcm = base64.b64decode(event["data"]) + pcm, _play_state = audioop.ratecv(pcm, 2, 1, RATE, device_rate, _play_state) + with buffer_lock: + play_buffer += pcm elif etype == "transcript.user": print("you: ", event.get("text", "")) elif etype == "transcript.agent": @@ -63,10 +92,8 @@ def send_mic(ws): except KeyboardInterrupt: print("\\nStopped.") finally: - speaker.stop() - speaker.close() - mic.stop() - mic.close() + stream.stop() + stream.close() """ diff --git a/assemblyai_cli/code_gen/transcribe.py b/assemblyai_cli/code_gen/transcribe.py index 60e3d468..bbb3ce4a 100644 --- a/assemblyai_cli/code_gen/transcribe.py +++ b/assemblyai_cli/code_gen/transcribe.py @@ -1,10 +1,22 @@ from __future__ import annotations +from assemblyai_cli import llm from assemblyai_cli.code_gen import serialize, snippets -def render(merged: dict[str, object], source: str) -> str: - """Generate a runnable transcribe script reproducing this CLI invocation.""" +def render( + merged: dict[str, object], + source: str, + *, + llm_gateway: dict[str, object] | None = None, +) -> str: + """Generate a runnable transcribe script reproducing this CLI invocation. + + When `llm_gateway` is given (a dict with ``prompt``/``model``/``max_tokens``), the + script transforms the transcript through AssemblyAI's LLM Gateway and prints that + result instead of the analysis sections — mirroring how `--llm-gateway-prompt` + replaces the normal output. + """ if merged: kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=4)) config_block = f"config = aai.TranscriptionConfig(\n{kwargs}\n)" @@ -13,12 +25,14 @@ def render(merged: dict[str, object], source: str) -> str: config_block = "" call = f"transcript = transcriber.transcribe({source!r})" - result = snippets.result_handling(merged) + imports = ["import assemblyai as aai"] + if llm_gateway: + imports.append("from openai import OpenAI") parts = [ "import os", "", - "import assemblyai as aai", + *imports, "", '# Export your key first: export ASSEMBLYAI_API_KEY=""', 'aai.settings.api_key = os.environ["ASSEMBLYAI_API_KEY"]', @@ -34,7 +48,35 @@ def render(merged: dict[str, object], source: str) -> str: "if transcript.status == aai.TranscriptStatus.error:", " raise RuntimeError(transcript.error)", "", - result, - "", ] + + if llm_gateway: + parts += _llm_gateway_block(llm_gateway) + else: + parts.append(snippets.result_handling(merged)) + + parts.append("") return "\n".join(parts) + + +def _llm_gateway_block(llm_gateway: dict[str, object]) -> list[str]: + """Emit an OpenAI-compatible LLM Gateway transform over the finished transcript. + + The gateway injects the transcript server-side via the ``transcript_id`` extra-body + field wherever the ``{{ transcript }}`` tag appears in the prompt. + """ + content = f"{llm_gateway['prompt']}\n\n{llm.TRANSCRIPT_TAG}" + return [ + "# Transform the transcript through AssemblyAI's LLM Gateway (OpenAI-compatible).", + "gateway = OpenAI(", + ' api_key=os.environ["ASSEMBLYAI_API_KEY"],', + f" base_url={llm.GATEWAY_BASE_URL!r},", + ")", + "response = gateway.chat.completions.create(", + f" model={llm_gateway['model']!r},", + f' messages=[{{"role": "user", "content": {content!r}}}],', + f" max_tokens={llm_gateway['max_tokens']},", + ' extra_body={"transcript_id": transcript.id},', + ")", + "print(response.choices[0].message.content)", + ] diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index ac680c4f..3d5e6929 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -177,7 +177,12 @@ def body(state: AppState, json_mode: bool) -> None: # transcribing or authenticating. Raw stdout so `--show-code > script.py` # yields a runnable file. audio = client.resolve_audio_source(source, sample=sample) - print(code_gen.transcribe(merged, audio)) + gateway = ( + {"prompt": llm_gateway_prompt, "model": model, "max_tokens": max_tokens} + if llm_gateway_prompt + else None + ) + print(code_gen.transcribe(merged, audio, llm_gateway=gateway)) return tc = config_builder.construct_transcription_config(merged) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index caab3e64..a96195ef 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -294,3 +294,39 @@ def test_every_snippet_execs_against_a_realistic_transcript(): def test_fuzz_result_handling_always_execs(merged): body = snippets.result_handling(merged) exec(compile(body, "", "exec"), {"transcript": _Stub(), "getattr": getattr}) # noqa: S102 + + +def test_transcribe_show_code_includes_llm_gateway_transform(): + code = code_gen.transcribe( + {"speaker_labels": True}, + "audio.mp3", + llm_gateway={ + "prompt": "translate to spanish", + "model": "claude-sonnet-4-6", + "max_tokens": 1000, + }, + ) + ast.parse(code) + assert "from openai import OpenAI" in code + assert "llm-gateway.assemblyai.com" in code + assert "translate to spanish" in code + assert "{{ transcript }}" in code # gateway injects the transcript at this tag + assert '"transcript_id": transcript.id' in code + # The LLM-gateway transform replaces the analysis result-handling (as the CLI does). + assert "transcript.utterances" not in code + + +def test_transcribe_show_code_without_gateway_has_no_openai_import(): + code = code_gen.transcribe({"speaker_labels": True}, "audio.mp3") + assert "from openai import OpenAI" not in code + assert "transcript.utterances" in code # normal result handling instead + + +def test_agent_show_code_uses_single_full_duplex_stream(): + # The CLI uses ONE sd.RawStream (mic+speaker); two separate streams fail on macOS. + code = code_gen.agent(voice="ivy", system_prompt="p", greeting="g") + ast.parse(code) + assert "sd.RawStream(" in code + assert "RawInputStream" not in code + assert "RawOutputStream" not in code + assert "audioop.ratecv" in code # device-rate <-> 24 kHz resampling diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index b8000f2c..9eb4d163 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -281,6 +281,24 @@ def _boom(*a, **k): assert "import assemblyai as aai" in result.output +def test_transcribe_show_code_includes_llm_gateway_without_running(monkeypatch): + # --llm-gateway-prompt must be reflected in the generated code, still without + # transcribing or calling the gateway. + def _boom(*a, **k): + raise AssertionError("must not call the API") + + monkeypatch.setattr("assemblyai_cli.commands.transcribe.client.transcribe", _boom) + monkeypatch.setattr("assemblyai_cli.commands.transcribe.llm.transform_transcript", _boom) + result = runner.invoke( + app, + ["transcribe", "--sample", "--llm-gateway-prompt", "translate to spanish", "--show-code"], + ) + assert result.exit_code == 0 + assert "llm-gateway.assemblyai.com" in result.output + assert "translate to spanish" in result.output + assert '"transcript_id": transcript.id' in result.output + + def test_transcribe_renders_summary_human(monkeypatch): _auth() monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) From 652fdf85665b39c7f3334f859ccc18acb05ba313 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:31:28 -0700 Subject: [PATCH 66/87] feat(transcribe): chain LLM Gateway prompts + distinct agent/you label colors - --llm-gateway-prompt is now repeatable: each prompt runs on the prior response (the first on the transcript), with per-step human/JSON output and matching --show-code generation. - Give "you:" and "agent:" distinct theme colors so the two speakers are easy to tell apart at a glance. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- assemblyai_cli/agent/render.py | 12 +++--- assemblyai_cli/code_gen/transcribe.py | 40 +++++++++++++------ assemblyai_cli/commands/transcribe.py | 55 +++++++++++++++++++-------- assemblyai_cli/theme.py | 4 ++ tests/test_code_gen.py | 22 ++++++++++- tests/test_transcribe.py | 37 +++++++++++++++++- 7 files changed, 135 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index cd411aa2..af768e62 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ aai transcribe --sample # transcribe the hosted wildfires.mp3 sample | --- | --- | | `aai login` / `logout` / `whoami` | Manage the stored API key. | | `aai doctor` | Check your environment is ready (API key, network, ffmpeg, microphone, agent tooling). | -| `aai transcribe ` | Transcribe an audio file, URL, or YouTube URL (`--sample` for a demo, `--llm-gateway-prompt` to transform the result). | +| `aai transcribe ` | Transcribe an audio file, URL, or YouTube URL (`--sample` for a demo, `--llm-gateway-prompt` to transform the result, `--show-code` to print the equivalent Python). | | `aai transcripts list` / `get ` | Browse and fetch past transcripts. | | `aai stream [file]` | Real-time transcription from a file or the microphone. | | `aai agent` | Live two-way voice conversation with a voice agent. | diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index 2859bd91..ad8d6381 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -7,9 +7,9 @@ from assemblyai_cli.render import BaseRenderer -def _labeled(label: str, body: str) -> Text: - """A line whose `label` prefix is brand-accented and whose body is default.""" - return Text.assemble((label, "aai.label"), body) +def _labeled(label: str, body: str, *, style: str = "aai.label") -> Text: + """A line whose `label` prefix is accented in `style` and whose body is default.""" + return Text.assemble((label, style), body) class AgentRenderer(BaseRenderer): @@ -39,13 +39,13 @@ def user_partial(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user.delta", "text": text}) return - self._update_line(_labeled("you: ", text)) + self._update_line(_labeled("you: ", text, style="aai.you")) def user_final(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user", "text": text}) return - self._finalize_line(_labeled("you: ", text)) + self._finalize_line(_labeled("you: ", text, style="aai.you")) # --- agent ------------------------------------------------------------- def reply_started(self) -> None: @@ -56,7 +56,7 @@ def agent_transcript(self, text: str, *, interrupted: bool) -> None: if self.json_mode: self._emit({"type": "transcript.agent", "text": text, "interrupted": interrupted}) return - self._line(_labeled("agent: ", text)) # commits any open "you: …" partial first + self._line(_labeled("agent: ", text, style="aai.agent")) # commits any open "you: …" partial first def reply_done(self, *, interrupted: bool) -> None: if self.json_mode: diff --git a/assemblyai_cli/code_gen/transcribe.py b/assemblyai_cli/code_gen/transcribe.py index bbb3ce4a..05eed243 100644 --- a/assemblyai_cli/code_gen/transcribe.py +++ b/assemblyai_cli/code_gen/transcribe.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from assemblyai_cli import llm from assemblyai_cli.code_gen import serialize, snippets @@ -60,23 +62,39 @@ def render( def _llm_gateway_block(llm_gateway: dict[str, object]) -> list[str]: - """Emit an OpenAI-compatible LLM Gateway transform over the finished transcript. + """Emit a chained OpenAI-compatible LLM Gateway transform over the transcript. - The gateway injects the transcript server-side via the ``transcript_id`` extra-body - field wherever the ``{{ transcript }}`` tag appears in the prompt. + The generated script loops over the prompts: the first runs over the transcript + (injected server-side via ``transcript_id`` wherever the ``{{ transcript }}`` tag + appears), and each subsequent prompt runs over the previous response. """ - content = f"{llm_gateway['prompt']}\n\n{llm.TRANSCRIPT_TAG}" + prompts = cast("list[str]", llm_gateway["prompts"]) + prompt_lines = "\n".join(f" {p!r}," for p in prompts) return [ "# Transform the transcript through AssemblyAI's LLM Gateway (OpenAI-compatible).", + "# Each prompt runs on the previous response; the first runs on the transcript.", "gateway = OpenAI(", ' api_key=os.environ["ASSEMBLYAI_API_KEY"],', f" base_url={llm.GATEWAY_BASE_URL!r},", ")", - "response = gateway.chat.completions.create(", - f" model={llm_gateway['model']!r},", - f' messages=[{{"role": "user", "content": {content!r}}}],', - f" max_tokens={llm_gateway['max_tokens']},", - ' extra_body={"transcript_id": transcript.id},', - ")", - "print(response.choices[0].message.content)", + "prompts = [", + prompt_lines, + "]", + "result = None", + "for i, prompt in enumerate(prompts):", + " if i == 0:", + f' content = prompt + "\\n\\n{llm.TRANSCRIPT_TAG}"', + ' extra = {"transcript_id": transcript.id}', + " else:", + ' content = prompt + "\\n\\n" + result', + " extra = None", + " response = gateway.chat.completions.create(", + f" model={llm_gateway['model']!r},", + ' messages=[{"role": "user", "content": content}],', + f" max_tokens={llm_gateway['max_tokens']},", + " extra_body=extra,", + " )", + " result = response.choices[0].message.content", + ' print(f"Step {i + 1}: {prompt}")', + " print(result)", ] diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 3d5e6929..21980f63 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -21,6 +21,14 @@ app = typer.Typer() +def _render_transform_steps(d: dict) -> str: + """Human view of chained LLM-Gateway steps: the lone output, or each step labeled.""" + steps = d["transform"]["steps"] + if len(steps) == 1: + return str(steps[0]["output"]) + return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) + + @app.command() def transcribe( ctx: typer.Context, @@ -101,8 +109,11 @@ def transcribe( ), config_file: str = typer.Option(None, "--config-file", help="JSON file of config fields."), # llm gateway transform (existing) - llm_gateway_prompt: str = typer.Option( - None, "--llm-gateway-prompt", help="Transform the finished transcript through LLM Gateway." + llm_gateway_prompt: list[str] = typer.Option( + None, + "--llm-gateway-prompt", + help="Transform the finished transcript through LLM Gateway. Repeatable: each " + "prompt runs on the previous one's response (a chain), the first on the transcript.", ), model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), @@ -178,7 +189,7 @@ def body(state: AppState, json_mode: bool) -> None: # yields a runnable file. audio = client.resolve_audio_source(source, sample=sample) gateway = ( - {"prompt": llm_gateway_prompt, "model": model, "max_tokens": max_tokens} + {"prompts": list(llm_gateway_prompt), "model": model, "max_tokens": max_tokens} if llm_gateway_prompt else None ) @@ -198,25 +209,37 @@ def body(state: AppState, json_mode: bool) -> None: transcript = client.transcribe(api_key, audio, config=tc) if llm_gateway_prompt: - transformed = llm.transform_transcript( - api_key, - prompt=llm_gateway_prompt, - model=model, - transcript_id=transcript.id, - max_tokens=max_tokens, - ) + # Chain the prompts: the first runs over the transcript (injected server-side + # via transcript_id); each subsequent prompt runs over the prior response. + steps: list[dict[str, str]] = [] + previous: str | None = None + for i, prompt_text in enumerate(llm_gateway_prompt): + if i == 0: + out = llm.transform_transcript( + api_key, + prompt=prompt_text, + model=model, + transcript_id=transcript.id, + max_tokens=max_tokens, + ) + else: + out = llm.transform_transcript( + api_key, + prompt=prompt_text, + model=model, + transcript_text=previous, + max_tokens=max_tokens, + ) + steps.append({"prompt": prompt_text, "output": out}) + previous = out output.emit( { "id": transcript.id, "status": client.status_str(transcript), "text": transcript.text, - "transform": { - "model": model, - "prompt": llm_gateway_prompt, - "output": transformed, - }, + "transform": {"model": model, "steps": steps}, }, - lambda d: str(d["transform"]["output"]), + _render_transform_steps, json_mode=json_mode, ) return diff --git a/assemblyai_cli/theme.py b/assemblyai_cli/theme.py index db4446ea..8b7654d8 100644 --- a/assemblyai_cli/theme.py +++ b/assemblyai_cli/theme.py @@ -22,6 +22,10 @@ "aai.brand": f"bold {BRAND}", "aai.heading": f"bold {BRAND}", "aai.label": BRAND, + # Conversation labels: the human keeps the brand accent, the agent gets a + # distinct hue so "you:" and "agent:" are easy to tell apart at a glance. + "aai.you": BRAND, + "aai.agent": "cyan", "aai.success": "green", "aai.error": "bold red", "aai.warn": "yellow", diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index a96195ef..d3b90297 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -301,7 +301,7 @@ def test_transcribe_show_code_includes_llm_gateway_transform(): {"speaker_labels": True}, "audio.mp3", llm_gateway={ - "prompt": "translate to spanish", + "prompts": ["translate to spanish"], "model": "claude-sonnet-4-6", "max_tokens": 1000, }, @@ -316,6 +316,26 @@ def test_transcribe_show_code_includes_llm_gateway_transform(): assert "transcript.utterances" not in code +def test_transcribe_show_code_chains_multiple_llm_gateway_prompts(): + code = code_gen.transcribe( + {}, + "audio.mp3", + llm_gateway={ + "prompts": ["summarize", "translate the summary to Spanish"], + "model": "claude-sonnet-4-6", + "max_tokens": 500, + }, + ) + ast.parse(code) + # Both prompts appear, in order, and the script loops to chain them. + assert "'summarize'," in code + assert "'translate the summary to Spanish'," in code + assert "for i, prompt in enumerate(prompts):" in code + # First step uses the transcript; later steps chain on the previous result. + assert '"transcript_id": transcript.id' in code + assert 'content = prompt + "\\n\\n" + result' in code + + def test_transcribe_show_code_without_gateway_has_no_openai_import(): code = code_gen.transcribe({"speaker_labels": True}, "audio.mp3") assert "from openai import OpenAI" not in code diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 9eb4d163..3ca36a69 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -113,13 +113,46 @@ def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens): assert result.exit_code == 0 data = json.loads(result.output) assert data["text"] == "hello world" # raw transcript still present in JSON - assert data["transform"]["output"] == "a short summary" - assert data["transform"]["prompt"] == "summarize" + steps = data["transform"]["steps"] + assert steps == [{"prompt": "summarize", "output": "a short summary"}] # The transform is injected server-side via the transcript id. assert seen["transcript_id"] == "t_1" assert seen["model"] == "claude-sonnet-4-6" +def test_transcribe_chains_multiple_gateway_prompts(monkeypatch): + _auth() + calls = [] + + def fake_transform(api_key, *, prompt, model, max_tokens, transcript_id=None, transcript_text=None): + calls.append({"prompt": prompt, "transcript_id": transcript_id, "transcript_text": transcript_text}) + return f"out({prompt})" + + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + monkeypatch.setattr( + "assemblyai_cli.commands.transcribe.llm.transform_transcript", fake_transform + ) + result = runner.invoke( + app, + [ + "transcribe", "audio.mp3", "--json", + "--llm-gateway-prompt", "summarize", + "--llm-gateway-prompt", "translate", + ], + ) + assert result.exit_code == 0 + # Step 1 runs over the transcript; step 2 chains over step 1's output. + assert calls[0]["transcript_id"] == "t_1" and calls[0]["transcript_text"] is None + assert calls[1]["transcript_id"] is None and calls[1]["transcript_text"] == "out(summarize)" + steps = json.loads(result.output)["transform"]["steps"] + assert steps == [ + {"prompt": "summarize", "output": "out(summarize)"}, + {"prompt": "translate", "output": "out(translate)"}, + ] + + def test_transcribe_prompt_human_shows_only_transform(monkeypatch): _auth() monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) From 1eef4724c9030b28516a935e049e324e17048332 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:31:32 -0700 Subject: [PATCH 67/87] feat(transcribe): chain repeatable --llm-gateway-prompt; reflect in --show-code Each --llm-gateway-prompt now runs on the previous prompt's response (the first over the transcript), printing every step labeled. --show-code emits the equivalent chained loop. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/agent/render.py | 4 +++- tests/test_transcribe.py | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index ad8d6381..c71fcc61 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -56,7 +56,9 @@ def agent_transcript(self, text: str, *, interrupted: bool) -> None: if self.json_mode: self._emit({"type": "transcript.agent", "text": text, "interrupted": interrupted}) return - self._line(_labeled("agent: ", text, style="aai.agent")) # commits any open "you: …" partial first + self._line( + _labeled("agent: ", text, style="aai.agent") + ) # commits any open "you: …" partial first def reply_done(self, *, interrupted: bool) -> None: if self.json_mode: diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 3ca36a69..d1af2dce 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -124,8 +124,12 @@ def test_transcribe_chains_multiple_gateway_prompts(monkeypatch): _auth() calls = [] - def fake_transform(api_key, *, prompt, model, max_tokens, transcript_id=None, transcript_text=None): - calls.append({"prompt": prompt, "transcript_id": transcript_id, "transcript_text": transcript_text}) + def fake_transform( + api_key, *, prompt, model, max_tokens, transcript_id=None, transcript_text=None + ): + calls.append( + {"prompt": prompt, "transcript_id": transcript_id, "transcript_text": transcript_text} + ) return f"out({prompt})" with patch( @@ -137,9 +141,13 @@ def fake_transform(api_key, *, prompt, model, max_tokens, transcript_id=None, tr result = runner.invoke( app, [ - "transcribe", "audio.mp3", "--json", - "--llm-gateway-prompt", "summarize", - "--llm-gateway-prompt", "translate", + "transcribe", + "audio.mp3", + "--json", + "--llm-gateway-prompt", + "summarize", + "--llm-gateway-prompt", + "translate", ], ) assert result.exit_code == 0 From 0f1813d75b00e0e123b04959c5e7dacb7c32d122 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:32:30 -0700 Subject: [PATCH 68/87] docs(readme): document --show-code (print-only, redirect, chaining) + uv run Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index af768e62..044b004f 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,32 @@ The agent is full-duplex — your mic stays open while it speaks, so you can int mid-sentence (barge-in). **Use headphones**, otherwise the agent hears itself on your speakers. +## Show the code + +Add `--show-code` to `transcribe`, `stream`, or `agent` to print the equivalent Python +SDK code **instead of running** the command — a ready-to-edit starting point for your +own app. It builds the script from exactly the flags you passed, needs no API key +(the generated code reads `ASSEMBLYAI_API_KEY` from the environment), and writes plain +Python to stdout, so you can redirect it straight into a file: + +```sh +aai transcribe --sample --speaker-labels --show-code # print the equivalent script +aai transcribe call.mp3 --sentiment-analysis --show-code > my_transcribe.py +aai stream --show-code # the microphone-streaming idiom +aai agent --voice ivy --show-code # the full-duplex agent loop +``` + +The generated transcribe code includes result handling for the analysis features you +enabled. With `--llm-gateway-prompt` (repeatable — each prompt runs on the previous +response), it emits the chained LLM Gateway calls too: + +```sh +aai transcribe call.mp3 \ + --llm-gateway-prompt "summarize" \ + --llm-gateway-prompt "translate the summary to Spanish" \ + --show-code > summarize_then_translate.py +``` + ## AI coding agents Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI skill so @@ -157,7 +183,12 @@ skipped (with the manual command to run), not treated as an error. ## Development +This project uses [uv](https://docs.astral.sh/uv/). Run tools through `uv run` so they +use the locked environment (`pyproject.toml` + `uv.lock`): + ```sh -pip install -e ".[dev]" +uv sync --extra dev # create/refresh the project venv with dev dependencies +uv run aai --help # run the CLI from the locked environment +uv run pytest # run the test suite (uv run mypy / ruff likewise) ./scripts/check.sh # ruff + mypy + pytest (the same checks CI runs on every PR) ``` From 1b9bbbc101ed16b35533c7755ba97885b4a8cf7c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:36:12 -0700 Subject: [PATCH 69/87] fix(show-code): generated agent uses 24 kHz duplex stream, drops audioop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated agent script resampled via audioop, which warns on 3.12 and is removed in 3.13. The agent's audio is already 24 kHz and the device accepts it, so open one full-duplex sd.RawStream at 24 kHz with no resampling — simpler, no deprecation warning, and portable to 3.13. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/code_gen/agent.py | 23 ++++++----------------- tests/test_code_gen.py | 6 ++++-- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/assemblyai_cli/code_gen/agent.py b/assemblyai_cli/code_gen/agent.py index 7d78d832..fd599337 100644 --- a/assemblyai_cli/code_gen/agent.py +++ b/assemblyai_cli/code_gen/agent.py @@ -8,7 +8,6 @@ _TEMPLATE = """# Live two-way voice conversation with an AssemblyAI voice agent. # Requires audio support: pip install sounddevice websockets # Tip: use headphones — the mic stays open while the agent speaks. -import audioop import base64 import json import os @@ -22,25 +21,17 @@ WS_URL = "wss://agents.assemblyai.com/v1/ws" RATE = 24000 # Voice Agent native PCM16 mono sample rate -# ONE full-duplex stream (mic + speaker together). Opening two separate streams on a -# single device fails on macOS CoreAudio, which silently kills capture. Audio is -# captured at the device's native rate and resampled to/from the agent's 24 kHz. -device_rate = int(sd.query_devices(None, "output")["default_samplerate"]) -blocksize = max(1, device_rate // 10) # ~100 ms - +# ONE full-duplex stream (mic + speaker together) at the agent's native 24 kHz. Opening +# two separate input/output streams on one device fails on macOS CoreAudio, which +# silently kills capture; a single sd.RawStream callback handles both directions. mic_queue: queue.Queue = queue.Queue() play_buffer = bytearray() buffer_lock = threading.Lock() ready = threading.Event() -_capture_state = None # audioop.ratecv state: device_rate -> 24 kHz -_play_state = None # audioop.ratecv state: 24 kHz -> device_rate def on_audio(indata, outdata, _frames, _time, _status): - global _capture_state - # Capture: resample the mic input to 24 kHz and queue it for the agent. - chunk, _capture_state = audioop.ratecv(bytes(indata), 2, 1, device_rate, RATE, _capture_state) - mic_queue.put_nowait(chunk) + mic_queue.put_nowait(bytes(indata)) # capture -> queue for the agent # Playback: drain the agent's audio into the output, zero-filling any shortfall. needed = len(outdata) with buffer_lock: @@ -59,7 +50,7 @@ def send_mic(ws): stream = sd.RawStream( - samplerate=device_rate, channels=1, dtype="int16", blocksize=blocksize, callback=on_audio + samplerate=RATE, channels=1, dtype="int16", blocksize=RATE // 10, callback=on_audio ) stream.start() @@ -81,10 +72,8 @@ def send_mic(ws): if etype == "session.ready": ready.set() elif etype == "reply.audio" and event.get("data"): - pcm = base64.b64decode(event["data"]) - pcm, _play_state = audioop.ratecv(pcm, 2, 1, RATE, device_rate, _play_state) with buffer_lock: - play_buffer += pcm + play_buffer += base64.b64decode(event["data"]) elif etype == "transcript.user": print("you: ", event.get("text", "")) elif etype == "transcript.agent": diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index d3b90297..59d691fe 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -343,10 +343,12 @@ def test_transcribe_show_code_without_gateway_has_no_openai_import(): def test_agent_show_code_uses_single_full_duplex_stream(): - # The CLI uses ONE sd.RawStream (mic+speaker); two separate streams fail on macOS. + # ONE sd.RawStream (mic+speaker); two separate streams fail on macOS CoreAudio. code = code_gen.agent(voice="ivy", system_prompt="p", greeting="g") ast.parse(code) assert "sd.RawStream(" in code + assert "samplerate=RATE" in code # opens at the agent's native 24 kHz, no resampling assert "RawInputStream" not in code assert "RawOutputStream" not in code - assert "audioop.ratecv" in code # device-rate <-> 24 kHz resampling + # No audioop: it's deprecated and removed in Python 3.13, so the script stays portable. + assert "audioop" not in code From 5d3b758709f1882f36a34ec24a34bfb080649c2c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:45:47 -0700 Subject: [PATCH 70/87] fix(deps): provide audioop on Python 3.13 via audioop-lts backport audioop (used by the PCM resampler in microphone.py / agent audio) left the stdlib in 3.13; add the audioop-lts backport under a python_version >= 3.13 marker so live stream/agent audio keeps working there. Generated --show-code samples don't use audioop at all (the agent script runs at the native 24 kHz). Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/claude.py | 9 ++++- assemblyai_cli/microphone.py | 4 +- pyproject.toml | 2 + tests/test_claude.py | 42 +++++++++++++++++++++ uv.lock | 63 ++++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py index db0c935a..f0fc2b6b 100644 --- a/assemblyai_cli/commands/claude.py +++ b/assemblyai_cli/commands/claude.py @@ -92,13 +92,18 @@ def _install_mcp(scope: str, force: bool) -> Step: _SKILL_ADD_HINT = f"npx skills add {SKILL_REPO} --global" -def _install_skill() -> Step: +def _install_skill(force: bool) -> Step: if shutil.which("npx") is None: return { "name": "skill", "status": "skipped", "detail": f"Node.js/npx not found. Install Node.js, then run: {_SKILL_ADD_HINT}", } + # Idempotent like the MCP step: if the skill is already on disk and the user + # didn't ask to --force, report `already` instead of silently re-downloading + # it and always claiming `installed`. + if _skill_installed() and not force: + return {"name": "skill", "status": "already", "detail": f"assemblyai skill at {_skill_dir()}"} # --global: install at user scope (not project scope, which `skills` auto-selects # when run inside a project) so the skill lands in ~/.claude/skills where `status` # looks. npx -y skips its install prompt; the longer timeout covers the download. @@ -214,7 +219,7 @@ def body(_state: AppState, json_mode: bool) -> None: raise UsageError( f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." ) - steps = [_install_mcp(scope, force), _install_skill()] + steps = [_install_mcp(scope, force), _install_skill(force)] output.emit({"steps": steps}, _render_steps, json_mode=json_mode) if any(s["status"] == "failed" for s in steps): raise typer.Exit(code=1) diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py index c482f330..a596620c 100644 --- a/assemblyai_cli/microphone.py +++ b/assemblyai_cli/microphone.py @@ -41,7 +41,9 @@ def _resample(chunk: bytes, state: Any, *, src_rate: int, dst_rate: int) -> tupl import warnings with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) # audioop is deprecated but stdlib + # audioop is deprecated stdlib on 3.11/3.12 (warning suppressed here) and is + # provided by the `audioop-lts` package on 3.13+, where it left the stdlib. + warnings.simplefilter("ignore", DeprecationWarning) import audioop return audioop.ratecv(chunk, 2, 1, src_rate, dst_rate, state) diff --git a/pyproject.toml b/pyproject.toml index 50b95e4c..28e41007 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ dependencies = [ "sounddevice>=0.5", "openai>=1.40", "yt-dlp>=2024.0", + # audioop (used for PCM resampling) left the stdlib in 3.13; this backport provides it. + "audioop-lts>=0.2; python_version >= '3.13'", ] [project.urls] diff --git a/tests/test_claude.py b/tests/test_claude.py index d03fd8ca..23019eeb 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -176,6 +176,48 @@ def test_install_idempotent_when_mcp_present(monkeypatch): assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) +def test_install_skill_idempotent_when_present(monkeypatch): + # Regression: a repeat install must report the skill as `already` (like MCP), + # not re-run `npx skills add` and claim `installed` every time. + _all_tools_present(monkeypatch) + skill = _skill_path() + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses["skill"] == "already" + # No `npx … add` should have run — the skill was already present. + assert not any(c[0] == "npx" and "add" in c for c in fake.calls) + + +def test_install_force_reinstalls_skill(monkeypatch): + # --force must re-run `npx skills add` even when the skill is already present. + _all_tools_present(monkeypatch) + skill = _skill_path() + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install", "--force"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses["skill"] == "installed" + assert [ + "npx", + "-y", + "skills", + "add", + "AssemblyAI/assemblyai-skill", + "--global", + "--yes", + ] in fake.calls + + def test_install_force_removes_then_adds(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 0}) diff --git a/uv.lock b/uv.lock index 312df03a..d2fd8552 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,8 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version == '3.14.*'", - "python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [[package]] @@ -61,6 +62,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "assemblyai" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "keyring" }, { name = "openai" }, { name = "platformdirs" }, @@ -85,6 +87,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "assemblyai", specifier = ">=0.34" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "keyring", specifier = ">=24.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, @@ -143,6 +146,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -540,7 +599,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.14'" }, + { name = "zipp", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ From 7880f1f6a3dc11e1350d7660432e3790d7e845ee Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:50:26 -0700 Subject: [PATCH 71/87] feat(llm): read piped stdin as prompt content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cat notes.txt | aai llm 'summarize' (and aai transcribe -o text | aai llm '...') now work — stdin text is injected into the prompt. An explicit --transcript-id still takes priority. Adds a small stdio.piped_stdin_text() helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/llm.py | 10 +++++++-- assemblyai_cli/stdio.py | 17 ++++++++++++++++ tests/test_llm_command.py | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 assemblyai_cli/stdio.py diff --git a/assemblyai_cli/commands/llm.py b/assemblyai_cli/commands/llm.py index c3758104..b6cdf0b8 100644 --- a/assemblyai_cli/commands/llm.py +++ b/assemblyai_cli/commands/llm.py @@ -3,7 +3,7 @@ import typer from rich.markup import escape -from assemblyai_cli import config, output +from assemblyai_cli import config, output, stdio from assemblyai_cli import llm as gateway from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError @@ -15,6 +15,7 @@ def llm( ctx: typer.Context, prompt: str = typer.Argument(None, help="The prompt to send to the model."), + # Note: text piped on stdin is injected into the prompt (e.g. `cat notes | aai llm "summarize"`). model: str = typer.Option(gateway.DEFAULT_MODEL, "--model", help="LLM Gateway model."), transcript_id: str = typer.Option( None, "--transcript-id", help="Inject this transcript's text into the prompt." @@ -40,7 +41,12 @@ def body(state: AppState, json_mode: bool) -> None: if not prompt: raise UsageError("Provide a prompt, or use --list-models.") api_key = config.resolve_api_key(profile=state.profile) - messages = gateway.build_messages(prompt, system=system, transcript_id=transcript_id) + # Text piped on stdin becomes the content the prompt operates on, unless an + # explicit --transcript-id is given (that injects server-side and takes priority). + stdin_text = stdio.piped_stdin_text() if not transcript_id else None + messages = gateway.build_messages( + prompt, system=system, transcript_id=transcript_id, transcript_text=stdin_text + ) response = gateway.complete( api_key, model=model, diff --git a/assemblyai_cli/stdio.py b/assemblyai_cli/stdio.py new file mode 100644 index 00000000..2103be6d --- /dev/null +++ b/assemblyai_cli/stdio.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import sys + + +def piped_stdin_text() -> str | None: + """Return text piped on stdin, or None when stdin is a terminal or empty. + + Lets commands accept input from a pipe (e.g. ``cat notes.txt | aai llm ...`` or + ``aai transcribe x.mp3 -o text | aai llm "summarize"``) without blocking when run + interactively. + """ + stream = sys.stdin + if stream is None or stream.isatty(): + return None + data = stream.read() + return data if data.strip() else None diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index d72dc66c..e69d4db6 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -74,6 +74,43 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): assert "{{ transcript }}" in seen["content"] +def test_llm_reads_content_from_stdin(monkeypatch): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + seen["content"] = messages[0]["content"] + seen["transcript_id"] = transcript_id + return _payload("done") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke(app, ["llm", "summarize", "--json"], input="meeting notes here") + assert result.exit_code == 0 + # The piped text is injected into the prompt content; no transcript id is used. + assert "summarize" in seen["content"] + assert "meeting notes here" in seen["content"] + assert seen["transcript_id"] is None + + +def test_llm_transcript_id_takes_priority_over_stdin(monkeypatch): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + seen["content"] = messages[0]["content"] + seen["transcript_id"] = transcript_id + return _payload("s") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke( + app, ["llm", "summarize", "--transcript-id", "t_9", "--json"], input="ignored stdin" + ) + assert result.exit_code == 0 + assert seen["transcript_id"] == "t_9" + assert "ignored stdin" not in seen["content"] + assert "{{ transcript }}" in seen["content"] + + def test_llm_missing_prompt_exits_2(monkeypatch): _auth() monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) From f3fcecd987eebafa64208f20cb40f9309752d64e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:52:46 -0700 Subject: [PATCH 72/87] feat(transcribe): -o/--output field selector (text|id|status|utterances|json) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Print a single field raw to stdout for pipelines — no jq needed for the common case. e.g. aai transcribe x.mp3 -o text | aai llm 'summarize'. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/transcribe.py | 41 +++++++++++++++++++++++++++ tests/test_transcribe.py | 29 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 21980f63..6190210e 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -3,6 +3,7 @@ import json import tempfile from pathlib import Path +from typing import Any import typer @@ -17,6 +18,7 @@ youtube, ) from assemblyai_cli.context import AppState, run_command +from assemblyai_cli.errors import UsageError app = typer.Typer() @@ -29,6 +31,30 @@ def _render_transform_steps(d: dict) -> str: return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) +_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "json") + + +def _select_output(transcript: Any, field: str) -> str: + """Render a single transcript field for ``-o/--output`` (raw, pipe-friendly).""" + if field == "id": + return str(getattr(transcript, "id", "") or "") + if field == "status": + return client.status_str(transcript) + if field == "utterances": + utterances = getattr(transcript, "utterances", None) or [] + if utterances: + return "\n".join(f"Speaker {u.speaker}: {u.text}" for u in utterances) + return str(getattr(transcript, "text", "") or "") + if field == "json": + payload = getattr(transcript, "json_response", None) or { + "id": transcript.id, + "status": client.status_str(transcript), + "text": transcript.text, + } + return json.dumps(payload, default=str) + return str(getattr(transcript, "text", "") or "") # "text" (and the validated default) + + @app.command() def transcribe( ctx: typer.Context, @@ -118,6 +144,12 @@ def transcribe( model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + output_field: str = typer.Option( + None, + "-o", + "--output", + help="Print one field of the result: text, id, status, utterances, or json.", + ), show_code: bool = typer.Option( False, "--show-code", @@ -132,6 +164,10 @@ def transcribe( """ def body(state: AppState, json_mode: bool) -> None: + if output_field is not None and output_field not in _OUTPUT_FIELDS: + raise UsageError( + f"Unknown --output {output_field!r}. Choose one of: {', '.join(_OUTPUT_FIELDS)}." + ) flags: dict[str, object] = { "speech_model": speech_model, "language_code": language_code, @@ -208,6 +244,11 @@ def body(state: AppState, json_mode: bool) -> None: else: transcript = client.transcribe(api_key, audio, config=tc) + if output_field is not None: + # Raw single-field output for pipelines (overrides --json and analysis render). + print(_select_output(transcript, output_field)) + return + if llm_gateway_prompt: # Chain the prompts: the first runs over the transcript (injected server-side # via transcript_id); each subsequent prompt runs over the prior response. diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index d1af2dce..37a92616 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -78,6 +78,35 @@ def test_transcribe_unauthenticated_exits_2(): assert result.exit_code == 2 +def test_transcribe_output_text_field(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "text"]) + assert result.exit_code == 0 + assert result.output.strip() == "hello world" # raw text, pipe-friendly + + +def test_transcribe_output_id_field(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--output", "id"]) + assert result.exit_code == 0 + assert result.output.strip() == "t_1" + + +def test_transcribe_output_invalid_exits_2(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "bogus"]) + assert result.exit_code == 2 # unknown field rejected + + def test_transcribe_status_renders_enum_value(): import assemblyai as aai From 598b39bd6ab6ef5f9f8faff031f0cdd63f59b897 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 18:58:00 -0700 Subject: [PATCH 73/87] feat(stdin): accept '-' audio on stdin for transcribe and stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aai transcribe - buffers piped bytes to a temp file the SDK uploads (e.g. ffmpeg -i v.mp4 -f wav - | aai transcribe -). - aai stream - streams raw PCM16 mono from stdin via a new StdinSource (e.g. ffmpeg … -f s16le - | aai stream -); --sample-rate sets the rate. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/stream.py | 16 ++++++++++++---- assemblyai_cli/commands/transcribe.py | 24 ++++++++++++++++++------ assemblyai_cli/stdio.py | 11 +++++++++++ assemblyai_cli/streaming/sources.py | 25 +++++++++++++++++++++++++ tests/test_stream_command.py | 21 +++++++++++++++++++++ tests/test_transcribe.py | 24 ++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 10 deletions(-) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 8a23dc9e..00fb9194 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -11,7 +11,7 @@ from assemblyai_cli.errors import UsageError from assemblyai_cli.microphone import MicrophoneSource from assemblyai_cli.streaming.render import StreamRenderer -from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource +from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource app = typer.Typer() @@ -150,8 +150,12 @@ def make_flags(rate: int) -> dict[str, object]: return api_key = config.resolve_api_key(profile=state.profile) + from_stdin = source == "-" from_file = bool(source) or sample - if from_file and (sample_rate is not None or device is not None): + if from_stdin: + if device is not None: + raise UsageError("--device applies only to microphone input.") + elif from_file and (sample_rate is not None or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") renderer = StreamRenderer(json_mode=json_mode) @@ -165,7 +169,7 @@ def on_turn(event: object) -> None: if text: turns.append(text) - def run(audio: FileSource | MicrophoneSource, rate: int) -> None: + def run(audio: FileSource | MicrophoneSource | StdinSource, rate: int) -> None: merged = config_builder.merge_streaming_params( flags=make_flags(rate), overrides=list(config_kv or []), config_file=config_file ) @@ -200,7 +204,11 @@ def run(audio: FileSource | MicrophoneSource, rate: int) -> None: ) renderer.llm(transformed) - if source and youtube.is_youtube_url(source): + if from_stdin: + # Raw PCM16 mono piped on stdin (e.g. `ffmpeg … -f s16le - | aai stream -`). + stdin_src = StdinSource(sample_rate=sample_rate or TARGET_RATE) + run(stdin_src, stdin_src.sample_rate) + elif source and youtube.is_youtube_url(source): # Fetch the audio first, then stream the local file in real time. with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: local = youtube.download_audio(source, Path(td)) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 6190210e..01ec72bb 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -14,6 +14,7 @@ config_builder, llm, output, + stdio, transcribe_render, youtube, ) @@ -234,15 +235,26 @@ def body(state: AppState, json_mode: bool) -> None: tc = config_builder.construct_transcription_config(merged) - audio = client.resolve_audio_source(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) - if youtube.is_youtube_url(audio): - # Fetch the audio first; AssemblyAI can't read a YouTube watch URL itself. - with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: - local = youtube.download_audio(audio, Path(td)) + if source == "-": + # Audio piped on stdin (e.g. `ffmpeg -i v.mp4 -f wav - | aai transcribe -`). + # The SDK uploads a path, so buffer the bytes to a temp file first. + data = stdio.read_binary_stdin() + if not data: + raise UsageError("No audio received on stdin.") + with tempfile.TemporaryDirectory(prefix="aai-stdin-") as td: + local = Path(td) / "audio" + local.write_bytes(data) transcript = client.transcribe(api_key, str(local), config=tc) else: - transcript = client.transcribe(api_key, audio, config=tc) + audio = client.resolve_audio_source(source, sample=sample) + if youtube.is_youtube_url(audio): + # Fetch first; AssemblyAI can't read a YouTube watch URL itself. + with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: + local = youtube.download_audio(audio, Path(td)) + transcript = client.transcribe(api_key, str(local), config=tc) + else: + transcript = client.transcribe(api_key, audio, config=tc) if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). diff --git a/assemblyai_cli/stdio.py b/assemblyai_cli/stdio.py index 2103be6d..18da4a8b 100644 --- a/assemblyai_cli/stdio.py +++ b/assemblyai_cli/stdio.py @@ -15,3 +15,14 @@ def piped_stdin_text() -> str | None: return None data = stream.read() return data if data.strip() else None + + +def read_binary_stdin() -> bytes: + """Read all bytes piped on stdin, for a ``-`` audio source. + + Used by ``cat call.wav | aai transcribe -`` and ``ffmpeg … | aai transcribe -``. + """ + buffer = getattr(sys.stdin, "buffer", None) + if buffer is None: # e.g. a text-only stub in tests + return sys.stdin.read().encode() if sys.stdin is not None else b"" + return bytes(buffer.read()) diff --git a/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py index a1244e51..ae93a4cf 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/assemblyai_cli/streaming/sources.py @@ -3,10 +3,12 @@ import contextlib import shutil import subprocess +import sys import time import wave from collections.abc import Callable, Iterator from pathlib import Path +from typing import Any from assemblyai_cli.errors import APIError, CLIError @@ -132,5 +134,28 @@ def _ffmpeg_chunks(self) -> Iterator[bytes]: ) +class StdinSource: + """Streams raw PCM16 mono audio piped on stdin. + + Expects signed 16-bit little-endian mono PCM at ``sample_rate`` (default 16 kHz): + ``ffmpeg -i in.mp4 -f s16le -acodec pcm_s16le -ac 1 -ar 16000 - | aai stream -``. + """ + + def __init__(self, *, sample_rate: int = TARGET_RATE, stdin: Any = None) -> None: + self.source = "-" + self.sample_rate = sample_rate + self._stdin = stdin # injectable for tests; defaults to sys.stdin.buffer + + def __iter__(self) -> Iterator[bytes]: + stream: Any = self._stdin + if stream is None: + stream = getattr(sys.stdin, "buffer", sys.stdin) + while True: + data = stream.read(CHUNK_BYTES) + if not data: + return + yield bytes(data) + + # MicrophoneSource (mic capture) lives in assemblyai_cli.microphone and is shared # with the voice agent; FileSource above is the only streaming-specific source. diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index ec6743a3..4bcd2fd9 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -441,3 +441,24 @@ def _boom(*a, **k): result = runner.invoke(app, ["stream", "--show-code", "--json"]) assert result.exit_code == 0 assert "StreamingClient(" in result.output + + +def test_stream_reads_raw_pcm_from_stdin(monkeypatch): + config.set_api_key("default", "sk_live") + seen = {} + + def fake_stream_audio(api_key, source, *, params, on_begin=None, **_kwargs): + seen["rate"] = params.sample_rate + seen["audio"] = b"".join(source) # consume the StdinSource + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + result = runner.invoke(app, ["stream", "-"], input=b"\x01\x02" * 100) + assert result.exit_code == 0 + assert seen["rate"] == 16000 # default raw-PCM rate + assert seen["audio"] == b"\x01\x02" * 100 + + +def test_stream_stdin_rejects_device(monkeypatch): + config.set_api_key("default", "sk_live") + result = runner.invoke(app, ["stream", "-", "--device", "2"], input=b"\x00\x00") + assert result.exit_code == 2 # --device applies only to the microphone diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 37a92616..1cefa1cd 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -107,6 +107,30 @@ def test_transcribe_output_invalid_exits_2(): assert result.exit_code == 2 # unknown field rejected +def test_transcribe_reads_audio_from_stdin(monkeypatch): + import pathlib + + _auth() + seen = {} + + def fake_transcribe(api_key, audio, *, config): + # The piped bytes are buffered to a temp file the SDK can upload. + seen["bytes"] = pathlib.Path(audio).read_bytes() + return _fake_transcript() + + monkeypatch.setattr("assemblyai_cli.commands.transcribe.client.transcribe", fake_transcribe) + result = runner.invoke(app, ["transcribe", "-", "-o", "text"], input=b"RIFFfake-wav-bytes") + assert result.exit_code == 0 + assert result.output.strip() == "hello world" + assert seen["bytes"] == b"RIFFfake-wav-bytes" + + +def test_transcribe_empty_stdin_exits_2(): + _auth() + result = runner.invoke(app, ["transcribe", "-"], input=b"") + assert result.exit_code == 2 # nothing piped -> usage error + + def test_transcribe_status_renders_enum_value(): import assemblyai as aai From 9d850b92e242661a4f988c2552cf737f3cf716f5 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:03:03 -0700 Subject: [PATCH 74/87] feat(stream): -o text emits plain finalized turns (pipe into aai llm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piped stream defaulted to NDJSON, so 'aai stream | aai llm' fed JSON to the model. 'aai stream -o text' now prints only finalized turn transcripts as plain stdout lines, with status ('Listening…') on stderr — clean for pipelines. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/stream.py | 15 +++++++++- assemblyai_cli/streaming/render.py | 45 ++++++++++++++++++++++++++++-- tests/test_stream_command.py | 17 +++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 00fb9194..466aff8e 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -92,6 +92,12 @@ def stream( model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), + output_field: str = typer.Option( + None, + "-o", + "--output", + help="Output mode: 'text' (finalized turns as plain lines, pipe-friendly) or 'json'.", + ), show_code: bool = typer.Option( False, "--show-code", @@ -105,6 +111,9 @@ def stream( """ def body(state: AppState, json_mode: bool) -> None: + if output_field is not None and output_field not in ("text", "json"): + raise UsageError(f"Unknown --output {output_field!r}. Choose one of: text, json.") + def make_flags(rate: int) -> dict[str, object]: flags: dict[str, object] = { "sample_rate": rate, @@ -158,7 +167,11 @@ def make_flags(rate: int) -> dict[str, object]: elif from_file and (sample_rate is not None or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") - renderer = StreamRenderer(json_mode=json_mode) + text_mode = output_field == "text" + renderer = StreamRenderer( + json_mode=(output_field == "json") or (json_mode and not text_mode), + text_mode=text_mode, + ) # Collect finalized turns so we can transform the full transcript at the end. turns: list[str] = [] diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index 3906744c..a62738ae 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -1,12 +1,40 @@ from __future__ import annotations +import sys +from typing import TextIO + +from rich.console import Console from rich.text import Text from assemblyai_cli.render import BaseRenderer class StreamRenderer(BaseRenderer): - """Renders streaming events: a live-updating line for humans, NDJSON for agents.""" + """Renders streaming events in one of three modes. + + - JSON: newline-delimited JSON to stdout (pipe-safe, machine-readable). + - text: only finalized turn transcripts, one plain line each, to stdout; status + notices ("Listening…") go to stderr. Lets `aai stream -o text | aai llm "…"` + pipe clean transcript text downstream. + - human (default): a live-updating line through Rich. + """ + + def __init__( + self, + *, + json_mode: bool, + text_mode: bool = False, + out: TextIO | None = None, + console: Console | None = None, + err: TextIO | None = None, + ) -> None: + super().__init__(json_mode=json_mode, out=out, console=console) + self.text_mode = text_mode + self._err = err if err is not None else sys.stderr + + def _status(self, message: str) -> None: + """Write a status notice to stderr so it never pollutes piped stdout.""" + print(message, file=self._err, flush=True) def begin(self, event: object) -> None: # The "Listening…" notice waits for the mic (see listening()); opening the @@ -16,7 +44,9 @@ def begin(self, event: object) -> None: def listening(self) -> None: """Announce capture has started — called once the mic is open and recording.""" - if not self.json_mode: + if self.text_mode: + self._status("Listening… (Ctrl-C to stop)") + elif not self.json_mode: self._line(Text("Listening… (Ctrl-C to stop)", style="aai.muted")) def turn(self, event: object) -> None: @@ -24,6 +54,9 @@ def turn(self, event: object) -> None: end = bool(getattr(event, "end_of_turn", False)) if self.json_mode: self._emit({"type": "turn", "transcript": text, "end_of_turn": end}) + elif self.text_mode: + if end and text: + self._write(text + "\n") # plain finalized line, pipe-friendly elif end: self._finalize_line(text) else: @@ -44,5 +77,13 @@ def llm(self, content: str) -> None: return if self.json_mode: self._emit({"type": "llm", "content": content}) + elif self.text_mode: + self._write(content + "\n") else: self._line(Text("\N{ELECTRIC LIGHT BULB} " + content, style="aai.brand")) + + def stopped(self) -> None: + if self.text_mode: + self._status("Stopped.") + else: + super().stopped() diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 4bcd2fd9..840f6562 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -462,3 +462,20 @@ def test_stream_stdin_rejects_device(monkeypatch): config.set_api_key("default", "sk_live") result = runner.invoke(app, ["stream", "-", "--device", "2"], input=b"\x00\x00") assert result.exit_code == 2 # --device applies only to the microphone + + +def test_stream_output_text_emits_plain_finalized_turns(monkeypatch): + # `-o text` -> only finalized transcripts as plain stdout lines (pipe into aai llm). + config.set_api_key("default", "sk_live") + + def fake_stream_audio(api_key, source, *, params, on_begin=None, on_turn=None, **_kwargs): + if on_turn: + on_turn(types.SimpleNamespace(transcript="partial", end_of_turn=False)) + on_turn(types.SimpleNamespace(transcript="hello world", end_of_turn=True)) + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + result = runner.invoke(app, ["stream", "-", "-o", "text"], input=b"\x00\x00") + assert result.exit_code == 0 + # Final turn only, plain text; partials and JSON envelopes are not on stdout. + assert result.output.strip() == "hello world" + assert '"type"' not in result.output From 5c6158e17200de8680bfa63ff6ea86b2c2070723 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:06:47 -0700 Subject: [PATCH 75/87] feat(agent): -o text emits plain you:/agent: transcript lines (pipe into aai llm) Mirrors 'stream -o text': finalized transcript lines to stdout, status to stderr, so 'aai agent -o text | aai llm "summarize the call"' works. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/agent/render.py | 58 ++++++++++++++++++++++++-------- assemblyai_cli/commands/agent.py | 25 ++++++++++---- tests/test_agent_command.py | 17 ++++++++++ 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index c71fcc61..6430aa2d 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +import sys +from typing import Any, TextIO from rich.text import Text @@ -13,39 +14,67 @@ def _labeled(label: str, body: str, *, style: str = "aai.label") -> Text: class AgentRenderer(BaseRenderer): - """Renders Voice Agent events: human transcript lines, or NDJSON for agents. + """Renders Voice Agent events in one of three modes. + + - JSON: NDJSON events to stdout. - text: plain ``you:``/``agent:`` transcript + lines to stdout with status on stderr (so ``aai agent -o text | aai llm "…"`` + pipes the conversation). - human (default): live Rich transcript. Audio payloads are never written; only text/state events are surfaced. """ - def __init__(self, *, mic_input: bool = True, **kwargs: Any) -> None: + def __init__( + self, + *, + mic_input: bool = True, + text_mode: bool = False, + err: TextIO | None = None, + **kwargs: Any, + ) -> None: super().__init__(**kwargs) # File-driven runs have no mic, so they skip the "start talking" prompt. self.mic_input = mic_input + self.text_mode = text_mode + self._err = err if err is not None else sys.stderr + + def _status(self, message: str) -> None: + """Write a status notice to stderr so it never pollutes piped stdout.""" + print(message, file=self._err, flush=True) # --- lifecycle --------------------------------------------------------- def connected(self) -> None: if self.json_mode: self._emit({"type": "session.ready"}) - elif self.mic_input: + elif not self.mic_input: + return + elif self.text_mode: + self._status("Connected — start talking. (Ctrl-C to stop)") + else: self._line(Text("Connected — start talking. (Ctrl-C to stop)", style="aai.muted")) def notice(self, text: str) -> None: - """Print a human-facing notice (caller chooses when to suppress in JSON).""" - self._line(text.rstrip("\n")) + """Print a human-facing notice (suppressed in JSON; to stderr in text mode).""" + if self.json_mode: + return + if self.text_mode: + self._status(text.rstrip("\n")) + else: + self._line(text.rstrip("\n")) # --- user -------------------------------------------------------------- def user_partial(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user.delta", "text": text}) - return - self._update_line(_labeled("you: ", text, style="aai.you")) + elif not self.text_mode: # partials are noise for piped text + self._update_line(_labeled("you: ", text, style="aai.you")) def user_final(self, text: str) -> None: if self.json_mode: self._emit({"type": "transcript.user", "text": text}) - return - self._finalize_line(_labeled("you: ", text, style="aai.you")) + elif self.text_mode: + self._write(f"you: {text}\n") + else: + self._finalize_line(_labeled("you: ", text, style="aai.you")) # --- agent ------------------------------------------------------------- def reply_started(self) -> None: @@ -55,10 +84,11 @@ def reply_started(self) -> None: def agent_transcript(self, text: str, *, interrupted: bool) -> None: if self.json_mode: self._emit({"type": "transcript.agent", "text": text, "interrupted": interrupted}) - return - self._line( - _labeled("agent: ", text, style="aai.agent") - ) # commits any open "you: …" partial first + elif self.text_mode: + self._write(f"agent: {text}\n") + else: + # commits any open "you: …" partial first + self._line(_labeled("agent: ", text, style="aai.agent")) def reply_done(self, *, interrupted: bool) -> None: if self.json_mode: diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 7a08e112..dbc5e29b 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -40,6 +40,12 @@ def agent( device: int | None = typer.Option(None, "--device", help="Microphone device index."), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), + output_field: str = typer.Option( + None, + "-o", + "--output", + help="Output mode: 'text' (you:/agent: lines as plain stdout, pipe-friendly) or 'json'.", + ), show_code: bool = typer.Option( False, "--show-code", @@ -57,6 +63,8 @@ def agent( raise typer.Exit(code=0) def body(state: AppState, json_mode: bool) -> None: + if output_field is not None and output_field not in ("text", "json"): + raise UsageError(f"Unknown --output {output_field!r}. Choose one of: text, json.") if voice not in VOICES: raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") if system_prompt_file is not None: @@ -82,7 +90,12 @@ def body(state: AppState, json_mode: bool) -> None: if from_file and device is not None: raise UsageError("--device applies only to microphone input.") - renderer = AgentRenderer(json_mode=json_mode, mic_input=not from_file) + text_mode = output_field == "text" + renderer = AgentRenderer( + json_mode=(output_field == "json") or (json_mode and not text_mode), + text_mode=text_mode, + mic_input=not from_file, + ) audio: Any player: Any if from_file: @@ -97,11 +110,11 @@ def body(state: AppState, json_mode: bool) -> None: duplex = DuplexAudio(target_rate=SAMPLE_RATE, device=device) audio = duplex.mic player = duplex.player - if not json_mode: - renderer.notice( - "Use headphones — the mic stays open while the agent speaks, " - "so speakers would let it hear itself.\n" - ) + # notice() self-suppresses in JSON mode and routes to stderr in text mode. + renderer.notice( + "Use headphones — the mic stays open while the agent speaks, " + "so speakers would let it hear itself.\n" + ) try: run_session( api_key, diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index f5816998..284aca1f 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -274,3 +274,20 @@ def _boom(*a, **k): result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code", "--json"]) assert result.exit_code == 0 assert "agents.assemblyai.com" in result.output + + +def test_agent_output_text_emits_plain_transcript(monkeypatch): + # `-o text` -> plain you:/agent: lines on stdout (pipe into aai llm). + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") + + def fake_run_session(api_key, *, renderer, **kwargs): + renderer.user_final("hello there") + renderer.agent_transcript("hi, how can I help?", interrupted=False) + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + result = runner.invoke(app, ["agent", "--sample", "-o", "text"]) + assert result.exit_code == 0 + assert "you: hello there" in result.output + assert "agent: hi, how can I help?" in result.output + assert '"type"' not in result.output # not NDJSON From 43a2463a8f8e8e1b226249c1b4cc2ef85c6018aa Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:10:37 -0700 Subject: [PATCH 76/87] fix(stream): swallow BrokenPipe from the SDK reader thread on closed pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the downstream consumer (a Ctrl-C'd '| aai llm', or '| head') closes the pipe, the Turn callback raised BrokenPipeError on the SDK's reader thread with no handler — dumping a thread traceback. Guard the event callbacks to swallow it and point stdout at /dev/null so the exit-time flush can't re-raise either; the main thread still stops on Ctrl-C / source EOF. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/client.py | 25 ++++++++++++++++++++++--- tests/test_client.py | 12 ++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 889c5579..6e5eee0b 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -1,6 +1,9 @@ from __future__ import annotations +import contextlib import json +import os +import sys from collections.abc import Callable, Iterable from typing import Any @@ -115,13 +118,29 @@ def stream_audio( sc = StreamingClient( StreamingClientOptions(api_key=api_key, api_host="streaming.assemblyai.com") ) + + def _guard(cb: Callable[[Any], Any]) -> Callable[[Any, Any], None]: + # Event callbacks run on the SDK's reader thread. If the downstream pipe is + # gone (e.g. a Ctrl-C'd `| aai llm`, or `| head`), writing a turn raises + # BrokenPipeError there with no handler -> an ugly thread traceback. Swallow + # it and point stdout at /dev/null so the interpreter's exit-flush can't + # re-raise either; the main thread still stops via Ctrl-C / source EOF. + def handler(_client: Any, event: Any) -> None: + try: + cb(event) + except BrokenPipeError: + with contextlib.suppress(Exception): + os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + + return handler + errors: list[object] = [] if on_begin is not None: - sc.on(StreamingEvents.Begin, lambda _client, event: on_begin(event)) + sc.on(StreamingEvents.Begin, _guard(on_begin)) if on_turn is not None: - sc.on(StreamingEvents.Turn, lambda _client, event: on_turn(event)) + sc.on(StreamingEvents.Turn, _guard(on_turn)) if on_termination is not None: - sc.on(StreamingEvents.Termination, lambda _client, event: on_termination(event)) + sc.on(StreamingEvents.Termination, _guard(on_termination)) sc.on(StreamingEvents.Error, lambda _client, error: errors.append(error)) try: diff --git a/tests/test_client.py b/tests/test_client.py index 48199aa2..e08043de 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -289,6 +289,18 @@ def stream(self, source): assert StreamFails.last.disconnected # still disconnected in finally +def test_stream_audio_swallows_broken_pipe_in_callback(monkeypatch): + # A closed downstream pipe makes a turn write raise BrokenPipeError on the SDK's + # reader thread; the guard must swallow it instead of dumping a thread traceback. + monkeypatch.setattr(client, "StreamingClient", _FakeStreamingClient) + monkeypatch.setattr(client.os, "dup2", lambda *a, **k: None) # never touch real stdout + + def on_turn(_event): + raise BrokenPipeError + + client.stream_audio("sk", [b"\x00"], params=_stream_params(), on_turn=on_turn) # no raise + + def test_stream_audio_passes_through_clierror(monkeypatch): from assemblyai_cli.errors import CLIError From b7d2fdbd652e5bc0bb82c3607e1e50142a6fec54 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:20:01 -0700 Subject: [PATCH 77/87] fix(output): write errors to stderr, keeping stdout clean for pipelines emit_error printed to stdout, so a failure polluted piped output (e.g. 'aai transcribe x -o text > out.txt' wrote the error into out.txt). Route both human and --json error output to stderr. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/output.py | 7 +++++-- tests/test_output.py | 15 +++++++++++++-- tests/test_theme.py | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/assemblyai_cli/output.py b/assemblyai_cli/output.py index 92de94f4..8aa5a6ff 100644 --- a/assemblyai_cli/output.py +++ b/assemblyai_cli/output.py @@ -16,6 +16,8 @@ T = TypeVar("T") console = theme.make_console() +# Errors go to stderr so they never pollute piped stdout (e.g. `aai transcribe x -o text > out`). +error_console = theme.make_console(stderr=True) _AGENT_ENV_VARS = ("CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT") @@ -43,7 +45,8 @@ def emit(data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> def emit_error(err: CLIError, *, json_mode: bool) -> None: + # Always to stderr, so stdout stays clean for `aai … | next-tool` pipelines. if json_mode: - print(json.dumps(err.to_dict(), default=str)) + print(json.dumps(err.to_dict(), default=str), file=sys.stderr) else: - console.print(f"[aai.error]Error:[/aai.error] {escape(err.message)}") + error_console.print(f"[aai.error]Error:[/aai.error] {escape(err.message)}") diff --git a/tests/test_output.py b/tests/test_output.py index 311c73f4..714cc4cc 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -46,5 +46,16 @@ def test_emit_error_escapes_markup(capsys): err = types.SimpleNamespace(message="bad [tag] here", to_dict=lambda: {"error": {}}) output.emit_error(err, json_mode=False) - out = capsys.readouterr().out - assert "[tag]" in out # not stripped as markup + captured = capsys.readouterr() + assert "[tag]" in captured.err # error goes to stderr, not stripped as markup + assert captured.out == "" # stdout stays clean for pipelines + + +def test_emit_error_json_goes_to_stderr(capsys): + import types + + err = types.SimpleNamespace(message="boom", to_dict=lambda: {"error": {"message": "boom"}}) + output.emit_error(err, json_mode=True) + captured = capsys.readouterr() + assert json.loads(captured.err) == {"error": {"message": "boom"}} + assert captured.out == "" diff --git a/tests/test_theme.py b/tests/test_theme.py index 454672db..7ebb96b3 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -52,7 +52,7 @@ def test_output_console_is_themed_and_error_is_styled(monkeypatch): buf = io.StringIO() monkeypatch.setattr( output, - "console", + "error_console", # errors render on the stderr console theme.make_console(file=buf, force_terminal=True, color_system="truecolor"), ) output.emit_error(CLIError("boom"), json_mode=False) From 1317562193d44ebd6f60eb2a0c99af1a159d6ad2 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:25:12 -0700 Subject: [PATCH 78/87] refactor(samples): generate scaffolds via the live code_gen, drop static templates samples create now renders through the same generator as --show-code (env-var auth, no secret written to disk), so scaffolds can't drift from a separate set of .tmpl files. Removes the static templates and key injection; scaffolding no longer requires auth. Generated samples are asserted to parse. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/samples.py | 60 ++++++++++--------- assemblyai_cli/templates/__init__.py | 0 assemblyai_cli/templates/agent.py.tmpl | 66 --------------------- assemblyai_cli/templates/stream.py.tmpl | 41 ------------- assemblyai_cli/templates/transcribe.py.tmpl | 19 ------ pyproject.toml | 5 -- tests/test_samples.py | 62 ++++++++----------- 7 files changed, 55 insertions(+), 198 deletions(-) delete mode 100644 assemblyai_cli/templates/__init__.py delete mode 100644 assemblyai_cli/templates/agent.py.tmpl delete mode 100644 assemblyai_cli/templates/stream.py.tmpl delete mode 100644 assemblyai_cli/templates/transcribe.py.tmpl diff --git a/assemblyai_cli/commands/samples.py b/assemblyai_cli/commands/samples.py index a96aac97..d40185e8 100644 --- a/assemblyai_cli/commands/samples.py +++ b/assemblyai_cli/commands/samples.py @@ -1,27 +1,39 @@ from __future__ import annotations -import os -from importlib import resources from pathlib import Path import typer +from assemblyai.streaming.v3 import SpeechModel from rich.markup import escape -from assemblyai_cli import config, output +from assemblyai_cli import client, code_gen, output +from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT +from assemblyai_cli.agent.voices import DEFAULT_VOICE from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import CLIError +from assemblyai_cli.streaming.sources import TARGET_RATE app = typer.Typer( help="Scaffold runnable AssemblyAI starter scripts.", no_args_is_help=True, ) -# template name -> (template resource filename, output filename) -TEMPLATES = { - "transcribe": ("transcribe.py.tmpl", "transcribe.py"), - "stream": ("stream.py.tmpl", "stream.py"), - "agent": ("agent.py.tmpl", "agent.py"), -} +SAMPLES = ("transcribe", "stream", "agent") + + +def _generate(name: str) -> str: + """Render a starter script via the same generator behind `--show-code`.""" + if name == "transcribe": + return code_gen.transcribe({}, client.SAMPLE_AUDIO_URL) + if name == "stream": + return code_gen.stream( + { + "sample_rate": TARGET_RATE, + "format_turns": True, + "speech_model": SpeechModel.u3_rt_pro, + } + ) + return code_gen.agent(DEFAULT_VOICE, DEFAULT_PROMPT, DEFAULT_GREETING) @app.command(name="list") @@ -29,12 +41,11 @@ def list_( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """List available sample templates.""" + """List available sample scripts.""" def body(_state: AppState, json_mode: bool) -> None: - names = sorted(TEMPLATES) output.emit( - names, + list(SAMPLES), lambda d: "Available samples:\n" + "\n".join(f" - {n}" for n in d), json_mode=json_mode, ) @@ -49,41 +60,32 @@ def create( force: bool = typer.Option(False, "--force", help="Overwrite an existing sample file."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Scaffold a runnable starter script with your API key injected.""" + """Scaffold a runnable starter script (reads ASSEMBLYAI_API_KEY from the environment).""" - def body(state: AppState, json_mode: bool) -> None: - if name not in TEMPLATES: + def body(_state: AppState, json_mode: bool) -> None: + if name not in SAMPLES: raise CLIError( - f"Unknown sample '{name}'. Try: {', '.join(sorted(TEMPLATES))}.", + f"Unknown sample '{name}'. Try: {', '.join(SAMPLES)}.", error_type="unknown_sample", exit_code=1, ) - api_key = config.resolve_api_key(profile=state.profile) - tmpl_file, out_file = TEMPLATES[name] - template = resources.files("assemblyai_cli.templates").joinpath(tmpl_file).read_text() - rendered = template.replace("{{API_KEY}}", api_key) - target_dir = Path.cwd() / name target_dir.mkdir(parents=True, exist_ok=True) - target_dir.chmod(0o700) - target = target_dir / out_file + target = target_dir / f"{name}.py" if target.exists() and not force: raise CLIError( f"{target} already exists. Delete it or pass --force to overwrite.", error_type="file_exists", exit_code=1, ) - fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as fh: - fh.write(rendered) - target.chmod(0o600) + target.write_text(_generate(name)) output.emit( {"created": str(target)}, lambda d: ( f"Created {escape(d['created'])}\n" - f"[aai.warn]Note:[/aai.warn] this file contains your API key — do not commit it.\n" - f"Run it with: python {escape(d['created'])}" + f'Set your key (export ASSEMBLYAI_API_KEY="…"), then run: ' + f"python {escape(d['created'])}" ), json_mode=json_mode, ) diff --git a/assemblyai_cli/templates/__init__.py b/assemblyai_cli/templates/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/assemblyai_cli/templates/agent.py.tmpl b/assemblyai_cli/templates/agent.py.tmpl deleted file mode 100644 index 19391135..00000000 --- a/assemblyai_cli/templates/agent.py.tmpl +++ /dev/null @@ -1,66 +0,0 @@ -# Live two-way voice conversation with an AssemblyAI voice agent. -# Requires audio support: pip install sounddevice websockets -# -# Tip: use headphones. This sample keeps the mic open while the agent speaks, -# so without them the agent may hear (and respond to) its own voice. -import base64 -import json -import threading - -import sounddevice as sd -from websockets.sync.client import connect - -API_KEY = "{{API_KEY}}" -WS_URL = "wss://agents.assemblyai.com/v1/ws" -RATE = 24000 # Voice Agent native PCM16 mono sample rate - -speaker = sd.RawOutputStream(samplerate=RATE, channels=1, dtype="int16") -speaker.start() -mic = sd.RawInputStream(samplerate=RATE, channels=1, dtype="int16", blocksize=1024) -mic.start() - -ready = threading.Event() - - -def send_mic(ws): - """Forward microphone audio to the agent once the session is ready.""" - while True: - try: - data, _overflowed = mic.read(1024) - chunk = bytes(data) - if ready.is_set(): - ws.send(json.dumps({"type": "input.audio", "audio": base64.b64encode(chunk).decode()})) - except Exception: - return - - -with connect(WS_URL, additional_headers={"Authorization": f"Bearer {API_KEY}"}) as ws: - ws.send(json.dumps({ - "type": "session.update", - "session": { - "system_prompt": "You are a friendly voice assistant. Keep replies short and natural.", - "greeting": "Hey, what's on your mind?", - "output": {"voice": "ivy"}, - }, - })) - threading.Thread(target=send_mic, args=(ws,), daemon=True).start() - print("Connected — start talking. (Ctrl-C to stop)") - try: - for raw in ws: - event = json.loads(raw) - etype = event.get("type") - if etype == "session.ready": - ready.set() - elif etype == "reply.audio" and event.get("data"): - speaker.write(base64.b64decode(event["data"])) - elif etype == "transcript.user": - print("you: ", event.get("text", "")) - elif etype == "transcript.agent": - print("agent:", event.get("text", "")) - except KeyboardInterrupt: - print("\nStopped.") - finally: - speaker.stop() - speaker.close() - mic.stop() - mic.close() diff --git a/assemblyai_cli/templates/stream.py.tmpl b/assemblyai_cli/templates/stream.py.tmpl deleted file mode 100644 index 3a5f6a1b..00000000 --- a/assemblyai_cli/templates/stream.py.tmpl +++ /dev/null @@ -1,41 +0,0 @@ -# Real-time microphone transcription. -# Requires microphone support: pip install "assemblyai[extras]" -import assemblyai as aai -from assemblyai.streaming.v3 import ( - SpeechModel, - StreamingClient, - StreamingClientOptions, - StreamingEvents, - StreamingParameters, - TurnEvent, -) - -aai.settings.api_key = "{{API_KEY}}" - - -def on_turn(client: StreamingClient, event: TurnEvent) -> None: - print(event.transcript, end="\r", flush=True) - if event.end_of_turn: - print() - - -client = StreamingClient( - StreamingClientOptions(api_key="{{API_KEY}}", api_host="streaming.assemblyai.com") -) -client.on(StreamingEvents.Turn, on_turn) -client.connect( - StreamingParameters( - sample_rate=16000, - format_turns=True, - speech_model=SpeechModel.u3_rt_pro, - # Extra features (all optional): - # max_turn_silence=400, # tune end-of-turn detection (ms) - # filter_profanity=True, - ) -) - -print("Listening… press Ctrl-C to stop.") -try: - client.stream(aai.extras.MicrophoneStream(sample_rate=16000)) -finally: - client.disconnect(terminate=True) diff --git a/assemblyai_cli/templates/transcribe.py.tmpl b/assemblyai_cli/templates/transcribe.py.tmpl deleted file mode 100644 index 597da7ea..00000000 --- a/assemblyai_cli/templates/transcribe.py.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -import assemblyai as aai - -aai.settings.api_key = "{{API_KEY}}" - -# TranscriptionConfig enables extra features. A few examples (all optional): -config = aai.TranscriptionConfig( - # speaker_labels=True, # diarize who said what - # summarization=True, summary_type="bullets", - # sentiment_analysis=True, - # auto_chapters=True, -) - -transcriber = aai.Transcriber() -transcript = transcriber.transcribe("https://assembly.ai/wildfires.mp3", config=config) - -if transcript.status == aai.TranscriptStatus.error: - raise RuntimeError(transcript.error) - -print(transcript.text) diff --git a/pyproject.toml b/pyproject.toml index 28e41007..42162a4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,11 +62,6 @@ aai = "assemblyai_cli.main:app" [tool.hatch.build.targets.wheel] packages = ["assemblyai_cli"] -[tool.hatch.build.targets.wheel.force-include] -"assemblyai_cli/templates/transcribe.py.tmpl" = "assemblyai_cli/templates/transcribe.py.tmpl" -"assemblyai_cli/templates/stream.py.tmpl" = "assemblyai_cli/templates/stream.py.tmpl" -"assemblyai_cli/templates/agent.py.tmpl" = "assemblyai_cli/templates/agent.py.tmpl" - [tool.pytest.ini_options] testpaths = ["tests"] markers = [ diff --git a/tests/test_samples.py b/tests/test_samples.py index 813970d5..51474d72 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -2,11 +2,12 @@ from typer.testing import CliRunner -from assemblyai_cli import config from assemblyai_cli.main import app runner = CliRunner() +_ENV_KEY = 'os.environ["ASSEMBLYAI_API_KEY"]' + def test_samples_list_shows_transcribe(): result = runner.invoke(app, ["samples", "list"]) @@ -22,16 +23,12 @@ def test_samples_list_shows_templates(): assert "agent" in result.output -def test_samples_create_agent_writes_script_with_key(tmp_path, monkeypatch): +def test_samples_create_agent_uses_env_key(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - config.set_api_key("default", "sk_injected") result = runner.invoke(app, ["samples", "create", "agent"]) assert result.exit_code == 0 - script = Path(tmp_path, "agent", "agent.py") - assert script.exists() - body = script.read_text() - assert "sk_injected" in body - assert "{{API_KEY}}" not in body + body = Path(tmp_path, "agent", "agent.py").read_text() + assert _ENV_KEY in body # reads the key from the environment, no secret in the file assert "session.update" in body # the voice-agent handshake assert "sounddevice" in body # audio backend (PortAudio bundled in the wheel) assert "pyaudio" not in body @@ -43,29 +40,29 @@ def test_samples_no_subcommand_lists_commands(): assert "list" in result.output and "create" in result.output -def test_samples_create_stream_writes_script_with_key(tmp_path, monkeypatch): +def test_samples_create_stream_uses_env_key(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - config.set_api_key("default", "sk_injected") result = runner.invoke(app, ["samples", "create", "stream"]) assert result.exit_code == 0 - script = Path(tmp_path, "stream", "stream.py") - assert script.exists() - body = script.read_text() - assert "sk_injected" in body - assert "{{API_KEY}}" not in body + body = Path(tmp_path, "stream", "stream.py").read_text() + assert _ENV_KEY in body assert "MicrophoneStream" in body -def test_samples_create_writes_script_with_key(tmp_path, monkeypatch): +def test_samples_create_transcribe_uses_env_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["samples", "create", "transcribe"]) + assert result.exit_code == 0 + body = Path(tmp_path, "transcribe", "transcribe.py").read_text() + assert _ENV_KEY in body + assert "import assemblyai as aai" in body + + +def test_samples_create_needs_no_auth(tmp_path, monkeypatch): + # Scaffolding writes no secret, so it works without being logged in. monkeypatch.chdir(tmp_path) - config.set_api_key("default", "sk_injected") result = runner.invoke(app, ["samples", "create", "transcribe"]) assert result.exit_code == 0 - script = Path(tmp_path, "transcribe", "transcribe.py") - assert script.exists() - body = script.read_text() - assert "sk_injected" in body - assert "{{API_KEY}}" not in body def test_samples_create_unknown_name_errors(): @@ -73,15 +70,8 @@ def test_samples_create_unknown_name_errors(): assert result.exit_code == 1 -def test_samples_create_unauthenticated_exits_2(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["samples", "create", "transcribe"]) - assert result.exit_code == 2 - - def test_samples_create_refuses_existing_without_force(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - config.set_api_key("default", "sk_injected") assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 # Second run without --force must refuse. result = runner.invoke(app, ["samples", "create", "transcribe"]) @@ -90,19 +80,15 @@ def test_samples_create_refuses_existing_without_force(tmp_path, monkeypatch): def test_samples_create_force_overwrites(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - config.set_api_key("default", "sk_injected") assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 result = runner.invoke(app, ["samples", "create", "transcribe", "--force"]) assert result.exit_code == 0 -def test_samples_create_file_is_owner_only(tmp_path, monkeypatch): - import stat +def test_samples_create_is_valid_python(tmp_path, monkeypatch): + import ast monkeypatch.chdir(tmp_path) - config.set_api_key("default", "sk_injected") - assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 - from pathlib import Path - - mode = stat.S_IMODE(Path(tmp_path, "transcribe", "transcribe.py").stat().st_mode) - assert mode == 0o600 + for name in ("transcribe", "stream", "agent"): + assert runner.invoke(app, ["samples", "create", name]).exit_code == 0 + ast.parse(Path(tmp_path, name, f"{name}.py").read_text()) # generated code parses From c5e34097d6a4886e7c2991ff5c4b9aa974f08424 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:26:07 -0700 Subject: [PATCH 79/87] docs(readme): add Pipelines section (-o, stdin -, aai llm stdin, stream->llm) Document the composability features: -o/--output field selectors, '-' stdin audio, aai llm reading piped stdin, errors-to-stderr, and the stream/agent -> llm patterns (timeout -s INT, inactivity-timeout, capture-then-process). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 044b004f..7c8b01fc 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ aai transcribe --sample # transcribe the hosted wildfires.mp3 sample | `aai samples create ` | Scaffold a runnable starter script with your key injected. | Add `--json` to any command for machine-readable output (it's also the default when -output is piped or run by an agent). Auth problems surface as a clean -"not authenticated" error across every command. +output is piped or run by an agent). Errors always go to **stderr**, so stdout stays +clean for pipelines. Auth problems surface as a clean "not authenticated" error +across every command. > **Tip:** Quote URLs that contain `?` (most YouTube links do). In zsh the `?` is a > glob character, so an unquoted URL fails with `zsh: no matches found` before the @@ -166,6 +167,52 @@ aai transcribe call.mp3 \ --show-code > summarize_then_translate.py ``` +## Pipelines + +`aai` is built to compose with the rest of your shell. Output is machine-clean +(errors go to stderr), commands read `-` from stdin, and `-o`/`--output` prints a +single field so you rarely need `jq`. + +**Pick one field with `-o`:** + +```sh +aai transcribe call.mp3 -o text # just the transcript text +aai transcribe call.mp3 -o id # just the transcript id +aai transcribe call.mp3 -o utterances # speaker-labeled lines +aai transcribe call.mp3 -o json | jq . # full JSON when you do want jq +``` + +**Read audio from stdin (`-`):** + +```sh +ffmpeg -i talk.mp4 -f wav - | aai transcribe - # transcribe any video +curl -sL https://example.com/ep.mp3 | aai transcribe - # no temp file +ffmpeg -i in.mp4 -f s16le -ac 1 -ar 16000 - | aai stream - # live, from a pipe +``` + +**Feed transcripts into the LLM Gateway** (`aai llm` reads piped stdin): + +```sh +aai transcribe call.mp3 -o text | aai llm "summarize, then list action items" +cat notes.txt | aai llm "turn these into a changelog" +``` + +**Stream, then summarize.** Piped `stream`/`agent` emit clean transcript lines with +`-o text`. A Ctrl-C in a pipe hits both sides, so to stop the producer *and* let the +consumer finish, signal only the producer — or end the stream on its own: + +```sh +# end after 30s by signaling just the producer (macOS: brew install coreutils, use gtimeout) +timeout -s INT 30s aai stream -o text | aai llm "summarize" + +# or end on a natural pause (server-side inactivity timeout, in seconds) +aai stream -o text --inactivity-timeout 5 | aai llm "summarize the call" + +# capture then process (most robust) +aai stream -o text > call.txt # Ctrl-C to stop +aai llm "summarize" < call.txt +``` + ## AI coding agents Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI skill so From dc276aec905f16b6a8dcd75590270f071ab830a3 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:32:33 -0700 Subject: [PATCH 80/87] feat(llm): --follow re-runs the prompt over a growing piped transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aai stream -o text | aai llm -f "summarize as I talk" — each finalized turn triggers a fresh transform over the full transcript, refreshing in place (Rich Live) for humans or one NDJSON object per refresh when piped. Adds stdio stdin_is_piped/iter_piped_stdin_lines helpers + tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 18 +++++++- assemblyai_cli/commands/llm.py | 78 +++++++++++++++++++++++++++++++++- assemblyai_cli/stdio.py | 24 +++++++++++ tests/test_llm_command.py | 77 +++++++++++++++++++++++++++++++++ tests/test_stdio.py | 30 +++++++++++++ 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 tests/test_stdio.py diff --git a/README.md b/README.md index 7c8b01fc..2582810a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ aai transcribe --sample # transcribe the hosted wildfires.mp3 sample | `aai transcripts list` / `get ` | Browse and fetch past transcripts. | | `aai stream [file]` | Real-time transcription from a file or the microphone. | | `aai agent` | Live two-way voice conversation with a voice agent. | -| `aai llm ` | Prompt AssemblyAI's LLM Gateway (optionally over a transcript with `--transcript-id`). | +| `aai llm ` | Prompt AssemblyAI's LLM Gateway (over a past transcript with `--transcript-id`, or a live streamed transcript with `--follow`). | | `aai claude install` | Wire Claude Code up to AssemblyAI's docs + skill. | | `aai samples create ` | Scaffold a runnable starter script with your key injected. | @@ -126,6 +126,22 @@ aai stream --sample \ --config vad_threshold=0.7 ``` +## Live transcript → live LLM + +`aai stream -o text` writes one finalized turn per line and flushes immediately, so it +can drive `aai llm` turn by turn. Add `--follow` (`-f`) to `aai llm` to keep re-running +your prompt over the *growing* transcript, refreshing the answer in place on every turn: + +```sh +aai stream -o text | aai llm -f --system "You are a meeting scribe" "summarize action items as I talk" +``` + +On a terminal you watch one evolving summary; piped onward it emits one JSON object per +refresh (`{"turns": N, "output": "…"}`). Each finalized turn triggers a fresh call over +the full transcript, so the answer is always current. Ctrl-C to stop. Without `--follow`, +`aai llm` stays one-shot — it reads stdin to EOF and answers once (`cat notes | aai llm +"summarize"`). + ## Voice agent Have a live, two-way voice conversation: diff --git a/assemblyai_cli/commands/llm.py b/assemblyai_cli/commands/llm.py index b6cdf0b8..56c1030a 100644 --- a/assemblyai_cli/commands/llm.py +++ b/assemblyai_cli/commands/llm.py @@ -1,7 +1,11 @@ from __future__ import annotations +import json + import typer +from rich.live import Live from rich.markup import escape +from rich.panel import Panel from assemblyai_cli import config, output, stdio from assemblyai_cli import llm as gateway @@ -11,6 +15,40 @@ app = typer.Typer() +class _FollowRenderer: + """Render a live transcript transform that refreshes on every turn. + + On a terminal, the latest answer is redrawn in place inside a Rich panel so a + human watches one evolving summary. When piped or run by an agent (json_mode), + each refresh is emitted as one NDJSON object so it stays machine-readable. + """ + + def __init__(self, *, json_mode: bool) -> None: + self.json_mode = json_mode + self._live: Live | None = None + + def __enter__(self) -> _FollowRenderer: + if not self.json_mode: + self._live = Live(console=output.console, auto_refresh=False, transient=False) + self._live.start() + return self + + def __call__(self, answer: str, turns: int) -> None: + if self.json_mode: + print(json.dumps({"turns": turns, "output": answer}, default=str), flush=True) + elif self._live is not None: + title = f"scribe · {turns} turn{'s' if turns != 1 else ''}" + self._live.update( + Panel(escape(answer or "…"), title=title, border_style="aai.brand"), + refresh=True, + ) + + def __exit__(self, *exc: object) -> None: + if self._live is not None: + self._live.stop() + self._live = None + + @app.command() def llm( ctx: typer.Context, @@ -21,6 +59,14 @@ def llm( None, "--transcript-id", help="Inject this transcript's text into the prompt." ), system: str = typer.Option(None, "--system", help="Optional system prompt."), + follow: bool = typer.Option( + False, + "--follow", + "-f", + help="Re-run the prompt over a growing transcript piped on stdin, refreshing " + "the answer in place on every finalized turn (e.g. aai stream -o text | aai " + 'llm -f "summarize action items as I talk"). Ctrl-C to stop.', + ), max_tokens: int = typer.Option( gateway.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens to generate." ), @@ -37,6 +83,36 @@ def llm( typer.echo("\n".join(gateway.KNOWN_MODELS)) raise typer.Exit(code=0) + def follow_body(state: AppState, json_mode: bool) -> None: + if not prompt: + raise UsageError("Provide a prompt to run over the streamed transcript.") + if transcript_id: + raise UsageError( + "--follow runs over live transcript text piped on stdin; it can't be " + "combined with --transcript-id." + ) + if not stdio.stdin_is_piped(): + raise UsageError( + "--follow needs transcript text piped on stdin, e.g. " + '`aai stream -o text | aai llm -f "summarize action items as I talk"`.' + ) + api_key = config.resolve_api_key(profile=state.profile) + + def ask(transcript_text: str) -> str: + messages = gateway.build_messages( + prompt, system=system, transcript_text=transcript_text + ) + response = gateway.complete( + api_key, model=model, messages=messages, max_tokens=max_tokens + ) + return gateway.content_of(response) + + with _FollowRenderer(json_mode=json_mode) as render: + transcript: list[str] = [] + for turn in stdio.iter_piped_stdin_lines(): + transcript.append(turn) + render(ask("\n".join(transcript)), len(transcript)) + def body(state: AppState, json_mode: bool) -> None: if not prompt: raise UsageError("Provide a prompt, or use --list-models.") @@ -64,4 +140,4 @@ def body(state: AppState, json_mode: bool) -> None: json_mode=json_mode, ) - run_command(ctx, body, json=json_out) + run_command(ctx, follow_body if follow else body, json=json_out) diff --git a/assemblyai_cli/stdio.py b/assemblyai_cli/stdio.py index 18da4a8b..32469ab4 100644 --- a/assemblyai_cli/stdio.py +++ b/assemblyai_cli/stdio.py @@ -1,6 +1,30 @@ from __future__ import annotations import sys +from collections.abc import Iterator + + +def stdin_is_piped() -> bool: + """True when stdin is a pipe/redirect rather than an interactive terminal.""" + stream = sys.stdin + return stream is not None and not stream.isatty() + + +def iter_piped_stdin_lines() -> Iterator[str]: + """Yield non-blank, stripped lines piped on stdin, live, as each one arrives. + + Unlike ``piped_stdin_text`` (which reads to EOF), this consumes the pipe + incrementally so a long-running upstream like ``aai stream -o text`` can drive + a downstream command turn by turn. Yields nothing when stdin is a terminal, so + a ``--follow`` consumer used interactively returns instead of blocking forever. + """ + stream = sys.stdin + if stream is None or stream.isatty(): + return + for raw in stream: + line = raw.strip() + if line: + yield line def piped_stdin_text() -> str | None: diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index e69d4db6..618f2d11 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -123,6 +123,83 @@ def test_llm_unauthenticated_exits_2(): assert result.exit_code == 2 +def test_llm_follow_summarizes_each_turn(monkeypatch): + _auth() + calls = [] + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + calls.append(messages[-1]["content"]) + return _payload(f"summary-{len(calls)}") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke( + app, + ["llm", "summarize action items", "--follow", "--json"], + input="we ship friday\nbob owns the deploy\n", + ) + assert result.exit_code == 0 + updates = [json.loads(line) for line in result.output.splitlines() if line.strip()] + # One update per finalized turn, full transcript accumulating each time. + assert len(updates) == 2 + assert "we ship friday" in calls[0] + assert "bob owns the deploy" not in calls[0] + assert "we ship friday" in calls[1] + assert "bob owns the deploy" in calls[1] + assert updates[-1]["output"] == "summary-2" + assert updates[-1]["turns"] == 2 + + +def test_llm_follow_includes_system_prompt(monkeypatch): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + seen["roles"] = [m["role"] for m in messages] + seen["system"] = messages[0]["content"] + return _payload("ok") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke( + app, + ["llm", "summarize", "--follow", "--system", "You are a scribe", "--json"], + input="one turn\n", + ) + assert result.exit_code == 0 + assert seen["roles"][0] == "system" + assert seen["system"] == "You are a scribe" + + +def test_llm_follow_rejects_transcript_id(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + result = runner.invoke( + app, + ["llm", "summarize", "--follow", "--transcript-id", "t_1", "--json"], + input="x\n", + ) + assert result.exit_code == 2 + assert "transcript-id" in result.output + + +def test_llm_follow_ignores_blank_lines(monkeypatch): + _auth() + calls = [] + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + calls.append(messages[-1]["content"]) + return _payload("ok") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke( + app, + ["llm", "summarize", "--follow", "--json"], + input="first\n\n \nsecond\n", + ) + assert result.exit_code == 0 + # Blank/whitespace-only lines don't trigger a call. + assert len(calls) == 2 + + def test_llm_passes_model_and_max_tokens(monkeypatch): _auth() seen = {} diff --git a/tests/test_stdio.py b/tests/test_stdio.py new file mode 100644 index 00000000..979c2979 --- /dev/null +++ b/tests/test_stdio.py @@ -0,0 +1,30 @@ +import io + +from assemblyai_cli import stdio + + +class _Tty(io.StringIO): + def isatty(self) -> bool: + return True + + +class _Pipe(io.StringIO): + def isatty(self) -> bool: + return False + + +def test_iter_piped_stdin_lines_yields_stripped_nonblank(monkeypatch): + monkeypatch.setattr("sys.stdin", _Pipe("alpha\n\n \nbeta\n")) + assert list(stdio.iter_piped_stdin_lines()) == ["alpha", "beta"] + + +def test_iter_piped_stdin_lines_empty_on_tty(monkeypatch): + monkeypatch.setattr("sys.stdin", _Tty("alpha\nbeta\n")) + assert list(stdio.iter_piped_stdin_lines()) == [] + + +def test_stdin_is_piped(monkeypatch): + monkeypatch.setattr("sys.stdin", _Pipe("")) + assert stdio.stdin_is_piped() is True + monkeypatch.setattr("sys.stdin", _Tty("")) + assert stdio.stdin_is_piped() is False From 58d6b91868a40e27103b79ff63032ef099b18309 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 19:32:53 -0700 Subject: [PATCH 81/87] docs(readme): samples create reads ASSEMBLYAI_API_KEY (no longer injects the key) Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2582810a..95b43436 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ aai transcribe --sample # transcribe the hosted wildfires.mp3 sample | `aai agent` | Live two-way voice conversation with a voice agent. | | `aai llm ` | Prompt AssemblyAI's LLM Gateway (over a past transcript with `--transcript-id`, or a live streamed transcript with `--follow`). | | `aai claude install` | Wire Claude Code up to AssemblyAI's docs + skill. | -| `aai samples create ` | Scaffold a runnable starter script with your key injected. | +| `aai samples create ` | Scaffold a runnable starter script (reads your key from `ASSEMBLYAI_API_KEY`). | Add `--json` to any command for machine-readable output (it's also the default when output is piped or run by an agent). Errors always go to **stderr**, so stdout stays From 9d15c51ac32ad589afa0f32d7d8ab593a5a92da2 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 20:27:11 -0700 Subject: [PATCH 82/87] feat(show-code): syntax-highlight for a human, raw text when piped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --show-code now renders Pygments-highlighted Python (via rich.Syntax) on an interactive terminal, but emits plain text with no ANSI whenever output isn't a terminal (pipe/redirect/agent) — so '--show-code > script.py' and '| aai llm' stay byte-clean and runnable. rich already bundles Pygments; no new dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- assemblyai_cli/commands/agent.py | 4 ++-- assemblyai_cli/commands/stream.py | 4 ++-- assemblyai_cli/commands/transcribe.py | 2 +- assemblyai_cli/output.py | 13 +++++++++++++ tests/test_output.py | 21 +++++++++++++++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index dbc5e29b..2f914826 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -6,7 +6,7 @@ import typer -from assemblyai_cli import client, code_gen, config +from assemblyai_cli import client, code_gen, config, output from assemblyai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from assemblyai_cli.agent.render import AgentRenderer from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session @@ -82,7 +82,7 @@ def body(state: AppState, json_mode: bool) -> None: if show_code: # Print-only: emit the equivalent agent script from the flags and exit # without authenticating or opening audio. Raw stdout for `> script.py`. - print(code_gen.agent(voice, system_prompt_text, greeting)) + output.print_code(code_gen.agent(voice, system_prompt_text, greeting)) return api_key = config.resolve_api_key(profile=state.profile) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 466aff8e..3b095647 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -6,7 +6,7 @@ import typer from assemblyai.streaming.v3 import SpeechModel -from assemblyai_cli import client, code_gen, config, config_builder, llm, youtube +from assemblyai_cli import client, code_gen, config, config_builder, llm, output, youtube from assemblyai_cli.context import AppState, run_command from assemblyai_cli.errors import UsageError from assemblyai_cli.microphone import MicrophoneSource @@ -155,7 +155,7 @@ def make_flags(rate: int) -> dict[str, object]: overrides=list(config_kv or []), config_file=config_file, ) - print(code_gen.stream(merged)) + output.print_code(code_gen.stream(merged)) return api_key = config.resolve_api_key(profile=state.profile) diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 01ec72bb..36b5bfff 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -230,7 +230,7 @@ def body(state: AppState, json_mode: bool) -> None: if llm_gateway_prompt else None ) - print(code_gen.transcribe(merged, audio, llm_gateway=gateway)) + output.print_code(code_gen.transcribe(merged, audio, llm_gateway=gateway)) return tc = config_builder.construct_transcription_config(merged) diff --git a/assemblyai_cli/output.py b/assemblyai_cli/output.py index 8aa5a6ff..61cf9938 100644 --- a/assemblyai_cli/output.py +++ b/assemblyai_cli/output.py @@ -50,3 +50,16 @@ def emit_error(err: CLIError, *, json_mode: bool) -> None: print(json.dumps(err.to_dict(), default=str), file=sys.stderr) else: error_console.print(f"[aai.error]Error:[/aai.error] {escape(err.message)}") + + +def print_code(code: str, *, language: str = "python") -> None: + """Print generated source: syntax-highlighted for an interactive human, raw text + otherwise. Piping/redirecting (or an agent) yields plain text with no ANSI, so + `aai … --show-code > script.py` stays byte-clean and runnable. + """ + if _is_agentic(): + print(code) + return + from rich.syntax import Syntax # lazily import Pygments-backed highlighter + + console.print(Syntax(code, language, theme="ansi_dark", background_color="default")) diff --git a/tests/test_output.py b/tests/test_output.py index 714cc4cc..8b2b5ac6 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -59,3 +59,24 @@ def test_emit_error_json_goes_to_stderr(capsys): captured = capsys.readouterr() assert json.loads(captured.err) == {"error": {"message": "boom"}} assert captured.out == "" + + +def test_print_code_plain_when_piped(monkeypatch, capsys): + monkeypatch.setattr(output, "_is_agentic", lambda: True) + output.print_code("import os\nprint(os.getcwd())\n") + out = capsys.readouterr().out + assert "import os" in out + assert "\x1b[" not in out # no ANSI for pipes/redirects -> runnable when saved + + +def test_print_code_highlights_for_interactive_human(monkeypatch, capsys): + from assemblyai_cli import theme + + monkeypatch.setattr(output, "_is_agentic", lambda: False) + monkeypatch.setattr( + output, "console", theme.make_console(force_terminal=True, color_system="truecolor") + ) + output.print_code("import os\n") + out = capsys.readouterr().out + assert "import" in out + assert "\x1b[" in out # syntax-highlighted -> ANSI present From f650e5fddac4331e10c3d9861db4e64e00adc1fe Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 20:40:17 -0700 Subject: [PATCH 83/87] feat(cli): unify pipe/stream output + robust follow-mode teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-driven consistency pass over how commands handle pipes and streams: - add a top-level BrokenPipe guard (main.run) so `aai … | head` never prints an "Exception ignored" traceback from the one-shot output paths; wire __main__ and the console-script entry point to it - extract output.validate_output_field / output.stream_output_modes and use them in stream + agent (drop the copy-pasted validation and json-mode logic) - move transcript field selection to client.select_transcript_field and add `-o text|id|status|utterances|json` to `transcripts get` plus `-o text|json` to `aai llm` (one-shot), removing the `… | jq -r .output` workaround - route transcribe's --json branch through output.emit and follow-mode NDJSON through output.emit_ndjson; clarify the `aai llm --json` help text - render the `aai llm -f` live panel in the alternate screen buffer so a Ctrl-C in `aai stream -o text | aai llm -f` no longer duplicates the panel border Docs: README Recipes use `-o text`; add a `--show-code | aai llm` example. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 69 ++++++++++++++++++++++++++ assemblyai_cli/__main__.py | 4 +- assemblyai_cli/client.py | 25 ++++++++++ assemblyai_cli/commands/agent.py | 6 +-- assemblyai_cli/commands/llm.py | 62 ++++++++++++++++------- assemblyai_cli/commands/stream.py | 9 +--- assemblyai_cli/commands/transcribe.py | 35 ++----------- assemblyai_cli/commands/transcripts.py | 11 ++++ assemblyai_cli/main.py | 25 ++++++++++ assemblyai_cli/output.py | 24 +++++++++ pyproject.toml | 2 +- tests/test_llm_command.py | 58 ++++++++++++++++++++++ tests/test_main_module.py | 30 +++++++++++ tests/test_transcripts.py | 30 +++++++++++ 14 files changed, 325 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 95b43436..115e062f 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,75 @@ aai stream -o text > call.txt # Ctrl-C to stop aai llm "summarize" < call.txt ``` +## Recipes + +A cookbook of `aai` composed with common Unix tools. macOS shown; on Linux swap +`pbcopy`/`pbpaste` → `xclip -sel clip`/`xclip -o` and `say` → `spd-say`. + +**Live meeting scribe** — `-o text` streams one finalized turn per line; `aai llm -f` +re-summarizes the growing transcript in place on every turn (Ctrl-C to stop): + +```sh +aai stream -o text | aai llm -f --model claude-haiku-4-5-20251001 "summarize todos as I talk" +``` + +**Chain `aai llm` into other tools** with `-o text` — it prints just the answer, so it +pipes onward cleanly (no `jq` needed): + +```sh +aai transcribe call.mp3 -o text | aai llm -o text "list action items" | pbcopy +``` + +**`aai llm` is a general text filter** — it reads stdin, audio optional: + +```sh +git log --oneline -30 | aai llm "write release notes grouped by feature/fix" +cat error.log | aai llm "what's the root cause and the one-line fix?" +``` + +**Translate a sample, then port the generated code** — `--show-code` prints the Python +for the pipeline you described, and `aai llm` rewrites it in another language: + +```sh +aai transcribe --sample --llm-gateway-prompt "translate to french" --show-code | aai llm "rewrite in rust" +``` + +**Mine the analysis JSON with `jq`** — enable a feature, then slice `-o json`: + +```sh +aai transcribe call.mp3 --sentiment-analysis -o json | jq -r '.sentiment_analysis_results[] | "\(.sentiment)\t\(.text)"' +aai transcribe call.mp3 --entity-detection -o json | jq -r '.entities[] | "\(.entity_type): \(.text)"' | sort -u +``` + +**Pick a past transcript with `fzf`, then summarize it:** + +```sh +aai transcripts list --json \ + | jq -r '.[] | "\(.id)\t\(.status)\t\(.created)"' \ + | fzf | cut -f1 \ + | xargs -I{} aai llm "summarize the key decisions" --transcript-id {} +``` + +**Who talked the most** (speaker-labeled utterances + `awk`): + +```sh +aai transcribe call.mp3 --speaker-labels -o utterances | awk -F: '{print $1}' | sort | uniq -c | sort -rn +``` + +**Redact PII before it leaves your machine:** + +```sh +aai transcribe call.mp3 --redact-pii --redact-pii-policy person_name,phone_number,email_address -o text | pbcopy +``` + +**DIY voice assistant** — speak a question, hear the answer (use headphones): + +```sh +aai stream -o text | while IFS= read -r line; do + echo "$line" | aai llm -o text "answer in one short sentence" | say +done +``` + ## AI coding agents Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI skill so diff --git a/assemblyai_cli/__main__.py b/assemblyai_cli/__main__.py index 39baeb62..40342726 100644 --- a/assemblyai_cli/__main__.py +++ b/assemblyai_cli/__main__.py @@ -1,4 +1,4 @@ -from assemblyai_cli.main import app +from assemblyai_cli.main import run if __name__ == "__main__": - app(prog_name="aai") + run() diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 6e5eee0b..dab445a1 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -91,6 +91,31 @@ def status_str(transcript: aai.Transcript) -> str: return str(getattr(status, "value", status)) +# Fields `transcribe` and `transcripts get` expose via `-o/--output` (raw, pipe-friendly). +TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "json") + + +def select_transcript_field(transcript: Any, field: str) -> str: + """Render a single transcript field for ``-o/--output``.""" + if field == "id": + return str(getattr(transcript, "id", "") or "") + if field == "status": + return status_str(transcript) + if field == "utterances": + utterances = getattr(transcript, "utterances", None) or [] + if utterances: + return "\n".join(f"Speaker {u.speaker}: {u.text}" for u in utterances) + return str(getattr(transcript, "text", "") or "") + if field == "json": + payload = getattr(transcript, "json_response", None) or { + "id": transcript.id, + "status": status_str(transcript), + "text": transcript.text, + } + return json.dumps(payload, default=str) + return str(getattr(transcript, "text", "") or "") # "text" (and the validated default) + + def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: _configure(api_key) try: diff --git a/assemblyai_cli/commands/agent.py b/assemblyai_cli/commands/agent.py index 2f914826..b1e00d80 100644 --- a/assemblyai_cli/commands/agent.py +++ b/assemblyai_cli/commands/agent.py @@ -63,8 +63,7 @@ def agent( raise typer.Exit(code=0) def body(state: AppState, json_mode: bool) -> None: - if output_field is not None and output_field not in ("text", "json"): - raise UsageError(f"Unknown --output {output_field!r}. Choose one of: text, json.") + text_mode, json_mode = output.stream_output_modes(output_field, json_mode) if voice not in VOICES: raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") if system_prompt_file is not None: @@ -90,9 +89,8 @@ def body(state: AppState, json_mode: bool) -> None: if from_file and device is not None: raise UsageError("--device applies only to microphone input.") - text_mode = output_field == "text" renderer = AgentRenderer( - json_mode=(output_field == "json") or (json_mode and not text_mode), + json_mode=json_mode, text_mode=text_mode, mic_input=not from_file, ) diff --git a/assemblyai_cli/commands/llm.py b/assemblyai_cli/commands/llm.py index 56c1030a..72b9dc0f 100644 --- a/assemblyai_cli/commands/llm.py +++ b/assemblyai_cli/commands/llm.py @@ -1,7 +1,5 @@ from __future__ import annotations -import json - import typer from rich.live import Live from rich.markup import escape @@ -26,27 +24,34 @@ class _FollowRenderer: def __init__(self, *, json_mode: bool) -> None: self.json_mode = json_mode self._live: Live | None = None + self._last: Panel | None = None def __enter__(self) -> _FollowRenderer: if not self.json_mode: - self._live = Live(console=output.console, auto_refresh=False, transient=False) + # screen=True draws into the terminal's alternate buffer (like less/htop). + # In the `aai stream -o text | aai llm -f` pipeline two processes share one + # TTY: stream writes status to stderr and the Ctrl-C "^C" echoes into our + # region, desyncing Rich's relative-cursor teardown and duplicating the top + # border. The alt buffer is isolated and restored verbatim on exit, so that + # noise is discarded; we reprint the final panel to the normal screen below. + self._live = Live(console=output.console, auto_refresh=False, screen=True) self._live.start() return self def __call__(self, answer: str, turns: int) -> None: if self.json_mode: - print(json.dumps({"turns": turns, "output": answer}, default=str), flush=True) + output.emit_ndjson({"turns": turns, "output": answer}) elif self._live is not None: title = f"scribe · {turns} turn{'s' if turns != 1 else ''}" - self._live.update( - Panel(escape(answer or "…"), title=title, border_style="aai.brand"), - refresh=True, - ) + self._last = Panel(escape(answer or "…"), title=title, border_style="aai.brand") + self._live.update(self._last, refresh=True) def __exit__(self, *exc: object) -> None: if self._live is not None: - self._live.stop() + self._live.stop() # leaves the alt buffer, restoring the normal screen self._live = None + if self._last is not None: + output.console.print(self._last) # leave the final summary as scrollback @app.command() @@ -67,11 +72,19 @@ def llm( "the answer in place on every finalized turn (e.g. aai stream -o text | aai " 'llm -f "summarize action items as I talk"). Ctrl-C to stop.', ), + output_field: str = typer.Option( + None, + "-o", + "--output", + help="Print one field of the result: text (just the answer, pipe-friendly) or json.", + ), max_tokens: int = typer.Option( gateway.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens to generate." ), list_models: bool = typer.Option(False, "--list-models", help="Print known models and exit."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = typer.Option( + False, "--json", help="Output raw JSON (one object per turn in --follow mode)." + ), ) -> None: """Send a prompt to AssemblyAI's LLM Gateway and print the response. @@ -86,6 +99,11 @@ def llm( def follow_body(state: AppState, json_mode: bool) -> None: if not prompt: raise UsageError("Provide a prompt to run over the streamed transcript.") + if output_field is not None: + raise UsageError( + "--output applies to one-shot mode; --follow renders a live panel " + "(or NDJSON when piped)." + ) if transcript_id: raise UsageError( "--follow runs over live transcript text piped on stdin; it can't be " @@ -109,13 +127,18 @@ def ask(transcript_text: str) -> str: with _FollowRenderer(json_mode=json_mode) as render: transcript: list[str] = [] - for turn in stdio.iter_piped_stdin_lines(): - transcript.append(turn) - render(ask("\n".join(transcript)), len(transcript)) + try: + for turn in stdio.iter_piped_stdin_lines(): + transcript.append(turn) + render(ask("\n".join(transcript)), len(transcript)) + except KeyboardInterrupt: + # Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0). + pass def body(state: AppState, json_mode: bool) -> None: if not prompt: raise UsageError("Provide a prompt, or use --list-models.") + output.validate_output_field(output_field, ("text", "json")) api_key = config.resolve_api_key(profile=state.profile) # Text piped on stdin becomes the content the prompt operates on, unless an # explicit --transcript-id is given (that injects server-side and takes priority). @@ -130,14 +153,15 @@ def body(state: AppState, json_mode: bool) -> None: max_tokens=max_tokens, transcript_id=transcript_id, ) + content = gateway.content_of(response) + if output_field == "text": + # Just the answer, raw — so `… | aai llm -o text "…" | next` composes cleanly. + print(content) + return output.emit( - { - "model": model, - "output": gateway.content_of(response), - "usage": gateway.usage_of(response), - }, + {"model": model, "output": content, "usage": gateway.usage_of(response)}, lambda d: escape(str(d["output"])), - json_mode=json_mode, + json_mode=json_mode or output_field == "json", ) run_command(ctx, follow_body if follow else body, json=json_out) diff --git a/assemblyai_cli/commands/stream.py b/assemblyai_cli/commands/stream.py index 3b095647..6ec5f882 100644 --- a/assemblyai_cli/commands/stream.py +++ b/assemblyai_cli/commands/stream.py @@ -111,8 +111,7 @@ def stream( """ def body(state: AppState, json_mode: bool) -> None: - if output_field is not None and output_field not in ("text", "json"): - raise UsageError(f"Unknown --output {output_field!r}. Choose one of: text, json.") + text_mode, json_mode = output.stream_output_modes(output_field, json_mode) def make_flags(rate: int) -> dict[str, object]: flags: dict[str, object] = { @@ -167,11 +166,7 @@ def make_flags(rate: int) -> dict[str, object]: elif from_file and (sample_rate is not None or device is not None): raise UsageError("--sample-rate and --device apply only to microphone input.") - text_mode = output_field == "text" - renderer = StreamRenderer( - json_mode=(output_field == "json") or (json_mode and not text_mode), - text_mode=text_mode, - ) + renderer = StreamRenderer(json_mode=json_mode, text_mode=text_mode) # Collect finalized turns so we can transform the full transcript at the end. turns: list[str] = [] diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 36b5bfff..995dcc44 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -1,9 +1,7 @@ from __future__ import annotations -import json import tempfile from pathlib import Path -from typing import Any import typer @@ -32,30 +30,6 @@ def _render_transform_steps(d: dict) -> str: return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) -_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "json") - - -def _select_output(transcript: Any, field: str) -> str: - """Render a single transcript field for ``-o/--output`` (raw, pipe-friendly).""" - if field == "id": - return str(getattr(transcript, "id", "") or "") - if field == "status": - return client.status_str(transcript) - if field == "utterances": - utterances = getattr(transcript, "utterances", None) or [] - if utterances: - return "\n".join(f"Speaker {u.speaker}: {u.text}" for u in utterances) - return str(getattr(transcript, "text", "") or "") - if field == "json": - payload = getattr(transcript, "json_response", None) or { - "id": transcript.id, - "status": client.status_str(transcript), - "text": transcript.text, - } - return json.dumps(payload, default=str) - return str(getattr(transcript, "text", "") or "") # "text" (and the validated default) - - @app.command() def transcribe( ctx: typer.Context, @@ -165,10 +139,7 @@ def transcribe( """ def body(state: AppState, json_mode: bool) -> None: - if output_field is not None and output_field not in _OUTPUT_FIELDS: - raise UsageError( - f"Unknown --output {output_field!r}. Choose one of: {', '.join(_OUTPUT_FIELDS)}." - ) + output.validate_output_field(output_field, client.TRANSCRIPT_OUTPUT_FIELDS) flags: dict[str, object] = { "speech_model": speech_model, "language_code": language_code, @@ -258,7 +229,7 @@ def body(state: AppState, json_mode: bool) -> None: if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). - print(_select_output(transcript, output_field)) + print(client.select_transcript_field(transcript, output_field)) return if llm_gateway_prompt: @@ -303,7 +274,7 @@ def body(state: AppState, json_mode: bool) -> None: "status": client.status_str(transcript), "text": transcript.text, } - print(json.dumps(payload, default=str)) + output.emit(payload, lambda d: d, json_mode=True) else: transcribe_render.render_transcript_result(transcript, output.console) diff --git a/assemblyai_cli/commands/transcripts.py b/assemblyai_cli/commands/transcripts.py index 6d92bc6e..26f63ef2 100644 --- a/assemblyai_cli/commands/transcripts.py +++ b/assemblyai_cli/commands/transcripts.py @@ -16,11 +16,18 @@ def get( ctx: typer.Context, transcript_id: str = typer.Argument(..., help="Transcript id."), + output_field: str = typer.Option( + None, + "-o", + "--output", + help="Print one field of the result: text, id, status, utterances, or json.", + ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: """Fetch a past transcript by id and print its text.""" def body(state: AppState, json_mode: bool) -> None: + output.validate_output_field(output_field, client.TRANSCRIPT_OUTPUT_FIELDS) api_key = config.resolve_api_key(profile=state.profile) transcript = client.get_transcript(api_key, transcript_id) if client.status_str(transcript) == "error": @@ -28,6 +35,10 @@ def body(state: AppState, json_mode: bool) -> None: getattr(transcript, "error", None) or "Transcript failed.", transcript_id=transcript_id, ) + if output_field is not None: + # Raw single-field output for pipelines (overrides --json), matching `transcribe`. + print(client.select_transcript_field(transcript, output_field)) + return output.emit( { "id": transcript.id, diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py index 08a4a48d..3f50a305 100644 --- a/assemblyai_cli/main.py +++ b/assemblyai_cli/main.py @@ -1,5 +1,8 @@ from __future__ import annotations +import contextlib +import os +import sys from typing import TYPE_CHECKING import typer @@ -92,3 +95,25 @@ def main( def version() -> None: """Show the CLI version.""" typer.echo(__version__) + + +def _silence_stdout() -> None: + """Point stdout at /dev/null so the interpreter-shutdown flush can't re-raise.""" + with contextlib.suppress(OSError): + os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + + +def run() -> None: + """Console-script entry point: run the app, exiting cleanly on a closed pipe. + + A downstream consumer (e.g. `aai … | head`) can close the pipe before we finish + writing. Without this, the write — or Python's flush at shutdown — raises + BrokenPipeError and prints an ugly "Exception ignored" traceback. We treat a + closed pipe as success: silence stdout and exit 0. Streaming commands also catch + it earlier; this is the catch-all for the one-shot `output.emit`/`print` paths. + """ + try: + app(prog_name="aai") + except BrokenPipeError: + _silence_stdout() + sys.exit(0) diff --git a/assemblyai_cli/output.py b/assemblyai_cli/output.py index 61cf9938..2ae8432f 100644 --- a/assemblyai_cli/output.py +++ b/assemblyai_cli/output.py @@ -9,6 +9,7 @@ from rich.markup import escape from assemblyai_cli import theme +from assemblyai_cli.errors import UsageError if TYPE_CHECKING: from assemblyai_cli.errors import CLIError @@ -37,6 +38,24 @@ def resolve_json(*, explicit: bool) -> bool: return explicit or _is_agentic() +def validate_output_field(field: str | None, allowed: tuple[str, ...]) -> None: + """Reject an unknown ``-o/--output`` value with a consistent, listing error.""" + if field is not None and field not in allowed: + raise UsageError(f"Unknown --output {field!r}. Choose one of: {', '.join(allowed)}.") + + +def stream_output_modes(field: str | None, json_mode: bool) -> tuple[bool, bool]: + """Fold a streaming command's ``-o/--output`` into ``(text_mode, json_mode)``. + + Shared by `stream` and `agent`, whose renderers take the same two flags: `text` + emits plain finalized lines, `json` forces NDJSON, and an unset field falls back + to the auto-detected `json_mode` (JSON when piped/agentic, human otherwise). + """ + validate_output_field(field, ("text", "json")) + text_mode = field == "text" + return text_mode, (field == "json") or (json_mode and not text_mode) + + def emit(data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> None: if json_mode: print(json.dumps(data, default=str)) @@ -44,6 +63,11 @@ def emit(data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> console.print(human_renderer(data)) +def emit_ndjson(obj: object) -> None: + """Write one newline-delimited JSON record to stdout, flushed for live pipelines.""" + print(json.dumps(obj, default=str), flush=True) + + def emit_error(err: CLIError, *, json_mode: bool) -> None: # Always to stderr, so stdout stays clean for `aai … | next-tool` pipelines. if json_mode: diff --git a/pyproject.toml b/pyproject.toml index 42162a4d..7eb7fd56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dev = [ ] [project.scripts] -aai = "assemblyai_cli.main:app" +aai = "assemblyai_cli.main:run" [tool.hatch.build.targets.wheel] packages = ["assemblyai_cli"] diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 618f2d11..8b34541d 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -200,6 +200,64 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): assert len(calls) == 2 +def test_llm_output_text_prints_raw_answer(monkeypatch): + _auth() + monkeypatch.setattr( + "assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("just the answer") + ) + result = runner.invoke(app, ["llm", "hi", "-o", "text"]) + assert result.exit_code == 0 + # Raw text, not JSON — composes cleanly into the next pipe stage. + assert result.output.strip() == "just the answer" + assert "{" not in result.output + + +def test_llm_output_json_forces_json(monkeypatch): + _auth() + monkeypatch.setattr( + "assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("hello") + ) + result = runner.invoke(app, ["llm", "hi", "-o", "json"]) + assert result.exit_code == 0 + assert json.loads(result.output)["output"] == "hello" + + +def test_llm_output_invalid_field_exits_2(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + result = runner.invoke(app, ["llm", "hi", "-o", "bogus"]) + assert result.exit_code == 2 + + +def test_llm_output_with_follow_is_rejected(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + result = runner.invoke(app, ["llm", "hi", "-f", "-o", "text"], input="x\n") + assert result.exit_code == 2 + assert "one-shot" in result.output + + +def test_llm_follow_stops_cleanly_on_interrupt(monkeypatch): + _auth() + calls = [] + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + calls.append(messages[-1]["content"]) + if len(calls) == 2: + raise KeyboardInterrupt # user hits Ctrl-C mid-meeting + return _payload("ok") + + monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke( + app, ["llm", "summarize", "--follow", "--json"], input="alpha\nbeta\ngamma\n" + ) + # Ctrl-C is a normal stop, not an error. + assert result.exit_code == 0 + updates = [json.loads(line) for line in result.output.splitlines() if line.strip()] + assert len(updates) == 1 + assert updates[0]["turns"] == 1 + + def test_llm_passes_model_and_max_tokens(monkeypatch): _auth() seen = {} diff --git a/tests/test_main_module.py b/tests/test_main_module.py index ed53cea2..0cd031e0 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -1,6 +1,36 @@ import subprocess import sys +import pytest + +import assemblyai_cli.main as main_mod + + +def test_run_exits_clean_on_broken_pipe(monkeypatch): + """A closed downstream pipe (`| head`) is success, not an error traceback.""" + + def boom(*a, **k): + raise BrokenPipeError + + monkeypatch.setattr(main_mod, "app", boom) + # Don't dup2 the real stdout fd during the test; just verify the exit contract. + monkeypatch.setattr(main_mod, "_silence_stdout", lambda: None) + with pytest.raises(SystemExit) as exc: + main_mod.run() + assert exc.value.code == 0 + + +def test_run_passes_through_normal_exit(monkeypatch): + """Non-pipe exits keep their code (Typer raises SystemExit on normal completion).""" + + def normal(*a, **k): + raise SystemExit(3) + + monkeypatch.setattr(main_mod, "app", normal) + with pytest.raises(SystemExit) as exc: + main_mod.run() + assert exc.value.code == 3 + def test_python_dash_m_entrypoint_runs(): """`python -m assemblyai_cli` wires up the Typer app (exercises __main__.py).""" diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index d675ab97..05846df1 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -20,6 +20,36 @@ def test_get_prints_transcript_text(): assert "retrieved text" in result.output +def test_get_output_text_prints_raw(): + config.set_api_key("default", "sk_live") + fake = MagicMock() + fake.id = "t_42" + fake.text = "retrieved text" + fake.status = "completed" + with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "text"]) + assert result.exit_code == 0 + assert result.output.strip() == "retrieved text" + + +def test_get_output_id_prints_id(): + config.set_api_key("default", "sk_live") + fake = MagicMock() + fake.id = "t_42" + fake.text = "retrieved text" + fake.status = "completed" + with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "id"]) + assert result.exit_code == 0 + assert result.output.strip() == "t_42" + + +def test_get_output_invalid_field_exits_2(): + config.set_api_key("default", "sk_live") + result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "bogus"]) + assert result.exit_code == 2 + + def test_list_renders_rows(): config.set_api_key("default", "sk_live") rows = [{"id": "t1", "status": "completed"}, {"id": "t2", "status": "processing"}] From 0163b7981859552c0fe2bcd7b8fcb93c3616c51c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 21:03:26 -0700 Subject: [PATCH 84/87] feat(transcribe): add -o srt output via SDK export_subtitles_srt Expose SubRip captions through `-o srt` on `transcribe` and `transcripts get`, backed by the SDK's export_subtitles_srt(). Network/auth errors from the /srt export endpoint surface as the usual clean CLI errors. Includes a "burn captions onto a video" README recipe. Also bundles in-flight working-tree changes across agent/render/streaming/main. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 ++++++++ assemblyai_cli/agent/audio.py | 12 ++------ assemblyai_cli/agent/render.py | 19 ++----------- assemblyai_cli/client.py | 38 ++++++++++++++++++-------- assemblyai_cli/commands/transcribe.py | 35 ++++++------------------ assemblyai_cli/commands/transcripts.py | 8 ++---- assemblyai_cli/main.py | 12 ++------ assemblyai_cli/microphone.py | 32 +++++++++++++--------- assemblyai_cli/render.py | 16 ++++++++++- assemblyai_cli/stdio.py | 14 ++++++++++ assemblyai_cli/streaming/render.py | 23 ++-------------- tests/test_client.py | 28 ++++++++++++++++++- tests/test_main_module.py | 2 +- tests/test_transcribe.py | 11 ++++++++ 14 files changed, 144 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 115e062f..ec7db4d6 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ single field so you rarely need `jq`. aai transcribe call.mp3 -o text # just the transcript text aai transcribe call.mp3 -o id # just the transcript id aai transcribe call.mp3 -o utterances # speaker-labeled lines +aai transcribe video.mp4 -o srt # SubRip (.srt) captions aai transcribe call.mp3 -o json | jq . # full JSON when you do want jq ``` @@ -290,6 +291,16 @@ aai transcribe call.mp3 --speaker-labels -o utterances | awk -F: '{print $1}' | aai transcribe call.mp3 --redact-pii --redact-pii-policy person_name,phone_number,email_address -o text | pbcopy ``` +**Burn captions onto a video** — `-o srt` writes a SubRip file; ffmpeg's `subtitles` +filter reads it from disk (it needs a seekable file, so the `.srt` is a real +intermediate, not a pipe). Works on a local file or a YouTube URL: + +```sh +aai transcribe "https://www.youtube.com/watch?v=VIDEO_ID" -o srt > captions.srt +yt-dlp -f 'bv*+ba/b' -o video.mp4 "https://www.youtube.com/watch?v=VIDEO_ID" +ffmpeg -i video.mp4 -vf "subtitles=captions.srt" -c:a copy out.mp4 +``` + **DIY voice assistant** — speak a question, hear the answer (use headphones): ```sh diff --git a/assemblyai_cli/agent/audio.py b/assemblyai_cli/agent/audio.py index 918073ff..5b4bb781 100644 --- a/assemblyai_cli/agent/audio.py +++ b/assemblyai_cli/agent/audio.py @@ -7,7 +7,7 @@ from typing import Any from assemblyai_cli.errors import CLIError -from assemblyai_cli.microphone import _FALLBACK_RATE, _resample, audio_missing_error +from assemblyai_cli.microphone import _default_rate, _resample, audio_missing_error SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate @@ -19,15 +19,7 @@ def _output_default_rate(device: int | None = None) -> int: 'paramErr' (-50) from forcing an unsupported one; agent audio (24 kHz) is resampled to it. Falls back to a safe default when the device can't be queried. """ - try: - import sounddevice as sd - except ImportError as exc: - raise audio_missing_error() from exc - try: - rate = int(sd.query_devices(device, "output")["default_samplerate"]) - except Exception: # noqa: BLE001 - any query failure -> safe fallback, never crash here - return _FALLBACK_RATE - return rate if rate > 0 else _FALLBACK_RATE + return _default_rate("output", device) def _default_output_stream(rate: int) -> Any: diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py index 6430aa2d..216be082 100644 --- a/assemblyai_cli/agent/render.py +++ b/assemblyai_cli/agent/render.py @@ -1,7 +1,6 @@ from __future__ import annotations -import sys -from typing import Any, TextIO +from typing import Any from rich.text import Text @@ -23,23 +22,11 @@ class AgentRenderer(BaseRenderer): Audio payloads are never written; only text/state events are surfaced. """ - def __init__( - self, - *, - mic_input: bool = True, - text_mode: bool = False, - err: TextIO | None = None, - **kwargs: Any, - ) -> None: + def __init__(self, *, mic_input: bool = True, **kwargs: Any) -> None: + # text_mode/err/json_mode/out/console are handled by BaseRenderer. super().__init__(**kwargs) # File-driven runs have no mic, so they skip the "start talking" prompt. self.mic_input = mic_input - self.text_mode = text_mode - self._err = err if err is not None else sys.stderr - - def _status(self, message: str) -> None: - """Write a status notice to stderr so it never pollutes piped stdout.""" - print(message, file=self._err, flush=True) # --- lifecycle --------------------------------------------------------- def connected(self) -> None: diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index dab445a1..20044fe7 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -1,9 +1,6 @@ from __future__ import annotations -import contextlib import json -import os -import sys from collections.abc import Callable, Iterable from typing import Any @@ -15,6 +12,7 @@ StreamingParameters, ) +from assemblyai_cli import stdio from assemblyai_cli.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" @@ -91,8 +89,22 @@ def status_str(transcript: aai.Transcript) -> str: return str(getattr(status, "value", status)) +def transcript_summary(transcript: Any) -> dict[str, object]: + """The compact ``{id, status, text}`` dict the commands emit for a transcript.""" + return { + "id": transcript.id, + "status": status_str(transcript), + "text": transcript.text, + } + + +def transcript_json_payload(transcript: Any) -> dict[str, object]: + """The transcript's full ``json_response`` if present, else the compact summary.""" + return getattr(transcript, "json_response", None) or transcript_summary(transcript) + + # Fields `transcribe` and `transcripts get` expose via `-o/--output` (raw, pipe-friendly). -TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "json") +TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "srt", "json") def select_transcript_field(transcript: Any, field: str) -> str: @@ -106,13 +118,16 @@ def select_transcript_field(transcript: Any, field: str) -> str: if utterances: return "\n".join(f"Speaker {u.speaker}: {u.text}" for u in utterances) return str(getattr(transcript, "text", "") or "") + if field == "srt": + # The SDK fetches SRT from the `/srt` export endpoint, so this hits the network. + try: + return transcript.export_subtitles_srt() + except Exception as exc: + if is_auth_failure(exc): + raise auth_failure() from exc + raise APIError(f"Could not export SRT subtitles: {exc}") from exc if field == "json": - payload = getattr(transcript, "json_response", None) or { - "id": transcript.id, - "status": status_str(transcript), - "text": transcript.text, - } - return json.dumps(payload, default=str) + return json.dumps(transcript_json_payload(transcript), default=str) return str(getattr(transcript, "text", "") or "") # "text" (and the validated default) @@ -154,8 +169,7 @@ def handler(_client: Any, event: Any) -> None: try: cb(event) except BrokenPipeError: - with contextlib.suppress(Exception): - os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + stdio.silence_stdout() return handler diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 995dcc44..5aea5380 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -123,7 +123,7 @@ def transcribe( None, "-o", "--output", - help="Print one field of the result: text, id, status, utterances, or json.", + help="Print one field of the result: text, id, status, utterances, srt, or json.", ), show_code: bool = typer.Option( False, @@ -238,29 +238,17 @@ def body(state: AppState, json_mode: bool) -> None: steps: list[dict[str, str]] = [] previous: str | None = None for i, prompt_text in enumerate(llm_gateway_prompt): - if i == 0: - out = llm.transform_transcript( - api_key, - prompt=prompt_text, - model=model, - transcript_id=transcript.id, - max_tokens=max_tokens, - ) - else: - out = llm.transform_transcript( - api_key, - prompt=prompt_text, - model=model, - transcript_text=previous, - max_tokens=max_tokens, - ) + # First prompt runs over the transcript (by id); each later one over + # the prior response. + target = {"transcript_id": transcript.id} if i == 0 else {"transcript_text": previous} + out = llm.transform_transcript( + api_key, prompt=prompt_text, model=model, max_tokens=max_tokens, **target + ) steps.append({"prompt": prompt_text, "output": out}) previous = out output.emit( { - "id": transcript.id, - "status": client.status_str(transcript), - "text": transcript.text, + **client.transcript_summary(transcript), "transform": {"model": model, "steps": steps}, }, _render_transform_steps, @@ -269,12 +257,7 @@ def body(state: AppState, json_mode: bool) -> None: return if json_mode: - payload = getattr(transcript, "json_response", None) or { - "id": transcript.id, - "status": client.status_str(transcript), - "text": transcript.text, - } - output.emit(payload, lambda d: d, json_mode=True) + output.emit(client.transcript_json_payload(transcript), lambda d: d, json_mode=True) else: transcribe_render.render_transcript_result(transcript, output.console) diff --git a/assemblyai_cli/commands/transcripts.py b/assemblyai_cli/commands/transcripts.py index 26f63ef2..b0e550a7 100644 --- a/assemblyai_cli/commands/transcripts.py +++ b/assemblyai_cli/commands/transcripts.py @@ -20,7 +20,7 @@ def get( None, "-o", "--output", - help="Print one field of the result: text, id, status, utterances, or json.", + help="Print one field of the result: text, id, status, utterances, srt, or json.", ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: @@ -40,11 +40,7 @@ def body(state: AppState, json_mode: bool) -> None: print(client.select_transcript_field(transcript, output_field)) return output.emit( - { - "id": transcript.id, - "status": client.status_str(transcript), - "text": transcript.text, - }, + client.transcript_summary(transcript), lambda d: escape(str(d["text"])), json_mode=json_mode, ) diff --git a/assemblyai_cli/main.py b/assemblyai_cli/main.py index 3f50a305..8f11d297 100644 --- a/assemblyai_cli/main.py +++ b/assemblyai_cli/main.py @@ -1,7 +1,5 @@ from __future__ import annotations -import contextlib -import os import sys from typing import TYPE_CHECKING @@ -13,7 +11,7 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from assemblyai_cli import __version__ +from assemblyai_cli import __version__, stdio from assemblyai_cli.commands import ( agent, claude, @@ -97,12 +95,6 @@ def version() -> None: typer.echo(__version__) -def _silence_stdout() -> None: - """Point stdout at /dev/null so the interpreter-shutdown flush can't re-raise.""" - with contextlib.suppress(OSError): - os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) - - def run() -> None: """Console-script entry point: run the app, exiting cleanly on a closed pipe. @@ -115,5 +107,5 @@ def run() -> None: try: app(prog_name="aai") except BrokenPipeError: - _silence_stdout() + stdio.silence_stdout() sys.exit(0) diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py index a596620c..99c56a26 100644 --- a/assemblyai_cli/microphone.py +++ b/assemblyai_cli/microphone.py @@ -1,10 +1,18 @@ from __future__ import annotations +import warnings from collections.abc import Callable, Iterator from typing import Any from assemblyai_cli.errors import CLIError +with warnings.catch_warnings(): + # audioop is deprecated stdlib on 3.11/3.12 (warning suppressed here) and is + # provided by the `audioop-lts` package on 3.13+, where it left the stdlib. + # Imported once at module load so the per-chunk resample path stays hot. + warnings.simplefilter("ignore", DeprecationWarning) + import audioop + # Used when the device's native rate can't be determined (e.g. headless CI). _FALLBACK_RATE = 48000 @@ -18,33 +26,31 @@ def audio_missing_error() -> CLIError: ) -def _device_default_rate(device: int | None = None) -> int: - """The input device's native sample rate. +def _default_rate(kind: str, device: int | None = None) -> int: + """A device's native sample rate for `kind` ("input" or "output"). - Opening the mic at its own rate avoids CoreAudio 'paramErr' (-50) failures - that happen when a device is forced to an unsupported rate. Falls back to a - safe default if the device can't be queried (no input device, headless CI). + Opening a device at its own rate avoids CoreAudio 'paramErr' (-50) failures + that happen when it's forced to an unsupported rate. Falls back to a safe + default if the device can't be queried (no device, headless CI). """ try: import sounddevice as sd except ImportError as exc: raise audio_missing_error() from exc try: - rate = int(sd.query_devices(device, "input")["default_samplerate"]) + rate = int(sd.query_devices(device, kind)["default_samplerate"]) except Exception: # noqa: BLE001 - any query failure -> safe fallback, never crash here return _FALLBACK_RATE return rate if rate > 0 else _FALLBACK_RATE +def _device_default_rate(device: int | None = None) -> int: + """The input device's native sample rate (see `_default_rate`).""" + return _default_rate("input", device) + + def _resample(chunk: bytes, state: Any, *, src_rate: int, dst_rate: int) -> tuple[bytes, Any]: """Resample one PCM16 mono fragment from `src_rate` to `dst_rate`.""" - import warnings - - with warnings.catch_warnings(): - # audioop is deprecated stdlib on 3.11/3.12 (warning suppressed here) and is - # provided by the `audioop-lts` package on 3.13+, where it left the stdlib. - warnings.simplefilter("ignore", DeprecationWarning) - import audioop return audioop.ratecv(chunk, 2, 1, src_rate, dst_rate, state) diff --git a/assemblyai_cli/render.py b/assemblyai_cli/render.py index ccef5bf0..1807e609 100644 --- a/assemblyai_cli/render.py +++ b/assemblyai_cli/render.py @@ -21,13 +21,27 @@ class BaseRenderer: """ def __init__( - self, *, json_mode: bool, out: TextIO | None = None, console: Console | None = None + self, + *, + json_mode: bool, + out: TextIO | None = None, + console: Console | None = None, + text_mode: bool = False, + err: TextIO | None = None, ) -> None: self.json_mode = json_mode self.out = out if out is not None else sys.stdout + # text mode emits plain transcript lines to stdout and status notices to + # stderr, so piping never mixes the two; err defaults to real stderr. + self.text_mode = text_mode + self._err = err if err is not None else sys.stderr self._console = console self._live: Live | None = None + def _status(self, message: str) -> None: + """Write a status notice to stderr so it never pollutes piped stdout.""" + print(message, file=self._err, flush=True) + # --- JSON output (plain text; preserves BrokenPipe for `| head`) ------- def _emit(self, obj: object) -> None: """Write one NDJSON event.""" diff --git a/assemblyai_cli/stdio.py b/assemblyai_cli/stdio.py index 32469ab4..9ec1f65a 100644 --- a/assemblyai_cli/stdio.py +++ b/assemblyai_cli/stdio.py @@ -1,9 +1,23 @@ from __future__ import annotations +import contextlib +import os import sys from collections.abc import Iterator +def silence_stdout() -> None: + """Point stdout at /dev/null so a closed downstream pipe can't re-raise. + + Once a consumer (e.g. ``| head``) closes the pipe, a later write — or Python's + flush at interpreter shutdown — raises BrokenPipeError with an ugly "Exception + ignored" traceback. Redirecting the fd makes those writes no-ops. Shared by the + one-shot entry point and the streaming reader thread. + """ + with contextlib.suppress(OSError): + os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + + def stdin_is_piped() -> bool: """True when stdin is a pipe/redirect rather than an interactive terminal.""" stream = sys.stdin diff --git a/assemblyai_cli/streaming/render.py b/assemblyai_cli/streaming/render.py index a62738ae..27ea4f37 100644 --- a/assemblyai_cli/streaming/render.py +++ b/assemblyai_cli/streaming/render.py @@ -1,9 +1,5 @@ from __future__ import annotations -import sys -from typing import TextIO - -from rich.console import Console from rich.text import Text from assemblyai_cli.render import BaseRenderer @@ -17,24 +13,9 @@ class StreamRenderer(BaseRenderer): notices ("Listening…") go to stderr. Lets `aai stream -o text | aai llm "…"` pipe clean transcript text downstream. - human (default): a live-updating line through Rich. - """ - def __init__( - self, - *, - json_mode: bool, - text_mode: bool = False, - out: TextIO | None = None, - console: Console | None = None, - err: TextIO | None = None, - ) -> None: - super().__init__(json_mode=json_mode, out=out, console=console) - self.text_mode = text_mode - self._err = err if err is not None else sys.stderr - - def _status(self, message: str) -> None: - """Write a status notice to stderr so it never pollutes piped stdout.""" - print(message, file=self._err, flush=True) + Construction and the json/text/human plumbing live in BaseRenderer. + """ def begin(self, event: object) -> None: # The "Listening…" notice waits for the mic (see listening()); opening the diff --git a/tests/test_client.py b/tests/test_client.py index e08043de..82c727ae 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -126,6 +126,31 @@ def test_transcribe_raises_on_error_status(): assert exc.value.transcript_id == "t_err" +def test_select_transcript_field_srt_uses_sdk(): + t = MagicMock() + t.export_subtitles_srt.return_value = "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" + assert client.select_transcript_field(t, "srt") == ( + "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" + ) + t.export_subtitles_srt.assert_called_once_with() + + +def test_select_transcript_field_srt_network_error_becomes_apierror(): + t = MagicMock() + t.export_subtitles_srt.side_effect = RuntimeError("connection reset") + with pytest.raises(APIError): + client.select_transcript_field(t, "srt") + + +def test_select_transcript_field_srt_auth_error_becomes_not_authenticated(): + from assemblyai_cli.errors import NotAuthenticated + + t = MagicMock() + t.export_subtitles_srt.side_effect = RuntimeError("HTTP 401 Unauthorized") + with pytest.raises(NotAuthenticated): + client.select_transcript_field(t, "srt") + + def test_get_transcript_calls_sdk(): fake = MagicMock() with patch.object(client.aai.Transcript, "get_by_id", return_value=fake) as g: @@ -293,7 +318,8 @@ def test_stream_audio_swallows_broken_pipe_in_callback(monkeypatch): # A closed downstream pipe makes a turn write raise BrokenPipeError on the SDK's # reader thread; the guard must swallow it instead of dumping a thread traceback. monkeypatch.setattr(client, "StreamingClient", _FakeStreamingClient) - monkeypatch.setattr(client.os, "dup2", lambda *a, **k: None) # never touch real stdout + # never touch the real stdout fd during the test + monkeypatch.setattr("assemblyai_cli.stdio.silence_stdout", lambda: None) def on_turn(_event): raise BrokenPipeError diff --git a/tests/test_main_module.py b/tests/test_main_module.py index 0cd031e0..72ff63f2 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -14,7 +14,7 @@ def boom(*a, **k): monkeypatch.setattr(main_mod, "app", boom) # Don't dup2 the real stdout fd during the test; just verify the exit contract. - monkeypatch.setattr(main_mod, "_silence_stdout", lambda: None) + monkeypatch.setattr("assemblyai_cli.stdio.silence_stdout", lambda: None) with pytest.raises(SystemExit) as exc: main_mod.run() assert exc.value.code == 0 diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 1cefa1cd..096afac7 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -98,6 +98,17 @@ def test_transcribe_output_id_field(): assert result.output.strip() == "t_1" +def test_transcribe_output_srt_field(): + _auth() + t = _fake_transcript() + t.export_subtitles_srt.return_value = "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" + with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "srt"]) + assert result.exit_code == 0 + assert "00:00:00,000 --> 00:00:02,000" in result.output # SRT body, pipe-friendly + t.export_subtitles_srt.assert_called_once() + + def test_transcribe_output_invalid_exits_2(): _auth() with patch( From 70153e2bdbb80aa5777fb288080a890a5afb9ac2 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 21:07:45 -0700 Subject: [PATCH 85/87] =?UTF-8?q?docs(readme):=20add=20YouTube=20=E2=86=92?= =?UTF-8?q?=20SRT=20=E2=86=92=20burn=20captions=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-line yt-dlp + aai transcribe -o srt + ffmpeg chain for captioning a YouTube video. Notes --no-playlist for radio links and that burned SRT is static line captions, not word-by-word karaoke. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ec7db4d6..23ff037d 100644 --- a/README.md +++ b/README.md @@ -291,16 +291,22 @@ aai transcribe call.mp3 --speaker-labels -o utterances | awk -F: '{print $1}' | aai transcribe call.mp3 --redact-pii --redact-pii-policy person_name,phone_number,email_address -o text | pbcopy ``` -**Burn captions onto a video** — `-o srt` writes a SubRip file; ffmpeg's `subtitles` -filter reads it from disk (it needs a seekable file, so the `.srt` is a real -intermediate, not a pipe). Works on a local file or a YouTube URL: +**Caption a YouTube video (sing-along subtitles)** — download the video, transcribe it +to SubRip with `-o srt`, then burn the captions in with ffmpeg. These steps pass *files* +to each other (not stdin/stdout), and ffmpeg's `subtitles` filter needs a seekable file, +so chain them with `&&` rather than `|` — each step runs only if the previous succeeds: ```sh -aai transcribe "https://www.youtube.com/watch?v=VIDEO_ID" -o srt > captions.srt -yt-dlp -f 'bv*+ba/b' -o video.mp4 "https://www.youtube.com/watch?v=VIDEO_ID" -ffmpeg -i video.mp4 -vf "subtitles=captions.srt" -c:a copy out.mp4 +URL="https://www.youtube.com/watch?v=6YzGOq42zLk&list=RD6YzGOq42zLk&start_radio=1" + +yt-dlp --no-playlist -f 'bv*+ba/b' --merge-output-format mp4 -o video.mp4 "$URL" && aai transcribe video.mp4 -o srt > captions.srt && ffmpeg -i video.mp4 -vf "subtitles=captions.srt" -c:a copy out.mp4 ``` +`--no-playlist` matters for music links: the `&list=RD…` suffix is an autoplay radio, so +without it yt-dlp downloads an endless mix instead of the one video. This burns in +**static per-line captions** — for true word-by-word karaoke highlighting you'd render an +ASS subtitle file from the transcript's word timings (`-o json` → `words[]`) instead. + **DIY voice assistant** — speak a question, hear the answer (use headphones): ```sh From f6c30c333accc78d0f6489f4ccfaef0449bd90f0 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 21:11:21 -0700 Subject: [PATCH 86/87] ci(check): install uv so check.sh runs; fix mypy + format The check job installed deps with pip but check.sh drives every tool through `uv run`/`uv build`, so CI died on `uv: command not found` (long-standing since 44b050d). Install uv from PyPI in the check job. Also clear the gates that surfaced once check.sh actually ran: - client.py: wrap export_subtitles_srt() in str() (mypy no-any-return) - ruff format reflow in claude.py and transcribe.py Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 ++++- assemblyai_cli/client.py | 2 +- assemblyai_cli/commands/claude.py | 6 +++++- assemblyai_cli/commands/transcribe.py | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c05d4d14..16c4cfd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,11 @@ jobs: - name: System audio deps (PortAudio for sounddevice) run: sudo apt-get update && sudo apt-get install -y libportaudio2 + # check.sh runs every tool through `uv run` / `uv build` for a locked, + # reproducible env, so uv must be on PATH (installed from PyPI to match the + # repo's pip-based, no-new-action posture). - name: Install - run: python -m pip install -e ".[dev]" + run: python -m pip install -e ".[dev]" uv - name: Lint, typecheck, test run: ./scripts/check.sh diff --git a/assemblyai_cli/client.py b/assemblyai_cli/client.py index 20044fe7..c768613b 100644 --- a/assemblyai_cli/client.py +++ b/assemblyai_cli/client.py @@ -121,7 +121,7 @@ def select_transcript_field(transcript: Any, field: str) -> str: if field == "srt": # The SDK fetches SRT from the `/srt` export endpoint, so this hits the network. try: - return transcript.export_subtitles_srt() + return str(transcript.export_subtitles_srt()) except Exception as exc: if is_auth_failure(exc): raise auth_failure() from exc diff --git a/assemblyai_cli/commands/claude.py b/assemblyai_cli/commands/claude.py index f0fc2b6b..3e7109ce 100644 --- a/assemblyai_cli/commands/claude.py +++ b/assemblyai_cli/commands/claude.py @@ -103,7 +103,11 @@ def _install_skill(force: bool) -> Step: # didn't ask to --force, report `already` instead of silently re-downloading # it and always claiming `installed`. if _skill_installed() and not force: - return {"name": "skill", "status": "already", "detail": f"assemblyai skill at {_skill_dir()}"} + return { + "name": "skill", + "status": "already", + "detail": f"assemblyai skill at {_skill_dir()}", + } # --global: install at user scope (not project scope, which `skills` auto-selects # when run inside a project) so the skill lands in ~/.claude/skills where `status` # looks. npx -y skips its install prompt; the longer timeout covers the download. diff --git a/assemblyai_cli/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py index 5aea5380..1a56817f 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/assemblyai_cli/commands/transcribe.py @@ -240,7 +240,9 @@ def body(state: AppState, json_mode: bool) -> None: for i, prompt_text in enumerate(llm_gateway_prompt): # First prompt runs over the transcript (by id); each later one over # the prior response. - target = {"transcript_id": transcript.id} if i == 0 else {"transcript_text": previous} + target = ( + {"transcript_id": transcript.id} if i == 0 else {"transcript_text": previous} + ) out = llm.transform_transcript( api_key, prompt=prompt_text, model=model, max_tokens=max_tokens, **target ) From 069d9ff4652078a887defe033113f8ab6a284d06 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Wed, 3 Jun 2026 21:16:46 -0700 Subject: [PATCH 87/87] ci: install ffmpeg + markdownlint so check.sh and tests run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more missing CI tools surfaced once uv was fixed: - markdownlint CLI: check.sh lints Markdown with it (pinned 0.45.0, via npm on the runner's Node) — was `command not found` in the check job. - ffmpeg: the `--sample` stream tests build a FileSource for the hosted sample URL, which requires ffmpeg; without it FileSource raises before stream_audio, so those 4 tests failed with KeyError: 'params'. Added to both jobs that run pytest (check + pre-commit). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16c4cfd4..64d207db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,15 @@ jobs: python-version: "3.12" cache: pip - - name: System audio deps (PortAudio for sounddevice) - run: sudo apt-get update && sudo apt-get install -y libportaudio2 + # PortAudio backs sounddevice; ffmpeg decodes non-WAV/URL audio (the `--sample` + # stream tests build a FileSource for the hosted sample, which needs ffmpeg). + - name: System deps (PortAudio + ffmpeg) + run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg + + # check.sh lints Markdown via the markdownlint CLI (a Node tool); pin to the + # version used locally. The runner ships Node, so a global npm install suffices. + - name: markdownlint CLI + run: npm install -g markdownlint-cli@0.45.0 # check.sh runs every tool through `uv run` / `uv build` for a locked, # reproducible env, so uv must be on PATH (installed from PyPI to match the @@ -44,8 +51,9 @@ jobs: python-version: "3.12" cache: pip - - name: System audio deps (PortAudio for sounddevice) - run: sudo apt-get update && sudo apt-get install -y libportaudio2 + # PortAudio backs sounddevice; ffmpeg decodes the `--sample` stream source. + - name: System deps (PortAudio + ffmpeg) + run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg # The local pytest hook runs `python -m pytest`, so the package must be importable. - name: Install