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 new file mode 100644 index 00000000..64d207db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +on: + pull_request: + branches: [main] + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + + # 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 + # repo's pip-based, no-new-action posture). + - name: Install + run: python -m pip install -e ".[dev]" uv + + - name: Lint, typecheck, test + run: ./scripts/check.sh + + pre-commit: + name: pre-commit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + + # 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 + run: python -m pip install -e ".[dev]" + + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + + build: + name: build + twine check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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/* + + 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/.gitignore b/.gitignore new file mode 100644 index 00000000..caa963c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# Editor/agent and local planning artifacts +.claude/ +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/.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/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..ea186c08 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +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 -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 + always_run: true 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/README.md b/README.md index a3c02a31..23ff037d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,343 @@ -# cli -AssemblyAI CLI +# AssemblyAI CLI (`aai`) + +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 + +```sh +curl -fsSL https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh +``` + +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: + +```sh +pipx install "git+https://github.com/AssemblyAI/cli.git" # or: pip install --user ... +``` + +Microphone and speaker support (for `stream` and `agent`) is **included by default** — +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 + +```sh +aai login # store your API key (browser-assisted) +aai transcribe --sample # transcribe the hosted wildfires.mp3 sample +``` + +## Commands + +| 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, `--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. | +| `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 (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 +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 +> command runs: +> +> ```sh +> 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 +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 +``` + +`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 +``` + +## 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: + +```sh +aai agent # talk; the agent talks back. Ctrl-C to stop. +aai agent --voice james --greeting "Hi" +aai agent --system-prompt-file persona.txt # load the system prompt from a file +aai agent --list-voices # see available voices +``` + +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. + +## 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 +``` + +## 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 video.mp4 -o srt # SubRip (.srt) captions +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 +``` + +## 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 +``` + +**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 +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 +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 +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. + +## 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 +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) +``` 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..40342726 --- /dev/null +++ b/assemblyai_cli/__main__.py @@ -0,0 +1,4 @@ +from assemblyai_cli.main import run + +if __name__ == "__main__": + run() 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..5b4bb781 --- /dev/null +++ b/assemblyai_cli/agent/audio.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import contextlib +import queue +import threading +from collections.abc import Callable, Iterator +from typing import Any + +from assemblyai_cli.errors import CLIError +from assemblyai_cli.microphone import _default_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. + """ + return _default_rate("output", device) + + +def _default_output_stream(rate: int) -> Any: + """Open a sounddevice PCM16 mono output stream (imported lazily to keep startup fast).""" + try: + import sounddevice as sd + except ImportError as exc: + raise audio_missing_error() from exc + try: + 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 + 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, + output_rate: int | None = None, + rate_query: Callable[[int | None], int] | None = None, + ) -> None: + 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._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 + 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() + if self._thread is not None: + self._thread.join(timeout=2) + if self._stream is not None: + with contextlib.suppress(Exception): + 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 + + +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`; the agent's live mic+speaker run through DuplexAudio. diff --git a/assemblyai_cli/agent/render.py b/assemblyai_cli/agent/render.py new file mode 100644 index 00000000..216be082 --- /dev/null +++ b/assemblyai_cli/agent/render.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any + +from rich.text import Text + +from assemblyai_cli.render import BaseRenderer + + +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): + """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: + # 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 + + # --- lifecycle --------------------------------------------------------- + def connected(self) -> None: + if self.json_mode: + self._emit({"type": "session.ready"}) + 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 (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}) + 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}) + 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: + 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}) + 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: + self._emit({"type": "reply.done", "interrupted": interrupted}) diff --git a/assemblyai_cli/agent/session.py b/assemblyai_cli/agent/session.py new file mode 100644 index 00000000..c9783450 --- /dev/null +++ b/assemblyai_cli/agent/session.py @@ -0,0 +1,232 @@ +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 + +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: 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.""" + return self.ready and not self.muted + + def dispatch(self, event: dict) -> None: + etype = event.get("type") + + 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: + 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._saw_user = True + 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) + # 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. + + 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: 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 + 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 _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, + *, + renderer: Any, + player: Any, + mic: Any, + voice: str, + 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 + + 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}"}) + except Exception as 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, 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] = [] + + 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 sounddevice can't load + player_started = True + threading.Thread(target=_capture, daemon=True).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)) + 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: + 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: + with contextlib.suppress(Exception): + 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/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..c768613b --- /dev/null +++ b/assemblyai_cli/client.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import json +from collections.abc import Callable, Iterable +from typing import Any + +import assemblyai as aai +from assemblyai.streaming.v3 import ( + StreamingClient, + StreamingClientOptions, + StreamingEvents, + 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" + + +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 + + +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: + if is_auth_failure(exc): + return False + raise APIError(f"Could not validate key: {exc}") from exc + except Exception as exc: + 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: + 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: + raise APIError(f"Network error contacting AssemblyAI: {exc}") from exc + return [_item_to_dict(item) for item in resp.transcripts] + + +def transcribe(api_key: str, audio: str, *, config: aai.TranscriptionConfig) -> aai.Transcript: + _configure(api_key) + try: + transcript = aai.Transcriber().transcribe(audio, config=config) + except APIError: + raise + except Exception as exc: + 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) + 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 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", "srt", "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 == "srt": + # The SDK fetches SRT from the `/srt` export endpoint, so this hits the network. + try: + return str(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": + return json.dumps(transcript_json_payload(transcript), 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: + return aai.Transcript.get_by_id(transcript_id) + 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 + + +def stream_audio( + api_key: str, + source: Iterable[bytes], + *, + params: StreamingParameters, + on_begin: Callable[[Any], Any] | None = None, + on_turn: Callable[[Any], Any] | None = None, + on_termination: Callable[[Any], Any] | None = None, +) -> 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. + `params` is a fully-built StreamingParameters (sample_rate/speech_model/etc). + """ + 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: + stdio.silence_stdout() + + return handler + + errors: list[object] = [] + if on_begin is not None: + sc.on(StreamingEvents.Begin, _guard(on_begin)) + if on_turn is not None: + sc.on(StreamingEvents.Turn, _guard(on_turn)) + if on_termination is not None: + sc.on(StreamingEvents.Termination, _guard(on_termination)) + sc.on(StreamingEvents.Error, lambda _client, error: errors.append(error)) + + try: + sc.connect(params) + except CLIError: + raise + 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 + + 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: + 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/code_gen/__init__.py b/assemblyai_cli/code_gen/__init__.py new file mode 100644 index 00000000..01a249b5 --- /dev/null +++ b/assemblyai_cli/code_gen/__init__.py @@ -0,0 +1,25 @@ +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, + *, + llm_gateway: dict[str, object] | None = None, +) -> str: + """Generate runnable Python that reproduces this transcribe invocation.""" + return _transcribe.render(merged, source, llm_gateway=llm_gateway) + + +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/agent.py b/assemblyai_cli/code_gen/agent.py new file mode 100644 index 00000000..fd599337 --- /dev/null +++ b/assemblyai_cli/code_gen/agent.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +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 base64 +import json +import os +import queue +import threading + +import sounddevice as sd +from websockets.sync.client import connect + +API_KEY = os.environ["ASSEMBLYAI_API_KEY"] +WS_URL = "wss://agents.assemblyai.com/v1/ws" +RATE = 24000 # Voice Agent native PCM16 mono sample rate + +# 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() + + +def on_audio(indata, outdata, _frames, _time, _status): + 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: + 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: + chunk = mic_queue.get() + if ready.is_set(): + ws.send(json.dumps({{"type": "input.audio", "audio": base64.b64encode(chunk).decode()}})) + + +stream = sd.RawStream( + samplerate=RATE, channels=1, dtype="int16", blocksize=RATE // 10, callback=on_audio +) +stream.start() + +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 — 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"): + with buffer_lock: + play_buffer += 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: + stream.stop() + stream.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/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/assemblyai_cli/code_gen/snippets.py b/assemblyai_cli/code_gen/snippets.py new file mode 100644 index 00000000..2913d6e4 --- /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"), + "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"), + "safety_summary = getattr(transcript.content_safety, 'summary', None) or {}\n" + "for label, confidence in safety_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/assemblyai_cli/code_gen/stream.py b/assemblyai_cli/code_gen/stream.py new file mode 100644 index 00000000..44fef592 --- /dev/null +++ b/assemblyai_cli/code_gen/stream.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from assemblyai_cli.code_gen import serialize + +# 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 ( +{imports} +) + +# 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={rate})) +finally: + client.disconnect(terminate=True) +""" + + +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 preamble + "\n" + connect + "\n" + _FOOTER.format(rate=rate) diff --git a/assemblyai_cli/code_gen/transcribe.py b/assemblyai_cli/code_gen/transcribe.py new file mode 100644 index 00000000..05eed243 --- /dev/null +++ b/assemblyai_cli/code_gen/transcribe.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import cast + +from assemblyai_cli import llm +from assemblyai_cli.code_gen import serialize, snippets + + +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)" + call = f"transcript = transcriber.transcribe({source!r}, config=config)" + else: + config_block = "" + call = f"transcript = transcriber.transcribe({source!r})" + + imports = ["import assemblyai as aai"] + if llm_gateway: + imports.append("from openai import OpenAI") + + parts = [ + "import os", + "", + *imports, + "", + '# 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)", + "", + ] + + 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 a chained OpenAI-compatible LLM Gateway transform over the transcript. + + 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. + """ + 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},", + ")", + "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/__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..b1e00d80 --- /dev/null +++ b/assemblyai_cli/commands/agent.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import contextlib +from pathlib import Path +from typing import Any + +import typer + +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 +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.streaming.sources import FileSource + +app = typer.Typer() + + +@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."), + system_prompt: str = typer.Option( + DEFAULT_PROMPT, "--system-prompt", help="System prompt (the agent's persona)." + ), + 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."), + 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", + 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. + + 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()) + raise typer.Exit(code=0) + + def body(state: AppState, json_mode: bool) -> None: + 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: + try: + system_prompt_text = system_prompt_file.read_text(encoding="utf-8") + except OSError as exc: + raise CLIError( + 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 + + 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`. + output.print_code(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, + text_mode=text_mode, + mic_input=not from_file, + ) + 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, + # and a NullPlayer since there is no listener for the reply audio. + audio = FileSource(client.resolve_audio_source(source, sample=sample)) + player = NullPlayer() + else: + # 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 + # 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, + renderer=renderer, + player=player, + mic=audio, + voice=voice, + system_prompt=system_prompt_text, + greeting="" if from_file else greeting, + full_duplex=True, # one duplex stream -> mic always open (use headphones) + exit_after_reply=from_file, + ) + 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: + 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 new file mode 100644 index 00000000..3e7109ce --- /dev/null +++ b/assemblyai_cli/commands/claude.py @@ -0,0 +1,273 @@ +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 + +from assemblyai_cli import output, theme +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).", + no_args_is_help=True, +) + +MCP_NAME = "assemblyai-docs" +MCP_URL = "https://mcp.assemblyai.com/docs" +SKILL_REPO = "AssemblyAI/assemblyai-skill" +_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 + # 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: + return _run(["claude", "mcp", "get", MCP_NAME]).returncode == 0 + + +def _install_mcp(scope: str, force: bool) -> Step: + 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"} + + +_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(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. + 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 + # where `status` looks, so the two commands can never disagree. + if not _skill_installed(): + return { + "name": "skill", + "status": "failed", + "detail": ( + 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())} + + +def _skill_dir() -> Path: + # 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() -> Step: + 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() -> Step: + return { + "name": "skill", + "status": "installed" if _skill_installed() else "not_installed", + "detail": str(_skill_dir()), + } + + +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(): + 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() -> Step: + 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: + 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() +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: AppState, 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(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) + + 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: AppState, 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: 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)}." + ) + 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/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/commands/llm.py b/assemblyai_cli/commands/llm.py new file mode 100644 index 00000000..72b9dc0f --- /dev/null +++ b/assemblyai_cli/commands/llm.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +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 +from assemblyai_cli.context import AppState, run_command +from assemblyai_cli.errors import UsageError + +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 + self._last: Panel | None = None + + def __enter__(self) -> _FollowRenderer: + if not self.json_mode: + # 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: + output.emit_ndjson({"turns": turns, "output": answer}) + elif self._live is not None: + title = f"scribe · {turns} turn{'s' if turns != 1 else ''}" + 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() # 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() +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." + ), + 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.', + ), + 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 (one object per turn in --follow mode)." + ), +) -> 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 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 " + "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] = [] + 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). + 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, + messages=messages, + 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": content, "usage": gateway.usage_of(response)}, + lambda d: escape(str(d["output"])), + 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/login.py b/assemblyai_cli/commands/login.py new file mode 100644 index 00000000..3643461d --- /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 AppState, 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: AppState, 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: # noqa: BLE001 - opening a browser is best-effort + output.console.print( + "[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): + 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"[aai.success]Authenticated[/aai.success] on profile '{escape(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: AppState, 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(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: AppState, 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(profile)} key={escape(masked)} reachable={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..d40185e8 --- /dev/null +++ b/assemblyai_cli/commands/samples.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from assemblyai.streaming.v3 import SpeechModel +from rich.markup import escape + +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, +) + +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") +def list_( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List available sample scripts.""" + + def body(_state: AppState, json_mode: bool) -> None: + output.emit( + list(SAMPLES), + 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 (reads ASSEMBLYAI_API_KEY from the environment).""" + + def body(_state: AppState, json_mode: bool) -> None: + if name not in SAMPLES: + raise CLIError( + f"Unknown sample '{name}'. Try: {', '.join(SAMPLES)}.", + error_type="unknown_sample", + exit_code=1, + ) + target_dir = Path.cwd() / name + target_dir.mkdir(parents=True, exist_ok=True) + 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, + ) + target.write_text(_generate(name)) + + output.emit( + {"created": str(target)}, + lambda d: ( + f"Created {escape(d['created'])}\n" + f'Set your key (export ASSEMBLYAI_API_KEY="…"), then run: ' + f"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..6ec5f882 --- /dev/null +++ b/assemblyai_cli/commands/stream.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import typer +from assemblyai.streaming.v3 import SpeechModel + +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 +from assemblyai_cli.streaming.render import StreamRenderer +from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource + +app = typer.Typer() + +DEFAULT_SPEECH_MODEL = SpeechModel.universal_streaming_multilingual.value + + +@app.command() +def stream( + ctx: typer.Context, + source: str = typer.Argument( + 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 | 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."), + # 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."), + 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", + help="Print the equivalent Python SDK code and exit (does not stream).", + ), +) -> None: + """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"). + """ + + def body(state: AppState, json_mode: bool) -> None: + text_mode, json_mode = output.stream_output_modes(output_field, json_mode) + + def make_flags(rate: int) -> dict[str, object]: + 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] + 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, + ) + output.print_code(code_gen.stream(merged)) + return + + api_key = config.resolve_api_key(profile=state.profile) + from_stdin = source == "-" + from_file = bool(source) or sample + 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, text_mode=text_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 | StdinSource, rate: int) -> None: + merged = config_builder.merge_streaming_params( + flags=make_flags(rate), overrides=list(config_kv or []), config_file=config_file + ) + params = config_builder.construct_streaming_params(merged) + + try: + client.stream_audio( + api_key, + audio, + params=params, + 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() + + 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 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)) + 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. + # 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/commands/transcribe.py b/assemblyai_cli/commands/transcribe.py new file mode 100644 index 00000000..1a56817f --- /dev/null +++ b/assemblyai_cli/commands/transcribe.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import typer + +from assemblyai_cli import ( + client, + code_gen, + config, + config_builder, + llm, + output, + stdio, + transcribe_render, + youtube, +) +from assemblyai_cli.context import AppState, run_command +from assemblyai_cli.errors import UsageError + +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, + 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."), + 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."), + 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,...)." + ), + 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." + ), + 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." + ), + 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: 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."), + 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, srt, or json.", + ), + show_code: bool = typer.Option( + 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. + + 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: + output.validate_output_field(output_field, client.TRANSCRIPT_OUTPUT_FIELDS) + 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] + + 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) + gateway = ( + {"prompts": list(llm_gateway_prompt), "model": model, "max_tokens": max_tokens} + if llm_gateway_prompt + else None + ) + output.print_code(code_gen.transcribe(merged, audio, llm_gateway=gateway)) + return + + tc = config_builder.construct_transcription_config(merged) + + api_key = config.resolve_api_key(profile=state.profile) + 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: + 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). + print(client.select_transcript_field(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. + steps: list[dict[str, str]] = [] + previous: str | None = 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} + ) + 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( + { + **client.transcript_summary(transcript), + "transform": {"model": model, "steps": steps}, + }, + _render_transform_steps, + json_mode=json_mode, + ) + return + + if json_mode: + output.emit(client.transcript_json_payload(transcript), lambda d: d, json_mode=True) + else: + transcribe_render.render_transcript_result(transcript, output.console) + + 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..b0e550a7 --- /dev/null +++ b/assemblyai_cli/commands/transcripts.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table +from rich.text import Text + +from assemblyai_cli import client, config, output, theme +from assemblyai_cli.context import AppState, run_command +from assemblyai_cli.errors import APIError + +app = typer.Typer(help="Browse and fetch past transcripts.", no_args_is_help=True) + + +@app.command() +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, srt, 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": + raise APIError( + 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( + client.transcript_summary(transcript), + 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: 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: list[dict[str, object]]) -> Table: + table = Table("id", "status", "created", header_style="aai.heading") + for row in data: + status = str(row["status"]) + table.add_row( + escape(str(row["id"])), + Text(status, style=theme.status_style(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..0f1bda1c --- /dev/null +++ b/assemblyai_cli/config.py @@ -0,0 +1,103 @@ +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__ +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[str, Any]: + path = _config_file() + if not path.exists(): + return {} + with path.open("rb") as fh: + data: dict[str, Any] = tomllib.load(fh) + return data + + +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 str(_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: + with contextlib.suppress(keyring.errors.PasswordDeleteError): + keyring.delete_password(KEYRING_SERVICE, profile) + + +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/config_builder.py b/assemblyai_cli/config_builder.py new file mode 100644 index 00000000..257f197e --- /dev/null +++ b/assemblyai_cli/config_builder.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import assemblyai as aai +from assemblyai.streaming.v3 import SpeechModel, 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 merge_transcribe_config( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> 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 + except Exception as exc: # surface SDK validation as a usage error + raise UsageError(f"Invalid transcription config: {exc}") from exc + + +def build_transcription_config( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> 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): + 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 + 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: + raise + except Exception as exc: + 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: + 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/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..600b6b05 --- /dev/null +++ b/assemblyai_cli/errors.py @@ -0,0 +1,70 @@ +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) + + +# 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", + "authentication", + "api token", + "invalid api key", + "invalid key", +) + +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/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 new file mode 100644 index 00000000..8f11d297 --- /dev/null +++ b/assemblyai_cli/main.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import sys +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__, stdio +from assemblyai_cli.commands import ( + agent, + claude, + doctor, + llm, + login, + samples, + stream, + transcribe, + transcripts, +) +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", + "doctor", + "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: 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) + ) + + +app = typer.Typer( + name="aai", + help="Command-line interface for AssemblyAI.", + no_args_is_help=True, + add_completion=False, + cls=_OrderedGroup, +) + + +@app.callback() +def main( + ctx: typer.Context, + profile: str = typer.Option(None, "--profile", "-p", help="Named credential profile."), +) -> None: + ctx.obj = AppState(profile=profile) + + +# 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(doctor.app) +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__) + + +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: + stdio.silence_stdout() + sys.exit(0) diff --git a/assemblyai_cli/microphone.py b/assemblyai_cli/microphone.py new file mode 100644 index 00000000..99c56a26 --- /dev/null +++ b/assemblyai_cli/microphone.py @@ -0,0 +1,150 @@ +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 + + +def audio_missing_error() -> CLIError: + """The shared 'sounddevice can't be imported' error for mic and speaker paths.""" + return CLIError( + "Audio support (sounddevice) is unavailable. Try: pip install --force-reinstall sounddevice", + error_type="mic_missing", + exit_code=2, + ) + + +def _default_rate(kind: str, device: int | None = None) -> int: + """A device's native sample rate for `kind` ("input" or "output"). + + 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, 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`.""" + return audioop.ratecv(chunk, 2, 1, src_rate, dst_rate, state) + + +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]: + """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 + + 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: + """Iterable of PCM16 chunks captured at the microphone's native rate. + + Shared by `aai stream` (mic input) and `aai agent` (captured speech). The + 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, + *, + 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, + 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) + # 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._capture_rate, device=self.device) + except ImportError as exc: + raise audio_missing_error() 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 + 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: + 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/output.py b/assemblyai_cli/output.py new file mode 100644 index 00000000..2ae8432f --- /dev/null +++ b/assemblyai_cli/output.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Callable +from typing import TYPE_CHECKING, TypeVar + +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 + +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") + + +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 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)) + else: + 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: + 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/assemblyai_cli/render.py b/assemblyai_cli/render.py new file mode 100644 index 00000000..1807e609 --- /dev/null +++ b/assemblyai_cli/render.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +import sys +from typing import TextIO + +from rich.console import Console +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. + + 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, + 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.""" + self._write(json.dumps(obj) + "\n") + + def _write(self, text: str) -> None: + 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 + + # --- human output (Rich) ---------------------------------------------- + def _console_obj(self) -> Console: + if self._console is None: + self._console = theme.make_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 + + @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(self._as_text(text), refresh=True) + + 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(self._as_text(text), refresh=True) + self._commit_live() + elif text is not None: + self._console_obj().print(self._as_text(text)) + + def _line(self, text: str | Text) -> None: + """Print a standalone permanent line, committing any open partial first.""" + self._commit_live() + self._console_obj().print(self._as_text(text)) + + # --- shared lifecycle -------------------------------------------------- + def stopped(self) -> None: + if not self.json_mode: + self._line(Text("Stopped.", style="aai.muted")) + + def close(self) -> None: + """Commit any in-progress line so later output starts clean.""" + if not self.json_mode: + self._commit_live() diff --git a/assemblyai_cli/stdio.py b/assemblyai_cli/stdio.py new file mode 100644 index 00000000..9ec1f65a --- /dev/null +++ b/assemblyai_cli/stdio.py @@ -0,0 +1,66 @@ +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 + 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: + """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 + + +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/__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..27ea4f37 --- /dev/null +++ b/assemblyai_cli/streaming/render.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from rich.text import Text + +from assemblyai_cli.render import BaseRenderer + + +class StreamRenderer(BaseRenderer): + """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. + + 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 + # session only emits the protocol event for JSON consumers. + if self.json_mode: + self._emit({"type": "begin", "id": getattr(event, "id", None)}) + + def listening(self) -> None: + """Announce capture has started — called once the mic is open and recording.""" + 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: + 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}) + elif self.text_mode: + if end and text: + self._write(text + "\n") # plain finalized line, pipe-friendly + elif end: + self._finalize_line(text) + else: + self._update_line(text) + + def termination(self, event: object) -> None: + if self.json_mode: + self._emit( + { + "type": "termination", + "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}) + 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/assemblyai_cli/streaming/sources.py b/assemblyai_cli/streaming/sources.py new file mode 100644 index 00000000..ae93a4cf --- /dev/null +++ b/assemblyai_cli/streaming/sources.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +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 + +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 + + +def _is_url(source: str) -> bool: + return source.startswith(("http://", "https://")) + + +class FileSource: + """Yields real-time-paced 16 kHz mono PCM chunks from a local file or a URL.""" + + def __init__(self, source: str, *, sleep: Callable[[float], object] = time.sleep) -> None: + self.source = source + self._sleep = sleep + self.sample_rate = TARGET_RATE + # 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 source 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.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: # _wav implies a local path + 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", + self.source, # a local path or an http(s) URL; ffmpeg reads both + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-ac", + "1", + "-ar", + str(TARGET_RATE), + "-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.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.") + 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: + # 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() + 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: + detail = proc.stderr.read().decode("utf-8", "replace").strip() if proc.stderr else "" + raise APIError( + f"ffmpeg could not decode {self.source}: {detail or f'exit {proc.returncode}'}" + ) + + +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/assemblyai_cli/theme.py b/assemblyai_cli/theme.py new file mode 100644 index 00000000..8b7654d8 --- /dev/null +++ b/assemblyai_cli/theme.py @@ -0,0 +1,68 @@ +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, + # 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", + "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/assemblyai_cli/transcribe_render.py b/assemblyai_cli/transcribe_render.py new file mode 100644 index 00000000..297f63da --- /dev/null +++ b/assemblyai_cli/transcribe_render.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +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: + 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.""" + _render_text(transcript, console) + _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_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 + # 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: + 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/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/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 new file mode 100644 index 00000000..7eb7fd56 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,99 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +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.13", # >=0.13 vendors its own click (typer._click); we no longer import click + "assemblyai>=0.34", + "rich>=13.0", + "keyring>=24.0", + "platformdirs>=4.0", + "tomli-w>=1.0", + "websockets>=13", + "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] +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", + "pytest-cov>=5.0", + "hypothesis>=6.0", + "ruff>=0.11", + "mypy>=1.10", + "pre-commit>=4.0", +] + +[project.scripts] +aai = "assemblyai_cli.main:run" + +[tool.hatch.build.targets.wheel] +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" +files = ["assemblyai_cli", "tests"] +# Third-party deps (assemblyai, sounddevice) 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.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" + +[tool.ruff.lint] +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. +# 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 new file mode 100755 index 00000000..677aa849 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Lint, typecheck, and test. Run locally before pushing; CI runs this on every PR. +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)" +uv run ruff check . + +echo "==> ruff format --check (src + tests)" +uv run ruff format --check . + +echo "==> mypy (src + tests)" +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: 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 +# 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." 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 new file mode 100644 index 00000000..58d318fb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,60 @@ +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): + # A plain value is the documented way to set a backend's priority. + 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/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 new file mode 100644 index 00000000..7838028a --- /dev/null +++ b/tests/e2e/test_cli_e2e.py @@ -0,0 +1,184 @@ +"""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}" + + +# --- 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", + "--llm-gateway-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_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") + + proc = _run_cli( + [ + "stream", + str(wav), + "--llm-gateway-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_agent_audio.py b/tests/test_agent_audio.py new file mode 100644 index 00000000..97e55888 --- /dev/null +++ b/tests/test_agent_audio.py @@ -0,0 +1,185 @@ +import sys +import types + +import pytest + +from assemblyai_cli.agent.audio import Player, _default_output_stream +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(self): + self.stopped = True + + def close(self): + self.closed = True + + +def test_player_writes_enqueued_audio(): + fake = FakeStream() + 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") + 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, 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") + p.flush() + assert p.pending() == 0 + + +def test_player_worker_survives_write_error(): + class BoomStream(FakeStream): + def write(self, data): + raise RuntimeError("device gone") + + 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 + 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 + + +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 new file mode 100644 index 00000000..284aca1f --- /dev/null +++ b/tests/test_agent_command.py @@ -0,0 +1,293 @@ +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, + exit_after_reply=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, + exit_after_reply=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", + "--system-prompt-file", + str(prompt_file), + "--system-prompt", + "ignored", + ], + ) + assert result.exit_code == 0 + assert seen["voice"] == "james" + 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_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 "headphones" in result.output.lower() # mic stays open -> warn to use headphones + + +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", "--system-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_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") + 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 "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 + + +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_ignores_json_flag(monkeypatch): + def _boom(*a, **k): + raise AssertionError("must not run a session") + + 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 + + +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 diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py new file mode 100644 index 00000000..233b0fe0 --- /dev/null +++ b/tests/test_agent_render.py @@ -0,0 +1,162 @@ +import io +import json + +import pytest + +from assemblyai_cli import theme +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 _human(width=80, color_system=None): + """A human-mode renderer writing to a forced-terminal themed console buffer.""" + buf = io.StringIO() + 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 + + +# --- json mode (unchanged) ------------------------------------------------- +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_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_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_json_emit_propagates_broken_pipe(): + class BrokenOut: + def write(self, _text): + raise BrokenPipeError("downstream closed") + + def flush(self): + pass + + r = AgentRenderer(json_mode=True, out=BrokenOut()) + with pytest.raises(BrokenPipeError): # propagates so the command stops cleanly + r.connected() + + +def test_json_emit_swallows_non_pipe_errors(): + class FlakyOut: + def write(self, _text): + raise OSError("transient write error") + + def flush(self): + pass + + 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 + + +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) + 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 diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py new file mode 100644 index 00000000..0157ced0 --- /dev/null +++ b/tests/test_agent_session.py @@ -0,0 +1,395 @@ +import base64 +import json + +import pytest + +from assemblyai_cli.agent.session import VoiceAgentSession, _send_audio_loop, run_session +from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated + + +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 + self.started = False + self.closed = False + + def enqueue(self, pcm): + self.enqueued.append(pcm) + + def flush(self): + self.flushed += 1 + + def start(self): + self.started = True + + def close(self): + self.closed = True + + +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, + ) + + +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_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"]) + + +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 _CloseError(1008) # Voice Agent rejects a bad key with close 1008 + + 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 _CloseError(1008) + + 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_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") + + 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"}) + 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 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..23019eeb --- /dev/null +++ b/tests/test_claude.py @@ -0,0 +1,431 @@ +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from assemblyai_cli.main import app + +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)) + 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. 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, 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) + 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 + 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") + + +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", + "-y", + "skills", + "add", + "AssemblyAI/assemblyai-skill", + "--global", + "--yes", + ] 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.""" + _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): + _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_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}) + 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 ["npx", "-y", "skills", "remove", "assemblyai", "--global"] 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) + skill = _skill_path() + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + # 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}, removes_skill=False), + ) + + 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 + + +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_claude_render.py b/tests/test_claude_render.py new file mode 100644 index 00000000..efa86cdf --- /dev/null +++ b/tests/test_claude_render.py @@ -0,0 +1,27 @@ +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) + # 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[32m" in out # aai.success (green) → "installed" + assert "\x1b[1;31m" in out # aai.error (bold red) → "failed" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..82c727ae --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,419 @@ +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 _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() + 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_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") + with pytest.raises(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_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 + fake_transcriber = MagicMock() + fake_transcriber.transcribe.return_value = fake_transcript + + cfg = aai.TranscriptionConfig(speaker_labels=True) + with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): + result = client.transcribe("sk", "audio.mp3", config=cfg) + + fake_transcriber.transcribe.assert_called_once_with("audio.mp3", config=cfg) + 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): + with pytest.raises(APIError) as exc: + client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) + 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: + result = client.get_transcript("sk", "t_123") + g.assert_called_once_with("t_123") + 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): + with pytest.raises(APIError): + client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) + + +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): + with pytest.raises(NotAuthenticated): + client.transcribe("sk_bad", "audio.mp3", config=aai.TranscriptionConfig()) + + +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"], params=_stream_params(), 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"], params=_stream_params()) + + +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"], + params=_stream_params(), + 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"], params=_stream_params()) + + +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"], params=_stream_params()) + + +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"], params=_stream_params()) + + +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"], params=_stream_params()) + 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) + # never touch the real stdout fd during the test + monkeypatch.setattr("assemblyai_cli.stdio.silence_stdout", lambda: None) + + 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 + + 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"], 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): + 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"], + params=_stream_params(), + on_termination=lambda e: seen.append(e.audio_duration_seconds), + ) + assert seen == [5.0] diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py new file mode 100644 index 00000000..59d691fe --- /dev/null +++ b/tests/test_code_gen.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +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'" + 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 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(): + # 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 + + 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) + 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}" + + +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 + + +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 + + +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 + + +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(): + 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 + + +# --------------------------------------------------------------------------- +# 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 + + +def test_transcribe_show_code_includes_llm_gateway_transform(): + code = code_gen.transcribe( + {"speaker_labels": True}, + "audio.mp3", + llm_gateway={ + "prompts": ["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_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 + assert "transcript.utterances" in code # normal result handling instead + + +def test_agent_show_code_uses_single_full_duplex_stream(): + # 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 + # No audioop: it's deprecated and removed in Python 3.13, so the script stays portable. + assert "audioop" not in code 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_config_builder.py b/tests/test_config_builder.py new file mode 100644 index 00000000..86b2d494 --- /dev/null +++ b/tests/test_config_builder.py @@ -0,0 +1,214 @@ +import json + +import pytest + +from assemblyai_cli import config_builder as cb +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 + 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) + + +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_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"}, + overrides=["max_turn_silence=400"], + config_file=None, + ) + 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 _param_names(StreamingParameters) + + +@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 _param_names(raw_cls) + + +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) + # _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(): + 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) 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_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_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..3e9d4caf --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,42 @@ +from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated, is_auth_failure + + +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"}} + + +def test_is_auth_failure_matches_credential_signals(): + 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_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..8b34541d --- /dev/null +++ b/tests/test_llm_command.py @@ -0,0 +1,276 @@ +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_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()) + 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_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_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 = {} + + 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_login.py b/tests/test_login.py new file mode 100644 index 00000000..b1c6cfa6 --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,75 @@ +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_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"]) + 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_main_module.py b/tests/test_main_module.py new file mode 100644 index 00000000..72ff63f2 --- /dev/null +++ b/tests/test_main_module.py @@ -0,0 +1,53 @@ +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("assemblyai_cli.stdio.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).""" + 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_microphone.py b/tests/test_microphone.py new file mode 100644 index 00000000..657eedb6 --- /dev/null +++ b/tests/test_microphone.py @@ -0,0 +1,207 @@ +import sys +import types + +import pytest + +from assemblyai_cli.errors import CLIError +from assemblyai_cli.microphone import ( + _FALLBACK_RATE, + MicrophoneSource, + _default_mic_stream, + _device_default_rate, + _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_at_capture_rate(): + seen = {} + + def fake_factory(*, sample_rate, device): + seen["rate"] = sample_rate + seen["device"] = device + return iter([b"aa", b"bb"]) + + 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} # 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(capture_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 "sounddevice" 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(capture_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(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(): + mic = MicrophoneSource(capture_rate=16000, stream_factory=lambda **_k: iter([b"z"])) + 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 = {} + + 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) + 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_output.py b/tests/test_output.py new file mode 100644 index 00000000..8b2b5ac6 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,82 @@ +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) + 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 == "" + + +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 diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 00000000..8ef02baa --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,76 @@ +"""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 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 + + +@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 + + +# --- 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 diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 00000000..51474d72 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,94 @@ +from pathlib import Path + +from typer.testing import CliRunner + +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"]) + 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 + assert "agent" in result.output + + +def test_samples_create_agent_uses_env_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["samples", "create", "agent"]) + assert result.exit_code == 0 + 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 + + +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_uses_env_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["samples", "create", "stream"]) + assert result.exit_code == 0 + body = Path(tmp_path, "stream", "stream.py").read_text() + assert _ENV_KEY in body + assert "MicrophoneStream" in body + + +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) + result = runner.invoke(app, ["samples", "create", "transcribe"]) + assert result.exit_code == 0 + + +def test_samples_create_unknown_name_errors(): + result = runner.invoke(app, ["samples", "create", "nope"]) + assert result.exit_code == 1 + + +def test_samples_create_refuses_existing_without_force(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + 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) + 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_is_valid_python(tmp_path, monkeypatch): + import ast + + monkeypatch.chdir(tmp_path) + 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 diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 00000000..db99a4f2 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,56 @@ +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 + + +def test_help_lists_commands_in_workflow_order(): + from typer.core import TyperGroup + from typer.main import get_command + + cmd = get_command(app) + # 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", + "stream", + "transcripts", + "agent", + "llm", + "login", + "logout", + "whoami", + "doctor", + "samples", + "claude", + "version", + ] 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 diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py new file mode 100644 index 00000000..840f6562 --- /dev/null +++ b/tests/test_stream_command.py @@ -0,0 +1,481 @@ +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, *, params, 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")) + 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, *, params, on_begin=None, on_turn=None, on_termination=None, **_kwargs + ): + seen["source_type"] = type(source).__name__ + seen["rate"] = params.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_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, *, params, 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, *, params, 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 + + +def _capture_source(seen): + def fake( + api_key, source, *, params, on_begin=None, on_turn=None, on_termination=None, **_kwargs + ): + seen["source"] = source + seen["rate"] = params.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") + + 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_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 + + 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, *, 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)) + + 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 + + +def test_stream_prompt_transforms_accumulated_transcript(monkeypatch): + config.set_api_key("default", "sk_live") + seen = {} + + 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)) + 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", + "--llm-gateway-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, *, params, 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 --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, *, 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"]) + 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, *, params, **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) + + +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" + + +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 + + +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 *a, **k: called.append(True), + ) + 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_ignores_json_flag(monkeypatch): + def _boom(*a, **k): + raise AssertionError("must not stream") + + monkeypatch.setattr( + "assemblyai_cli.commands.stream.client.stream_audio", + _boom, + ) + 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 + + +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 diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py new file mode 100644 index 00000000..720cde98 --- /dev/null +++ b/tests/test_streaming_render.py @@ -0,0 +1,170 @@ +import io +import json +import types + +import pytest + +from assemblyai_cli import theme +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 _human(width=80, color_system=None): + """A human-mode renderer writing to a forced-terminal themed console buffer.""" + buf = io.StringIO() + 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() + r.turn(_turn("hello", False)) + r.turn(_turn("hello world", True)) + r.close() + assert "hello world" in buf.getvalue() + + +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 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(): + # 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) + 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_termination_json_emits_duration(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.termination(types.SimpleNamespace(audio_duration_seconds=12.5)) + assert json.loads(out.getvalue()) == {"type": "termination", "audio_duration_seconds": 12.5} + + +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_ignores_empty_content(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.llm("") + assert out.getvalue() == "" + + +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 + + +def test_json_emit_propagates_broken_pipe(): + class BrokenOut: + def write(self, _text): + raise BrokenPipeError("downstream closed") + + def flush(self): + pass + + 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_json_emit_swallows_non_pipe_errors(): + class FlakyOut: + def write(self, _text): + raise OSError("transient write error") + + def flush(self): + pass + + r = StreamRenderer(json_mode=True, out=FlakyOut()) + r.turn(_turn("hi", True)) # non-pipe write errors are non-fatal + + +def test_human_listening_notice_is_muted(): + r, buf = _human(color_system="truecolor") + 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)) + r.llm("the summary") + out = buf.getvalue() + assert "the summary" in out + assert "\x1b[" in out # brand styling emits ANSI diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py new file mode 100644 index 00000000..b053cbf2 --- /dev/null +++ b/tests/test_streaming_sources.py @@ -0,0 +1,202 @@ +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_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") + 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_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" diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 00000000..7ebb96b3 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,62 @@ +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") + + +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, + "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) + out = buf.getvalue() + assert "Error:" in out + assert "boom" in out + assert "\x1b[" in out # themed error emits ANSI on a forced-color console diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py new file mode 100644 index 00000000..096afac7 --- /dev/null +++ b/tests/test_transcribe.py @@ -0,0 +1,417 @@ +import json +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.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) + t.utterances = None + return t + + +def _enum_or_str(value): + return getattr(value, "value", value) + + +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["config"].speaker_labels is True + + +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_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_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( + "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_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 + + _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 + assert '"status": "completed"' 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", "--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 + 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) + 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", "--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_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["config"].prompt == "expect medical terms" + + +def test_transcribe_maps_analysis_flags(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + 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_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", + ] + + +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_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" + + +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_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_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" 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) + 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 diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py new file mode 100644 index 00000000..41013341 --- /dev/null +++ b/tests/test_transcribe_render.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace + +from rich.console import Console + +from assemblyai_cli import theme +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 _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 + 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", + 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 diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py new file mode 100644 index 00000000..05846df1 --- /dev/null +++ b/tests/test_transcripts.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 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, ["transcripts", "get", "t_42"]) + assert result.exit_code == 0 + 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"}] + with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + 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, ["transcripts", "list"]) + 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, ["transcripts", "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 + + 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, ["transcripts", "get", "t_err"]) + assert result.exit_code == 1 + + +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", + make_console(force_terminal=True, color_system="truecolor"), + ) + 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 "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 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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..d2fd8552 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1642 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[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 = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { 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 = "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" }, + { 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.13" }, + { 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 = "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" +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.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 = [ + { 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" }, +]