From 47f1febf72d9f84131de1198118b80df099ae54e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 07:34:13 +0000 Subject: [PATCH 1/5] refactor: centralize resolution, unify auth detection, harden agent/config - context: AppState owns profile/env/session resolution as the single source of truth; the module-level resolve_* helpers delegate to it, so precedence rules no longer drift across call sites. - errors: fold the agent-only structural auth checks (WebSocket 1008, HTTP 401/403) into is_auth_failure so REST, streaming, and the voice agent classify rejected credentials identically; drop the duplicate _is_auth_rejection. - agent/session: guard the cross-thread duplex flags (ready/muted) with a lock so the receive-loop -> capture-thread handoff is explicit, not GIL-incidental; document the capture_error and audio _out/_out_state thread ownership. - config: write config.toml atomically (temp file + os.replace) so a crash or concurrent reader can't leave truncated, invalid TOML. Adds tests for each. --- aai_cli/agent/audio.py | 9 +++- aai_cli/agent/session.py | 42 ++++++++--------- aai_cli/config.py | 16 ++++++- aai_cli/context.py | 92 ++++++++++++++++++++++--------------- aai_cli/errors.py | 32 +++++++++++-- tests/test_agent_session.py | 12 +++++ tests/test_config.py | 27 +++++++++++ tests/test_context.py | 18 +++++++- tests/test_errors.py | 37 +++++++++++++++ 9 files changed, 217 insertions(+), 68 deletions(-) diff --git a/aai_cli/agent/audio.py b/aai_cli/agent/audio.py index 601df15f..33c51823 100644 --- a/aai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -98,9 +98,14 @@ def __init__( self._device = device self._factory = stream_factory or _default_duplex_stream self._blocksize = max(1, self._device_rate // 10) # ~100 ms + # Thread ownership: `_in` is a thread-safe Queue handed from the PortAudio + # callback thread to capture_frames(). `_out` (device-rate playback bytes) is + # shared between feed()/flush() (caller thread) and the callback, so every + # access goes through `_lock`. `_out_state` (the target->device ratecv state) + # is touched ONLY by feed(), never the callback, so it needs no lock. 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._out = bytearray() + self._out_state: Any = None self._lock = threading.Lock() self._stream: Any = None self._started = False diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index e13766c1..c345c0dd 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -40,6 +40,11 @@ def __init__( # to the spoken input, so the process exits instead of waiting forever. self.exit_after_reply = exit_after_reply self.ready_event = ready_event + # `ready`/`muted` are the duplex gate: written by dispatch() on the receive + # loop, read by should_send_audio() on the capture thread. The lock makes that + # cross-thread handoff explicit (and the reads consistent) instead of relying on + # the GIL. `finished`/`_saw_user` are touched only by dispatch (one thread). + self._lock = threading.Lock() self.ready = False self.muted = False self.finished = False @@ -47,13 +52,15 @@ def __init__( def should_send_audio(self) -> bool: """True when captured mic frames should be forwarded to the server.""" - return self.ready and not self.muted + with self._lock: + return self.ready and not self.muted def dispatch(self, event: dict) -> None: etype = event.get("type") if etype == "session.ready": - self.ready = True + with self._lock: + self.ready = True if self.ready_event is not None: self.ready_event.set() self.renderer.connected() @@ -69,7 +76,8 @@ def dispatch(self, event: dict) -> None: self.renderer.user_final(event.get("text", "")) elif etype == "reply.started": if not self.full_duplex: - self.muted = True + with self._lock: + self.muted = True self.renderer.reply_started() elif etype == "reply.audio": data = event.get("data") @@ -81,7 +89,8 @@ def dispatch(self, event: dict) -> None: ) elif etype == "reply.done": if not self.full_duplex: - self.muted = False + with self._lock: + self.muted = False interrupted = event.get("status") == "interrupted" if interrupted: self.player.flush() @@ -121,24 +130,6 @@ def _send_audio_loop(ws: Any, session: VoiceAgentSession, mic: Any) -> None: return -def _is_auth_rejection(exc: BaseException) -> bool: - """True when a connect/session failure means the credentials were rejected. - - Detected structurally where possible — the Voice Agent closes with WebSocket - code 1008 on a bad key, and a pre-upgrade HTTP 401/403 carries a status code — - then falls back to the shared text heuristic. - """ - code = getattr(exc, "code", None) - if code is None: - code = getattr(getattr(exc, "rcvd", None), "code", None) - if code == 1008: - return True - response = getattr(exc, "response", None) - if getattr(response, "status_code", None) in (401, 403): - return True - return is_auth_failure(exc) - - def run_session( api_key: str, *, @@ -174,13 +165,16 @@ def run_session( try: ws = connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) except Exception as exc: - if _is_auth_rejection(exc): + if is_auth_failure(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. + # The append below happens-before the main thread reads `capture_error`: the + # capture thread appends, then ws.close() ends the `for raw in ws` loop, so the + # error is always visible by the time we inspect it after the loop. capture_error: list[CLIError] = [] def _capture() -> None: @@ -217,7 +211,7 @@ def _capture() -> None: 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): + if is_auth_failure(exc): raise auth_failure() from exc raise APIError(f"Voice agent session failed: {exc}") from exc finally: diff --git a/aai_cli/config.py b/aai_cli/config.py index cfe3b05e..2efccd41 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -4,6 +4,7 @@ import json import os import re +import tempfile import tomllib from pathlib import Path from typing import Any @@ -63,8 +64,19 @@ def _load() -> dict[str, Any]: 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) + # Write to a sibling temp file and atomically rename over the target, so a crash + # (or concurrent reader) mid-write can never leave config.toml truncated into + # invalid TOML that _load would then reject. os.replace is atomic within a dir. + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=".config-", suffix=".toml.tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + tomli_w.dump(data, fh) + tmp.replace(path) + except BaseException: + with contextlib.suppress(OSError): + tmp.unlink() + raise def get_active_profile() -> str: diff --git a/aai_cli/context.py b/aai_cli/context.py index 2e30ea17..5888661b 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -12,56 +12,76 @@ @dataclass class AppState: + """Request-scoped CLI state (the global --profile / --env) and the single place + that turns it into a concrete profile, environment, or session. + + Centralizing resolution here keeps the precedence rules in one spot instead of + being re-derived per command. The module-level ``resolve_*`` functions below are + thin adapters retained for existing call sites and tests. + """ + profile: str | None = None env: str | None = None + def resolve_profile(self) -> str: + """The profile to act on: explicit --profile, else the active profile.""" + return self.profile or config.get_active_profile() + + def resolve_environment(self) -> Environment: + """The backend environment: --env > AAI_ENV > the profile's stored env > default.""" + profile_env = config.get_profile_env(self.resolve_profile()) + return environments.resolve(self.env, profile_env) + + def resolve_session(self) -> tuple[int, str]: + """Account id + Stytch session JWT for AMS self-service commands. + + These endpoints authenticate with the browser-login session, not the API + key. Raises NotAuthenticated when the active profile has no stored session + (e.g. it was set up with `aai login --api-key`, or the session expired). + """ + from aai_cli.errors import NotAuthenticated + + profile = self.resolve_profile() + session = config.get_session(profile) + account_id = config.get_account_id(profile) + if session is None or account_id is None: + raise NotAuthenticated( + "These commands need a browser login. Run 'aai login' (without --api-key)." + ) + return account_id, session["jwt"] + + def env_override_warning(self) -> str | None: + """A warning when an explicit --env contradicts the profile's stored env. + + The stored key was minted against the profile's environment, so forcing a + different --env points the client at hosts that key won't authenticate to. + """ + if self.env is None: + return None + profile = self.resolve_profile() + profile_env = config.get_profile_env(profile) + if profile_env is None or profile_env == self.env: + return None + return ( + f"Using --env {self.env}, but profile '{profile}' was set up for " + f"{profile_env}; its stored key may be rejected by {self.env}." + ) + 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() + return state.resolve_profile() def resolve_environment(state: AppState) -> Environment: - """The backend environment: --env > AAI_ENV > the profile's stored env > default.""" - profile_env = config.get_profile_env(resolve_profile(state)) - return environments.resolve(state.env, profile_env) + return state.resolve_environment() def resolve_session(state: AppState) -> tuple[int, str]: - """Account id + Stytch session JWT for AMS self-service commands. - - These endpoints authenticate with the browser-login session, not the API - key. Raises NotAuthenticated when the active profile has no stored session - (e.g. it was set up with `aai login --api-key`, or the session expired). - """ - from aai_cli.errors import NotAuthenticated - - profile = resolve_profile(state) - session = config.get_session(profile) - account_id = config.get_account_id(profile) - if session is None or account_id is None: - raise NotAuthenticated( - "These commands need a browser login. Run 'aai login' (without --api-key)." - ) - return account_id, session["jwt"] + return state.resolve_session() def env_override_warning(state: AppState) -> str | None: - """A warning when an explicit --env contradicts the profile's stored env. - - The stored key was minted against the profile's environment, so forcing a - different --env points the client at hosts that key won't authenticate to. - """ - if state.env is None: - return None - profile = resolve_profile(state) - profile_env = config.get_profile_env(profile) - if profile_env is None or profile_env == state.env: - return None - return ( - f"Using --env {state.env}, but profile '{profile}' was set up for " - f"{profile_env}; its stored key may be rejected by {state.env}." - ) + return state.env_override_warning() def run_command( diff --git a/aai_cli/errors.py b/aai_cli/errors.py index be0c1db5..8f853145 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -67,8 +67,8 @@ def __init__(self, message: str, *, suggestion: str | None = None) -> None: # 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). +# (transcript ids, byte counts, ports). Numeric status/close codes are matched +# structurally on exception attributes by `_has_auth_status_code` instead. _AUTH_FAILURE_HINTS = ( "unauthorized", "forbidden", @@ -82,8 +82,34 @@ def __init__(self, message: str, *, suggestion: str | None = None) -> None: REJECTED_KEY_SUGGESTION = "Run 'aai login' with a valid key, or set ASSEMBLYAI_API_KEY." +def _has_auth_status_code(exc: object) -> bool: + """True when an exception carries a numeric signal that credentials were rejected. + + Reads exception attributes, never the message text, so a transcript id or byte + count that happens to contain "401"/"1008" can't trip it. Covers the two structured + cases: the Voice Agent closes a bad-key WebSocket with code 1008 (on ``.code``, or + on the received close frame ``.rcvd.code``), and a pre-upgrade HTTP rejection + carries 401/403 on ``.response.status_code``. + """ + 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) + return getattr(response, "status_code", None) in (401, 403) + + def is_auth_failure(exc: object) -> bool: - """Heuristic: does this exception/error indicate rejected/invalid credentials?""" + """Does this exception/error indicate rejected/invalid credentials? + + Checks structured numeric signals first (WebSocket 1008, HTTP 401/403), then falls + back to a case-insensitive text heuristic. Shared by every backend path — REST + (client.py), v3 streaming, and the Voice Agent — so a rejected key yields the same + exit code no matter which transport surfaced the failure. + """ + if _has_auth_status_code(exc): + return True text = str(exc).lower() return any(hint in text for hint in _AUTH_FAILURE_HINTS) diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index 23ac7ce2..302977d4 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -148,6 +148,18 @@ def test_should_send_audio_only_when_ready_and_unmuted(): assert s.should_send_audio() is False # gated while the agent speaks +def test_should_send_audio_reflects_dispatch_mute_cycle(): + # The duplex gate is driven entirely by dispatched events (the path the receive + # loop uses); should_send_audio reads it through the lock from the capture thread. + s = _session(full_duplex=False) + s.dispatch({"type": "session.ready"}) + assert s.should_send_audio() is True + s.dispatch({"type": "reply.started"}) + assert s.should_send_audio() is False # muted while the agent speaks + s.dispatch({"type": "reply.done"}) + assert s.should_send_audio() is True # gate reopens once the reply finishes + + class _RecordingWS: def __init__(self, fail_on_send=False): self.sent = [] diff --git a/tests/test_config.py b/tests/test_config.py index 7fdbdc12..d232d832 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -84,3 +84,30 @@ def test_config_roundtrips_after_special_value(tmp_path, monkeypatch): # profile names are validated; this checks tomli_w writes valid TOML for normal data config.set_api_key("staging", "sk_x") assert config.get_active_profile() == "staging" + + +def test_dump_is_atomic_and_leaves_no_temp_files(tmp_config): + # Atomic write: config.toml is the only file in the dir after writes (no leftover + # .config-*.toml.tmp), and the data is intact valid TOML. + config.set_api_key("default", "sk_abc") + config.set_profile_env("default", "sandbox000") + names = sorted(p.name for p in tmp_config.iterdir()) + assert names == ["config.toml"] + assert config.get_profile_env("default") == "sandbox000" + + +def test_dump_cleans_up_temp_file_when_write_fails(tmp_config, monkeypatch): + # A failure mid-write must propagate AND leave no temp file behind, so the real + # config.toml is never clobbered by a partial write. + config.set_api_key("default", "sk_abc") # establish a valid config.toml first + + def boom(_data, _fh): + raise RuntimeError("disk full") + + monkeypatch.setattr(config.tomli_w, "dump", boom) + with pytest.raises(RuntimeError): + config._dump({"profiles": {}}) + + names = sorted(p.name for p in tmp_config.iterdir()) + assert names == ["config.toml"] # no .config-*.toml.tmp left behind + assert config.get_api_key("default") == "sk_abc" # original untouched diff --git a/tests/test_context.py b/tests/test_context.py index 5e05fa2f..a120a493 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,7 +1,7 @@ import typer from typer.testing import CliRunner -from aai_cli import config +from aai_cli import config, environments from aai_cli.context import AppState, env_override_warning, run_command from aai_cli.errors import NotAuthenticated @@ -78,3 +78,19 @@ def test_resolve_session_raises_when_no_session(): with pytest.raises(NotAuthenticated): resolve_session(AppState()) + + +def test_appstate_methods_are_the_single_source_of_truth(): + # The module-level resolve_* helpers are thin adapters over the AppState methods; + # both must agree, and the precedence (default profile + default env) must hold. + config.set_profile_env("default", "sandbox000") + state = AppState(env="production") + + assert state.resolve_profile() == "default" + assert state.resolve_environment().name == "production" # --env wins over profile + assert state.env_override_warning() == env_override_warning(state) + assert state.env_override_warning() is not None # production contradicts sandbox000 + + +def test_appstate_environment_falls_back_to_default(): + assert AppState().resolve_environment().name == environments.DEFAULT_ENV diff --git a/tests/test_errors.py b/tests/test_errors.py index ef6894e7..0b2e881a 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -65,3 +65,40 @@ def test_is_auth_failure_ignores_bare_numeric_substrings(): 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")) + + +def test_is_auth_failure_detects_structural_status_codes(): + # The structural signals folded in from the old agent-only _is_auth_rejection: + # a WebSocket 1008 close (on .code or .rcvd.code) and an HTTP 401/403 response. + class _Closed(Exception): + code = 1008 + + class _Frame: + code = 1008 + + class _ClosedRcvd(Exception): + rcvd = _Frame() + + class _Resp: + status_code = 403 + + class _HTTPish(Exception): + response = _Resp() + + assert is_auth_failure(_Closed("policy violation")) # no auth word in the text + assert is_auth_failure(_ClosedRcvd("connection closed")) + assert is_auth_failure(_HTTPish("nope")) + + +def test_is_auth_failure_ignores_unrelated_status_codes(): + class _Closed(Exception): + code = 1000 # normal closure, not a policy rejection + + class _Resp: + status_code = 500 + + class _HTTPish(Exception): + response = _Resp() + + assert not is_auth_failure(_Closed("bye")) + assert not is_auth_failure(_HTTPish("server error")) From ac4d8efc1e92c7456881cc2a97261db9f596557e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 07:39:05 +0000 Subject: [PATCH 2/5] refactor(client): extract repeated SDK error normalization Five wrappers around the assemblyai SDK duplicated the same try/except shape (map auth failures to auth_failure(), everything else to APIError). Collapse it into a single _sdk_errors() context manager so the classification lives in one place; CLIError and BrokenPipeError still pass through untouched. Behavior is unchanged (tests assert on exception type, not message). --- aai_cli/client.py | 68 ++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/aai_cli/client.py b/aai_cli/client.py index 94670980..359d0ff0 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -1,7 +1,8 @@ from __future__ import annotations +import contextlib import json -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from typing import Any import assemblyai as aai @@ -38,6 +39,26 @@ def _configure(api_key: str) -> None: aai.settings.base_url = environments.active().api_base +@contextlib.contextmanager +def _sdk_errors(message: str) -> Iterator[None]: + """Normalize SDK exceptions for one call: a rejected key becomes a single clean + auth_failure(); any other error becomes APIError(f"{message}: {exc}"). + + CLIErrors (including an APIError the SDK call raised itself) and a closed + downstream pipe pass through untouched; KeyboardInterrupt propagates as a + BaseException. This is the one shape every wrapper around the assemblyai SDK + shares — keeping it here means auth/error classification lives in one place. + """ + try: + yield + except (CLIError, BrokenPipeError): + raise + except Exception as exc: + if is_auth_failure(exc): + raise auth_failure() from exc + raise APIError(f"{message}: {exc}") from exc + + def validate_key(api_key: str) -> bool: """True if the key authenticates, False on an auth failure. Raises APIError otherwise.""" _configure(api_key) @@ -61,27 +82,15 @@ def _item_to_dict(item: Any) -> dict[str, Any]: def list_transcripts(api_key: str, *, limit: int = 10) -> list[dict[str, object]]: _configure(api_key) - try: + with _sdk_errors("Could not list transcripts"): 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: + with _sdk_errors("Transcription request failed"): 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 @@ -124,12 +133,8 @@ def select_transcript_field(transcript: Any, field: str) -> str: return str(getattr(transcript, "text", "") or "") if field == "srt": # The SDK fetches SRT from the `/srt` export endpoint, so this hits the network. - try: + with _sdk_errors("Could not export SRT subtitles"): 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) @@ -137,12 +142,8 @@ def select_transcript_field(transcript: Any, field: str) -> str: def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: _configure(api_key) - try: + with _sdk_errors(f"Could not fetch transcript {transcript_id}"): 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( @@ -186,23 +187,12 @@ def handler(_client: Any, event: Any) -> None: sc.on(StreamingEvents.Termination, _guard(on_termination)) sc.on(StreamingEvents.Error, lambda _client, error: errors.append(error)) - try: + with _sdk_errors("Could not start streaming session"): 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 + with _sdk_errors("Streaming failed"): + sc.stream(source) finally: sc.disconnect(terminate=True) From f8b8de65edafeadb1b7c98459ad2fac027ffaae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 07:44:13 +0000 Subject: [PATCH 3/5] refactor: cut cyclomatic complexity of the hottest functions - agent/session: replace the 11-branch dispatch() if/elif chain (CC 20) with an _EVENT_HANDLERS table of small per-event methods (CC 2). - config_builder: extract the issubclass ladder out of _derive_kind into _scalar_kind (CC 15 -> ~8); replace coerce_value's kind chain with a _COERCERS table of per-kind functions (CC 14 -> ~2). - client: replace select_transcript_field's field chain (CC 12) with a _FIELD_RENDERERS dispatch table plus small render helpers (CC 1). Behavior unchanged; full suite green. Average CC 2.57 -> 2.41. --- aai_cli/agent/session.py | 111 +++++++++++++++++++++-------------- aai_cli/client.py | 44 ++++++++------ aai_cli/config_builder.py | 118 +++++++++++++++++++++++--------------- 3 files changed, 169 insertions(+), 104 deletions(-) diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index c345c0dd..6b6c4430 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -4,6 +4,7 @@ import contextlib import json import threading +from collections.abc import Callable from typing import Any from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure @@ -56,51 +57,60 @@ def should_send_audio(self) -> bool: return self.ready and not self.muted def dispatch(self, event: dict) -> None: - etype = event.get("type") + """Route one server event to its handler; unknown types are ignored. - if etype == "session.ready": + Handlers are registered in ``_EVENT_HANDLERS`` (below the class). Events with + no handler — ``input.speech.stopped``, ``tool.call``, anything new — are no-ops. + """ + handler = _EVENT_HANDLERS.get(event.get("type", "")) + if handler is not None: + handler(self, event) + + def _on_session_ready(self, _event: dict) -> None: + with self._lock: + self.ready = True + if self.ready_event is not None: + self.ready_event.set() + self.renderer.connected() + + def _on_speech_started(self, _event: dict) -> None: + if self.full_duplex: + self.player.flush() + + def _on_user_delta(self, event: dict) -> None: + self.renderer.user_partial(event.get("text", "")) + + def _on_user_final(self, event: dict) -> None: + self._saw_user = True + self.renderer.user_final(event.get("text", "")) + + def _on_reply_started(self, _event: dict) -> None: + if not self.full_duplex: with self._lock: - 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: - with self._lock: - 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: - with self._lock: - 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. + self.muted = True + self.renderer.reply_started() + + def _on_reply_audio(self, event: dict) -> None: + data = event.get("data") + if data: + self.player.enqueue(base64.b64decode(data)) + + def _on_agent_transcript(self, event: dict) -> None: + self.renderer.agent_transcript( + event.get("text", ""), interrupted=bool(event.get("interrupted", False)) + ) + + def _on_reply_done(self, event: dict) -> None: + if not self.full_duplex: + with self._lock: + 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 def _raise_error(self, event: dict) -> None: code = event.get("code", "") @@ -114,6 +124,21 @@ def _raise_error(self, event: dict) -> None: raise APIError(f"Voice agent error ({code}): {message}") +# Server event type -> the VoiceAgentSession method that handles it. Types absent +# here (input.speech.stopped, tool.call, anything unrecognized) are ignored. +_EVENT_HANDLERS: dict[str, Callable[[VoiceAgentSession, dict], None]] = { + "session.ready": VoiceAgentSession._on_session_ready, + "input.speech.started": VoiceAgentSession._on_speech_started, + "transcript.user.delta": VoiceAgentSession._on_user_delta, + "transcript.user": VoiceAgentSession._on_user_final, + "reply.started": VoiceAgentSession._on_reply_started, + "reply.audio": VoiceAgentSession._on_reply_audio, + "transcript.agent": VoiceAgentSession._on_agent_transcript, + "reply.done": VoiceAgentSession._on_reply_done, + "session.error": VoiceAgentSession._raise_error, +} + + 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 diff --git a/aai_cli/client.py b/aai_cli/client.py index 359d0ff0..ecabeaba 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -120,24 +120,36 @@ def transcript_json_payload(transcript: Any) -> dict[str, object]: TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "srt", "json") +def _transcript_text(transcript: Any) -> str: + return str(getattr(transcript, "text", "") or "") + + +def _render_utterances(transcript: Any) -> str: + utterances = getattr(transcript, "utterances", None) or [] + if not utterances: + return _transcript_text(transcript) + return "\n".join(f"Speaker {u.speaker}: {u.text}" for u in utterances) + + +def _export_srt(transcript: Any) -> str: + # The SDK fetches SRT from the `/srt` export endpoint, so this hits the network. + with _sdk_errors("Could not export SRT subtitles"): + return str(transcript.export_subtitles_srt()) + + +# Output field -> renderer. Fields absent here fall back to the plain transcript text. +_FIELD_RENDERERS: dict[str, Callable[[Any], str]] = { + "id": lambda t: str(getattr(t, "id", "") or ""), + "status": status_str, + "utterances": _render_utterances, + "srt": _export_srt, + "json": lambda t: json.dumps(transcript_json_payload(t), default=str), +} + + 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. - with _sdk_errors("Could not export SRT subtitles"): - return str(transcript.export_subtitles_srt()) - if field == "json": - return json.dumps(transcript_json_payload(transcript), default=str) - return str(getattr(transcript, "text", "") or "") # "text" (and the validated default) + return _FIELD_RENDERERS.get(field, _transcript_text)(transcript) def get_transcript(api_key: str, transcript_id: str) -> aai.Transcript: diff --git a/aai_cli/config_builder.py b/aai_cli/config_builder.py index 7e5da7b0..513ae0e8 100644 --- a/aai_cli/config_builder.py +++ b/aai_cli/config_builder.py @@ -3,6 +3,7 @@ import enum import json import typing +from collections.abc import Callable from pathlib import Path import assemblyai as aai @@ -114,35 +115,41 @@ def _field_annotations(model_cls: type) -> dict[str, object]: return {name: field.outer_type_ for name, field in legacy_fields.items()} +# Concrete scalar base types -> coercion kind, checked in order. bool precedes int +# because bool is an int subclass; enum.Enum coerces from its string member values. +_SCALAR_KINDS: tuple[tuple[type, str], ...] = ( + (bool, "bool"), + (enum.Enum, "str"), + (int, "int"), + (float, "float"), + (str, "str"), +) + + +def _scalar_kind(annotation: object) -> str: + """Coercion kind for a concrete (non-generic) annotation; JSON for anything else.""" + if isinstance(annotation, type): + for base, kind in _SCALAR_KINDS: + if issubclass(annotation, base): + return kind + return "json" # pydantic submodels and anything else: accept a raw JSON value + + def _derive_kind(annotation: object) -> str: """Map an SDK field annotation to a coercion kind for `coerce_value`.""" if typing.get_origin(annotation) is typing.Union: non_none = [a for a in typing.get_args(annotation) if a is not type(None)] - if len(non_none) == 1: - annotation = non_none[0] # unwrap Optional[X] - # A genuine multi-type union (e.g. Union[str, LanguageCode]): a string is - # always an acceptable input, otherwise fall back to a raw JSON value. - elif any(_is_str_like(a) for a in non_none): - return "str" - else: - return "json" + if len(non_none) != 1: + # A genuine multi-type union (e.g. Union[str, LanguageCode]): a string is + # always an acceptable input, otherwise fall back to a raw JSON value. + return "str" if any(_is_str_like(a) for a in non_none) else "json" + annotation = non_none[0] # unwrap Optional[X] and classify the inner type origin = typing.get_origin(annotation) if origin in (list, set, tuple): return "list" if origin is dict: return "json" - if isinstance(annotation, type): - if issubclass(annotation, bool): # before int: bool is an int subclass - return "bool" - if issubclass(annotation, enum.Enum): - return "str" - if issubclass(annotation, int): - return "int" - if issubclass(annotation, float): - return "float" - if issubclass(annotation, str): - return "str" - return "json" # pydantic submodels and anything else: accept a raw JSON value + return _scalar_kind(annotation) def _is_str_like(annotation: object) -> bool: @@ -179,34 +186,55 @@ def _coerce_table(model_cls: type, names: tuple[str, ...]) -> dict[str, str]: _FALSE = {"0", "false", "no", "off"} +def _coerce_bool(field: str, raw: str) -> object: + 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}.") + + +def _coerce_int(field: str, raw: str) -> object: + try: + return int(raw) + except ValueError as exc: + raise UsageError(f"{field} expects an integer, got {raw!r}.") from exc + + +def _coerce_float(field: str, raw: str) -> object: + try: + return float(raw) + except ValueError as exc: + raise UsageError(f"{field} expects a number, got {raw!r}.") from exc + + +def _coerce_list(_field: str, raw: str) -> object: + return [part.strip() for part in raw.split(",") if part.strip()] + + +def _coerce_json(field: str, raw: str) -> object: + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise UsageError(f"{field} expects a JSON value, got {raw!r}.") from exc + + +# Coercion kind -> coercer. Kinds absent here ("str", and any unknown) pass through raw. +_COERCERS: dict[str, Callable[[str, str], object]] = { + "bool": _coerce_bool, + "int": _coerce_int, + "float": _coerce_float, + "list": _coerce_list, + "json": _coerce_json, +} + + 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 + coercer = _COERCERS.get(kind) + return coercer(field, raw) if coercer is not None else raw def parse_config_overrides(fields: dict[str, str], pairs: list[str]) -> dict[str, object]: From 945b86957485a938a1c5a75c42219620c98d09ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 07:46:44 +0000 Subject: [PATCH 4/5] ci: add xenon cyclomatic-complexity gate to check.sh Enforce the complexity work so it can't silently regress: --max-absolute B : no function may exceed cyclomatic complexity 10 --max-modules B : no file's average may exceed grade B --max-average A : project-wide average stays grade A (<= 5) To clear the strict per-function bar, refactor run_session (the last C-rated function, CC 13) down to B by extracting _open_ws and _auth_or_api_error; the threading/error structure is unchanged. Adds xenon to the dev dependency group. --- aai_cli/agent/session.py | 27 ++++--- pyproject.toml | 3 +- scripts/check.sh | 9 +++ uv.lock | 163 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 10 deletions(-) diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index 6b6c4430..d3e75ca2 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -155,6 +155,22 @@ def _send_audio_loop(ws: Any, session: VoiceAgentSession, mic: Any) -> None: return +def _auth_or_api_error(exc: Exception, message: str) -> CLIError: + """Map a connect/session exception to the right CLIError: a rejected key becomes + auth_failure(), anything else becomes APIError(f"{message}: {exc}").""" + if is_auth_failure(exc): + return auth_failure() + return APIError(f"{message}: {exc}") + + +def _open_ws(connect: Any, api_key: str) -> Any: + """Open the Voice Agent socket, mapping a connect failure to a clean CLIError.""" + try: + return connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) + except Exception as exc: + raise _auth_or_api_error(exc, "Could not connect to the voice agent") from exc + + def run_session( api_key: str, *, @@ -187,12 +203,7 @@ def run_session( ready_event=ready_event, ) - try: - ws = connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) - except Exception as exc: - if is_auth_failure(exc): - raise auth_failure() from exc - raise APIError(f"Could not connect to the voice agent: {exc}") from exc + ws = _open_ws(connect, api_key) # 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 @@ -236,9 +247,7 @@ def _capture() -> None: except Exception as exc: if capture_error: raise capture_error[0] from exc # a mic-open failure is the real cause - if is_auth_failure(exc): - raise auth_failure() from exc - raise APIError(f"Voice agent session failed: {exc}") from exc + raise _auth_or_api_error(exc, "Voice agent session failed") from exc finally: with contextlib.suppress(Exception): ws.close() diff --git a/pyproject.toml b/pyproject.toml index 3694c5a9..68d8eac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,8 @@ dev = [ "python-dotenv>=1.0.0", "python-multipart>=0.0.9", "syrupy>=4.0", - "numpy>=2.0.0", # e2e tests resample kokoro audio (tests/e2e/test_cli_e2e.py) + "numpy>=2.0.0", # e2e tests resample kokoro audio (tests/e2e/test_cli_e2e.py) + "xenon>=0.9.3", ] [tool.uv] diff --git a/scripts/check.sh b/scripts/check.sh index ae299a9b..d4b6e319 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -24,6 +24,15 @@ uv run mypy # files = ["aai_cli", "tests"] in pyproject.toml echo "==> pyright (src + tests)" uv run pyright # include = ["aai_cli", "tests"] in [tool.pyright] +echo "==> xenon (cyclomatic complexity gate, src only)" +# Fail the build if any function gets too branchy. Grades map to cyclomatic +# complexity: A=1-5, B=6-10, C=11-20, ... Thresholds: +# --max-absolute B : no single function may exceed CC 10 (grade B). +# --max-modules B : no file's average may exceed grade B. +# --max-average A : the project-wide average must stay grade A (CC <= 5). +# Tests are excluded (not shipped); only the aai_cli package is gated. +uv run xenon --max-absolute B --max-modules B --max-average A aai_cli + echo "==> markdownlint (docs/ is generated, so excluded)" markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cache diff --git a/uv.lock b/uv.lock index 1fddbdb6..eb76cadc 100644 --- a/uv.lock +++ b/uv.lock @@ -43,6 +43,7 @@ dev = [ { name = "ruff" }, { name = "syrupy" }, { name = "uvicorn" }, + { name = "xenon" }, ] [package.metadata] @@ -77,6 +78,7 @@ dev = [ { name = "ruff", specifier = ">=0.15.15" }, { name = "syrupy", specifier = ">=4.0" }, { name = "uvicorn", specifier = ">=0.30.0" }, + { name = "xenon", specifier = ">=0.9.3" }, ] [[package]] @@ -319,6 +321,95 @@ 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 = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -849,6 +940,18 @@ wheels = [ { 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 = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1409,6 +1512,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1469,6 +1600,15 @@ 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 = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1639,6 +1779,15 @@ 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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "uvicorn" version = "0.48.0" @@ -1735,6 +1884,20 @@ wheels = [ { 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 = "xenon" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "radon" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/7c/2b341eaeec69d514b635ea18481885a956d196a74322a4b0942ef0c31691/xenon-0.9.3.tar.gz", hash = "sha256:4a7538d8ba08aa5d79055fb3e0b2393c0bd6d7d16a4ab0fcdef02ef1f10a43fa", size = 9883, upload-time = "2024-10-21T10:27:53.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/5d/29ff8665b129cafd147d90b86e92babee32e116e3c84447107da3e77f8fb/xenon-0.9.3-py2.py3-none-any.whl", hash = "sha256:6e2c2c251cc5e9d01fe984e623499b13b2140fcbf74d6c03a613fa43a9347097", size = 8966, upload-time = "2024-10-21T10:27:51.121Z" }, +] + [[package]] name = "yt-dlp" version = "2026.3.17" From 3ea4fcfb0d4e3ae24cec30db53b4ae30bc3ef077 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 07:58:16 +0000 Subject: [PATCH 5/5] ci: add patch-coverage, suppression-rule, and warnings-as-errors gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three quality gates that push changes to fix rather than skip: 1. diff-cover patch coverage (check.sh): every line changed vs origin/main must be covered (--fail-under=100), closing the loophole where new code rides on the project-wide 90% gate. Emits coverage.xml from the pytest step. 2. ruff PGH/ERA/TRY/TD/FIX: ban blanket # noqa / # type: ignore, commented-out code, exception anti-patterns, and untracked TODO/FIXME markers. TRY003 is ignored (the CLIError hierarchy is built on descriptive messages). 3. pytest filterwarnings = error: a deprecation or resource leak now fails the build instead of scrolling past. Issues these surfaced, fixed here: - auth/loopback: the OAuth callback server leaked its listening socket (shutdown() doesn't close it) — caught by warnings-as-errors; add server_close(). - client.validate_key: moved the success return out of the try (TRY300). - environments: reworded the production-values TODO into a factual NOTE. - client: added tests for the utterances output field (was untested) to hit 100% patch coverage. Adds diff-cover to the dev group. --- .gitignore | 1 + aai_cli/auth/loopback.py | 3 +- aai_cli/client.py | 2 +- aai_cli/environments.py | 4 +- pyproject.toml | 23 ++++++- scripts/check.sh | 14 +++- tests/test_client.py | 20 ++++++ uv.lock | 140 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 200 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index d8badeda..2510994d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ .ruff_cache/ .mypy_cache/ .coverage +coverage.xml htmlcov/ # Editor/agent local artifacts: keep personal settings local, but track the diff --git a/aai_cli/auth/loopback.py b/aai_cli/auth/loopback.py index 179653fe..42cd889d 100644 --- a/aai_cli/auth/loopback.py +++ b/aai_cli/auth/loopback.py @@ -63,6 +63,7 @@ def log_message(self, *args: object) -> None: # silence stderr logging if not done.wait(timeout): result.error = "timeout" finally: - server.shutdown() + server.shutdown() # stop serve_forever() thread.join(timeout=5) + server.server_close() # close the listening socket (shutdown() leaves it open) return result diff --git a/aai_cli/client.py b/aai_cli/client.py index ecabeaba..5eaac4c9 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -64,13 +64,13 @@ def validate_key(api_key: str) -> bool: _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 + return True def _item_to_dict(item: Any) -> dict[str, Any]: diff --git a/aai_cli/environments.py b/aai_cli/environments.py index 0b99e373..fa6044b7 100644 --- a/aai_cli/environments.py +++ b/aai_cli/environments.py @@ -30,7 +30,9 @@ class Environment: api_base="https://api.assemblyai.com", streaming_host="streaming.assemblyai.com", llm_gateway_base="https://llm-gateway.assemblyai.com/v1", - # TODO: production AMS + Stytch values are not stood up yet (spec P2/O4). + # NOTE: production AMS + Stytch are not provisioned yet — the values below are + # placeholders (see the REPLACE_ME token), which is why DEFAULT_ENV stays + # "sandbox000". Tracked under spec P2/O4. ams_base="https://ams.assemblyai.com", stytch_domain="https://api.stytch.com", stytch_public_token="public-token-live-REPLACE_ME", # noqa: S106 - public token, safe to ship diff --git a/pyproject.toml b/pyproject.toml index 68d8eac1..b8465ed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ dev = [ "syrupy>=4.0", "numpy>=2.0.0", # e2e tests resample kokoro audio (tests/e2e/test_cli_e2e.py) "xenon>=0.9.3", + "diff-cover>=9.0.0", ] [tool.uv] @@ -88,6 +89,15 @@ exclude = ["**/__pycache__", "**/*.pyc"] [tool.pytest.ini_options] testpaths = ["tests"] +# Treat warnings as errors so a deprecation or resource leak introduced in a change +# fails the build instead of scrolling past. Listed-later filters take precedence, so +# the targeted ignores below override the blanket `error`. +filterwarnings = [ + "error", + # Starlette's TestClient warns that it rides on httpx (not the unreleased httpx2) + # when the `aai init` template apps are exercised; we don't control this. + "ignore:Using .httpx. with .starlette.testclient. is deprecated:", +] markers = [ "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY + kokoro; skip otherwise)", "install_script: real install via install.sh from a locally-built wheel; asserts `aai` runs (slow; needs network + uv/pipx; skip otherwise)", @@ -128,12 +138,19 @@ line-length = 100 target-version = "py311" [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "BLE", "C4", "SIM", "RET", "PTH", "ARG", "S", "RUF"] +# PGH/ERA/TRY/TD/FIX close the "make the error go away" escape hatches a coding agent +# reaches for: blanket `# noqa`/`# type: ignore` (PGH), commented-out code (ERA), +# exception anti-patterns (TRY), and untracked TODO/FIXME markers (TD/FIX). +select = ["E", "F", "I", "UP", "B", "BLE", "C4", "SIM", "RET", "PTH", "ARG", "S", "RUF", + "PGH", "ERA", "TRY", "TD", "FIX"] # 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"] +# TRY003: the CLIError hierarchy is built around descriptive messages passed at the +# raise site, so long messages there are deliberate, not an anti-pattern. +ignore = ["E501", "B008", "S603", "S607", "TRY003"] [tool.ruff.lint.per-file-ignores] # Tests assert freely, use throwaway args/temp paths, and don't need pathlib/security lints. -"tests/**" = ["S101", "S105", "S106", "S107", "S108", "ARG001", "ARG002", "ARG005", "PTH123", "SIM117"] +# TRY300: test helpers commonly `return` inside a try while asserting on the except path. +"tests/**" = ["S101", "S105", "S106", "S107", "S108", "ARG001", "ARG002", "ARG005", "PTH123", "SIM117", "TRY300"] diff --git a/scripts/check.sh b/scripts/check.sh index d4b6e319..16fdb795 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -53,7 +53,19 @@ echo "==> pytest (with branch-coverage gate)" # uv run pytest -m e2e # uv run pytest -m install # uv run pytest -m install_script -uv run pytest -q -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +uv run pytest -q -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-report=xml --cov-fail-under=90 + +echo "==> diff-cover (patch coverage: every changed line must be tested)" +# The 90% gate above is project-wide, so new code can ride on the existing suite and +# stay untested. diff-cover requires 100% coverage of the lines changed versus the +# merge-base with origin/main (uses coverage.xml from the pytest step). Genuinely +# unreachable defensive lines can be marked `# pragma: no cover`. Skipped with a +# notice when origin/main isn't present (e.g. a shallow clone of just the branch). +if git rev-parse --verify --quiet origin/main >/dev/null; then + uv run diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 +else + echo " origin/main not found; skipping patch-coverage gate (CI provides it)" +fi echo "==> build + twine check (PyPI publish readiness)" # Build sdist + wheel into ./dist, then validate the metadata and README render diff --git a/tests/test_client.py b/tests/test_client.py index ba030e11..bce99670 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -128,6 +128,26 @@ def test_transcribe_raises_on_error_status(): assert exc.value.transcript_id == "t_err" +def test_select_transcript_field_utterances_formats_speakers(): + import types + + t = MagicMock() + t.utterances = [ + types.SimpleNamespace(speaker="A", text="hello"), + types.SimpleNamespace(speaker="B", text="hi there"), + ] + assert ( + client.select_transcript_field(t, "utterances") == "Speaker A: hello\nSpeaker B: hi there" + ) + + +def test_select_transcript_field_utterances_falls_back_to_text_when_absent(): + t = MagicMock() + t.utterances = [] # no diarization -> plain transcript text + t.text = "plain transcript" + assert client.select_transcript_field(t, "utterances") == "plain transcript" + + 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" diff --git a/uv.lock b/uv.lock index eb76cadc..6634984b 100644 --- a/uv.lock +++ b/uv.lock @@ -30,6 +30,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "diff-cover" }, { name = "fastapi" }, { name = "hypothesis" }, { name = "mypy" }, @@ -65,6 +66,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "diff-cover", specifier = ">=9.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "hypothesis", specifier = ">=6.155.1" }, { name = "mypy", specifier = ">=2.1.0" }, @@ -321,6 +323,43 @@ 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 = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -583,6 +622,21 @@ wheels = [ { 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 = "diff-cover" +version = "10.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "jinja2" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/21/057e816125c162662d2a2cc2ebcd72dd333e78e51678298d07dd3146011a/diff_cover-10.3.0.tar.gz", hash = "sha256:474dbc63e815fbb7567d7b7ca5b104123e96129f25426ebdbc9a1bdbb935b2c6", size = 106546, upload-time = "2026-05-30T14:17:14.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0a/a96e57a7a3fca419cd5ceff0d13dee2166520fa67103fb82624ad64700fb/diff_cover-10.3.0-py3-none-any.whl", hash = "sha256:2e47d5ab3868d1e92131c11f364f3f4a8583c97123d3bbc6b6cc8ce0a4cc2202", size = 58989, upload-time = "2026-05-30T14:17:12.858Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -759,6 +813,18 @@ 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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.15.0" @@ -964,6 +1030,80 @@ 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 = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2"