From a3a7e83273aa2270f90580d6ec5db7d25ff7afd5 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Fri, 5 Jun 2026 14:24:07 -0700 Subject: [PATCH] Leverage pydantic, simplify shared helpers, and lint CI workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsonshape gains pydantic-backed as_int/as_float and a strict as_object_list; three near-identical numeric coercers and the per-module _mapping_list/_as_mapping/_mapping pass-through wrappers (keys, audit, account, flow, transcribe_render) collapse onto it. The four duplicate TypeAdapter(dict[str,object]) definitions are routed through jsonshape too. config.py now models the config.toml document (Config/Profile/StoredSession) so reads/writes are typed and validated instead of hand-juggling nested dicts. auth/flow.py parses the AMS login responses through pydantic models, replacing _require/_require_int/_require_mapping. llm.py imports openai lazily so it no longer loads on every CLI startup. CI: add actionlint (via actionlint-py) and zizmor pre-commit hooks to lint the workflows, then fix what they surface — persist-credentials: false on every checkout, and a Dependabot cooldown mirroring the safe-chain minimum-package-age posture. Also drops the unused scripts/mutation_gate.py and syncs pyproject/uv.lock/check.sh. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/dependabot.yml | 7 + .github/workflows/ci.yml | 10 ++ .gitignore | 1 - .pre-commit-config.yaml | 18 +++ aai_cli/auth/ams.py | 28 ++-- aai_cli/auth/flow.py | 180 +++++++++++++------------ aai_cli/commands/account.py | 31 +---- aai_cli/commands/audit.py | 6 +- aai_cli/commands/keys.py | 14 +- aai_cli/commands/stream.py | 4 +- aai_cli/commands/transcribe.py | 70 +++++----- aai_cli/config.py | 115 ++++++++++------ aai_cli/config_builder.py | 39 +++--- aai_cli/jsonshape.py | 44 ++++++ aai_cli/llm.py | 75 +++++++++-- aai_cli/transcribe_render.py | 40 +----- pyproject.toml | 22 --- scripts/check.sh | 14 -- scripts/mutation_gate.py | 82 ------------ tests/test_account_command.py | 4 - tests/test_auth_flow.py | 28 ++-- tests/test_config.py | 11 +- tests/test_config_builder.py | 6 +- tests/test_jsonshape.py | 19 +++ tests/test_keys.py | 3 - tests/test_llm.py | 41 ++++++ tests/test_transcribe_render.py | 12 +- uv.lock | 229 -------------------------------- 28 files changed, 490 insertions(+), 663 deletions(-) delete mode 100644 scripts/mutation_gate.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9d4d8391..396c4a6f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,11 @@ updates: directory: "/" schedule: interval: weekly + # Wait before proposing a freshly published release, mirroring the safe-chain + # minimum-package-age posture: a compromised version is usually yanked within + # days, so a cooldown keeps it out of an auto-opened PR in the first place. + cooldown: + default-days: 7 groups: python-deps: patterns: ["*"] @@ -14,6 +19,8 @@ updates: directory: "/" schedule: interval: weekly + cooldown: + default-days: 7 groups: actions: patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 161e6542..c0c15a16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # no job pushes; don't leave the token in .git/config - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -47,6 +49,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # no job pushes; don't leave the token in .git/config - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -70,6 +74,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # no job pushes; don't leave the token in .git/config - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -88,6 +94,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # no job pushes; don't leave the token in .git/config - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -116,6 +124,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # no job pushes; don't leave the token in .git/config - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" diff --git a/.gitignore b/.gitignore index c2c09a6f..2510994d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ build/ .coverage coverage.xml htmlcov/ -mutants/ # Editor/agent local artifacts: keep personal settings local, but track the # team-shared bits (.claude/settings.json, agents/, skills/). diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26aca6b5..b7b91f93 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,24 @@ repos: args: [--fix] - id: ruff-format + # Lint the GitHub Actions workflows themselves (the jobs in ci.yml). Both hooks + # self-scope to .github/workflows/. actionlint-py is the pip-packaged wrapper + # (bundles the actionlint binary; no Go/Docker), and brings shellcheck-py so + # embedded `run:` shell is shellcheck'd the same way check.sh checks install.sh. + - repo: https://github.com/Mateusz-Grzelinski/actionlint-py + rev: v1.7.12.24 + hooks: + - id: actionlint + additional_dependencies: [shellcheck-py>=0.9.0.5] + + # Security audit for the workflows: catches the Actions-specific smells (unpinned + # actions, over-broad permissions/tokens, injection-prone expressions) that + # complement the SHA-pinning + least-privilege posture already in ci.yml. + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.25.2 + hooks: + - id: zizmor + - repo: local hooks: - id: pytest diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 7f611699..44833a52 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -1,26 +1,24 @@ from __future__ import annotations import httpx2 as httpx -from pydantic import TypeAdapter, ValidationError +from aai_cli import jsonshape from aai_cli.auth import endpoints from aai_cli.errors import APIError, NotAuthenticated _TIMEOUT = 30.0 _HTTP_ERROR_MIN_STATUS = 400 -_JSON_OBJECT: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object]) -_JSON_OBJECTS: TypeAdapter[list[dict[str, object]]] = TypeAdapter(list[dict[str, object]]) def _detail(resp: httpx.Response) -> str: fallback = resp.text or f"HTTP {resp.status_code}" try: body: object = resp.json() - mapping = _JSON_OBJECT.validate_python(body) - if "detail" in mapping: - return str(mapping["detail"]) - except (TypeError, ValueError, ValidationError): + except ValueError: return fallback + mapping = jsonshape.as_mapping(body) + if mapping is not None and "detail" in mapping: + return str(mapping["detail"]) return fallback @@ -39,22 +37,22 @@ def _json_or_raise(resp: httpx.Response) -> object: def _json_object_or_raise(resp: httpx.Response) -> dict[str, object]: data = _json_or_raise(resp) - try: - return _JSON_OBJECT.validate_python(data) - except ValidationError as exc: + mapping = jsonshape.as_mapping(data) + if mapping is None: raise APIError( f"AMS request returned unexpected JSON: expected object, got {type(data).__name__}." - ) from exc + ) + return mapping def _json_object_list_or_raise(resp: httpx.Response) -> list[dict[str, object]]: data = _json_or_raise(resp) - try: - return _JSON_OBJECTS.validate_python(data) - except ValidationError as exc: + objects = jsonshape.as_object_list(data) + if objects is None: raise APIError( f"AMS request returned unexpected JSON: expected list of objects, got {type(data).__name__}." - ) from exc + ) + return objects def _client(session_jwt: str | None = None) -> httpx.Client: diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index d0f88e45..d6073e20 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -1,12 +1,13 @@ from __future__ import annotations import webbrowser -from collections.abc import Mapping from dataclasses import dataclass +from typing import TypeVar +from pydantic import BaseModel, TypeAdapter, ValidationError from rich.markup import escape -from aai_cli import jsonshape, output +from aai_cli import output from aai_cli.auth import ams, discovery, endpoints, loopback from aai_cli.errors import APIError @@ -22,56 +23,68 @@ class LoginResult: account_id: int -def _as_mapping(value: object) -> dict[str, object] | None: - return jsonshape.as_mapping(value) +# Typed views of the AMS login responses. AMS only returns HTTP errors for outright +# failures; a 200 with an unexpected shape would otherwise KeyError into an ugly +# traceback, so each required field's absence becomes the same clean "run login +# again" APIError via `_parse`. Extra fields (e.g. discover's `email`) are ignored. +class _Organization(BaseModel): + organization_id: str + organization_name: str | None = None -def _mapping_list(value: object) -> list[dict[str, object]]: - return jsonshape.mapping_list(value) +class _Discovery(BaseModel): + intermediate_session_token: str + organizations: list[_Organization] = [] -def _require_int(mapping: Mapping[str, object], key: str, what: str) -> int: - value = _require(mapping, key, what) - if isinstance(value, bool): - raise APIError( - f"Login failed: the server response was missing {what}. Run 'aai login' again." - ) - if isinstance(value, int): - return value - if isinstance(value, str): - try: - return int(value) - except ValueError as exc: - raise APIError( - f"Login failed: the server response was missing {what}. Run 'aai login' again." - ) from exc - raise APIError(f"Login failed: the server response was missing {what}. Run 'aai login' again.") - - -def _require(mapping: Mapping[str, object], key: str, what: str) -> object: - """Pull a required field out of an AMS response, or raise a clean APIError. - - AMS only returns HTTP errors for outright failures; a 200 with an unexpected - shape would otherwise KeyError into an ugly traceback, so map that to the same - "run login again" message the rest of the flow uses. The return stays `object` - because AMS JSON leaves are narrowed by callers with int()/str(). - """ - value = mapping.get(key) - if value is None: - raise APIError( - f"Login failed: the server response was missing {what}. Run 'aai login' again." - ) - return value +class _Account(BaseModel): + id: int + + +class _SignedIn(BaseModel): + session_jwt: str + session_token: str + account: _Account -def _require_mapping(mapping: Mapping[str, object], key: str, what: str) -> dict[str, object]: - value = _require(mapping, key, what) - mapped = _as_mapping(value) - if mapped is None: +class _Project(BaseModel): + id: int + + +class _Token(BaseModel): + # List endpoints may key the display name as either "name" or "token_name" + # (the latter matches the create payload), so accept either. + name: str | None = None + token_name: str | None = None + is_disabled: bool = False + api_key: str | None = None + + +class _ProjectEntry(BaseModel): + project: _Project | None = None + tokens: list[_Token] = [] + + +class _CreatedToken(BaseModel): + api_key: str + + +T = TypeVar("T") + +_DISCOVERY: TypeAdapter[_Discovery] = TypeAdapter(_Discovery) +_SIGNED_IN: TypeAdapter[_SignedIn] = TypeAdapter(_SignedIn) +_CREATED_TOKEN: TypeAdapter[_CreatedToken] = TypeAdapter(_CreatedToken) +_PROJECT_LIST: TypeAdapter[list[_ProjectEntry]] = TypeAdapter(list[_ProjectEntry]) + + +def _parse(adapter: TypeAdapter[T], data: object) -> T: + """Validate an AMS response into a typed view, or raise a clean login error.""" + try: + return adapter.validate_python(data) + except ValidationError as exc: raise APIError( - f"Login failed: the server response was missing {what}. Run 'aai login' again." - ) - return mapped + "Login failed: the server returned an unexpected response. Run 'aai login' again." + ) from exc def _open_browser(url: str) -> None: @@ -91,36 +104,38 @@ def _capture() -> loopback.CallbackResult: return loopback.capture_callback() -def _is_reusable_cli_token(token: dict[str, object]) -> bool: - """A live 'AssemblyAI CLI' token whose key the list actually exposes.""" - # List endpoints may key the display name as either "name" or "token_name" - # (the latter matches the create payload); accept either so we don't mint a - # duplicate every login. A token whose api_key the list omits can't be reused. - name = token.get("name") or token.get("token_name") - return ( - name == endpoints.CLI_TOKEN_NAME - and not token.get("is_disabled") - and bool(token.get("api_key")) - ) +def _reusable_cli_key(token: _Token) -> str | None: + """The api_key of a live 'AssemblyAI CLI' token the list actually exposes, else None. + + A disabled token, or one whose api_key the list omits, can't be reused — we fall + through to minting a fresh one instead of duplicating or crashing. + """ + name = token.name or token.token_name + if name == endpoints.CLI_TOKEN_NAME and not token.is_disabled and token.api_key: + return token.api_key + return None def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: """Return the existing 'AssemblyAI CLI' key, or create one in the first project.""" - projects = ams.list_projects(account_id, session_jwt) + projects = _parse(_PROJECT_LIST, ams.list_projects(account_id, session_jwt)) if not projects: raise APIError( "Your account has no project to create an API key in.", suggestion="Create a project in the AssemblyAI dashboard, then run 'aai login' again.", ) for entry in projects: - for token in _mapping_list(entry.get("tokens")): - if _is_reusable_cli_token(token): - return str(token["api_key"]) - project_id = _require_int( - _require_mapping(projects[0], "project", "a project"), "id", "a project id" - ) - created = ams.create_token(account_id, project_id, endpoints.CLI_TOKEN_NAME, session_jwt) - return str(_require(created, "api_key", "an API key")) + for token in entry.tokens: + if key := _reusable_cli_key(token): + return key + project = projects[0].project + if project is None: + raise APIError( + "Your account has no project to create an API key in.", + suggestion="Create a project in the AssemblyAI dashboard, then run 'aai login' again.", + ) + created = ams.create_token(account_id, project.id, endpoints.CLI_TOKEN_NAME, session_jwt) + return _parse(_CREATED_TOKEN, created).api_key def run_login_flow() -> LoginResult: @@ -139,39 +154,28 @@ def run_login_flow() -> LoginResult: suggestion="Run 'aai login' again.", ) - disc = ams.discover(result.token) - organizations = _mapping_list(disc.get("organizations")) - if not organizations: + disc = _parse(_DISCOVERY, ams.discover(result.token)) + if not disc.organizations: raise APIError( "Signed in, but this identity has no AssemblyAI account yet. " f"Create one at {endpoints.signup_url()}, then run 'aai login' again." ) - if len(organizations) > 1: - chosen = str( - organizations[0].get("organization_name") - or organizations[0].get("organization_id", "the first") - ) + org = disc.organizations[0] + if len(disc.organizations) > 1: output.error_console.print( - f"[aai.muted]Found {len(organizations)} organizations; signing in to " - f"'{chosen}'.[/aai.muted]" + f"[aai.muted]Found {len(disc.organizations)} organizations; signing in to " + f"'{org.organization_name or org.organization_id}'.[/aai.muted]" ) - organization_id = str(_require(organizations[0], "organization_id", "an organization id")) - intermediate_session_token = str( - _require(disc, "intermediate_session_token", "a session token") - ) - signed_in = ams.exchange(intermediate_session_token, organization_id) - session_jwt = str(_require(signed_in, "session_jwt", "a session token")) - session_token = str(_require(signed_in, "session_token", "a session token")) # `exchange` already returns the signed-in account, so read the id from it # rather than making a second GET /v1/auth round-trip. - account_id = _require_int( - _require_mapping(signed_in, "account", "an account"), "id", "an account id" + signed_in = _parse( + _SIGNED_IN, ams.exchange(disc.intermediate_session_token, org.organization_id) ) - api_key = find_or_create_cli_key(account_id, session_jwt) + api_key = find_or_create_cli_key(signed_in.account.id, signed_in.session_jwt) return LoginResult( api_key=api_key, - session_jwt=session_jwt, - session_token=session_token, - account_id=account_id, + session_jwt=signed_in.session_jwt, + session_token=signed_in.session_token, + account_id=signed_in.account.id, ) diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 435b561a..89163d8d 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -41,32 +41,15 @@ def _format_usage_day(value: object) -> str: return parsed.date().isoformat() -def _usage_number(value: object) -> float: - if isinstance(value, bool): - return 0.0 - if isinstance(value, int | float): - return float(value) - if isinstance(value, str): - try: - return float(value) - except ValueError: - return 0.0 - return 0.0 - - def _format_usage_number(value: object) -> str: - number = _usage_number(value) + number = jsonshape.as_float(value) if number.is_integer(): return f"{int(number):,}" return f"{number:,.6f}".rstrip("0").rstrip(".") -def _mapping_list(value: object) -> list[dict[str, object]]: - return jsonshape.mapping_list(value) - - def _usage_items(data: Mapping[str, object]) -> list[dict[str, object]]: - return _mapping_list(data.get("usage_items")) + return jsonshape.mapping_list(data.get("usage_items")) def _window_label(item: Mapping[str, object]) -> str: @@ -108,7 +91,7 @@ def _line_item_label(line_item: Mapping[str, object]) -> str: def _line_items_summary(item: Mapping[str, object]) -> str: labels = [ label - for line_item in _mapping_list(item.get("line_items")) + for line_item in jsonshape.mapping_list(item.get("line_items")) if (label := _line_item_label(line_item)) ] return ", ".join(labels) @@ -134,7 +117,7 @@ def balance( def body(state: AppState, json_mode: bool) -> None: _, jwt = resolve_session(state) data = ams.get_balance(jwt) - cents = _usage_number(data.get("balance_in_cents")) + cents = jsonshape.as_float(data.get("balance_in_cents")) output.emit( data, lambda _d: f"Balance: [aai.success]${cents / 100:,.2f}[/aai.success]", @@ -177,9 +160,9 @@ def render(d: dict[str, object]) -> object: shown = ( items if include_zero - else [item for item in items if _usage_number(item.get("total"))] + else [item for item in items if jsonshape.as_float(item.get("total"))] ) - total = sum(_usage_number(item.get("total")) for item in items) + total = sum(jsonshape.as_float(item.get("total")) for item in items) range_label = f"{_format_usage_day(start_date)} to {_format_usage_day(end_date)} (UTC)" summary = Text( f"Usage total: {_format_usage_number(total)} for {range_label}", @@ -248,7 +231,7 @@ def body(state: AppState, json_mode: bool) -> None: def render(d: dict[str, object]) -> Table: table = Table("service", "limit", header_style="aai.heading") - for limit in _mapping_list(d.get("rate_limits")): + for limit in jsonshape.mapping_list(d.get("rate_limits")): table.add_row( escape(str(limit.get("service", ""))), _format_usage_number(limit.get("magnitude")), diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index f8976478..eb0e9105 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -90,12 +90,8 @@ def _resource_label(entry: Mapping[str, object]) -> str: return label if not resource_id else f"{label} #{resource_id}" -def _mapping_list(value: object) -> list[dict[str, object]]: - return jsonshape.mapping_list(value) - - def _audit_rows(payload: Mapping[str, object]) -> list[dict[str, object]]: - return _mapping_list(payload.get("data")) + return jsonshape.mapping_list(payload.get("data")) @app.command( diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index baba745a..cb656d4d 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -13,14 +13,6 @@ app = typer.Typer(help="List, create, and rename your AssemblyAI API keys.", no_args_is_help=True) -def _mapping(value: object) -> dict[str, object] | None: - return jsonshape.as_mapping(value) - - -def _mapping_list(value: object) -> list[dict[str, object]]: - return jsonshape.mapping_list(value) - - def _project_id(project: dict[str, object]) -> int | None: value = project.get("id") if isinstance(value, bool): @@ -55,7 +47,7 @@ def body(state: AppState, json_mode: bool) -> None: projects = ams.list_projects(account_id, jwt) rows: list[dict[str, object]] = [] for entry in projects: - project = _mapping(entry.get("project")) or {} + project = jsonshape.as_mapping(entry.get("project")) or {} project_name = project.get("name", "") rows.extend( { @@ -65,7 +57,7 @@ def body(state: AppState, json_mode: bool) -> None: "key": output.mask_secret(str(token.get("api_key", ""))), "disabled": bool(token.get("is_disabled")), } - for token in _mapping_list(entry.get("tokens")) + for token in jsonshape.mapping_list(entry.get("tokens")) ) def render(data: list[dict[str, object]]) -> Table: @@ -110,7 +102,7 @@ def body(state: AppState, json_mode: bool) -> None: projects = ams.list_projects(account_id, jwt) if not projects: raise APIError("Your account has no project to create a key in.") - project = _mapping(projects[0].get("project")) + project = jsonshape.as_mapping(projects[0].get("project")) if project is None: raise APIError("Your account has no project to create a key in.") pid = _project_id(project) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 7655411d..cff53dee 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -197,7 +197,7 @@ def make_flags(rate: int) -> dict[str, object]: raise UsageError("--show-code does not support macOS system audio capture yet.") merged = config_builder.merge_streaming_params( flags=make_flags(TARGET_RATE), - overrides=list(config_kv or []), + overrides=config_kv, config_file=config_file, ) gateway = code_gen.gateway_options(list(llm_prompt or []), model, max_tokens) @@ -281,7 +281,7 @@ def stream_one( source_label: str | None = None, ) -> None: merged = config_builder.merge_streaming_params( - flags=make_flags(rate), overrides=list(config_kv or []), config_file=config_file + flags=make_flags(rate), overrides=config_kv, config_file=config_file ) params = config_builder.construct_streaming_params(merged) client.stream_audio( diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 261cb405..66f83b90 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any +import assemblyai as aai import typer from aai_cli import ( @@ -33,6 +34,33 @@ def _render_transform_steps(d: dict[str, Any]) -> str: return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) +def _transcribe_audio( + api_key: str, + source: str | None, + *, + sample: bool, + transcription_config: aai.TranscriptionConfig, +) -> aai.Transcript: + if source == "-": + # Audio piped on stdin (e.g. `ffmpeg -i v.mp4 -f wav - | aai transcribe -`). + # The SDK uploads a path, so buffer the bytes to a temp file first. + data = stdio.read_binary_stdin() + if not data: + raise UsageError("No audio received on stdin.") + with tempfile.TemporaryDirectory(prefix="aai-stdin-") as td: + local = Path(td) / "audio" + local.write_bytes(data) + return client.transcribe(api_key, str(local), config=transcription_config) + + audio = client.resolve_audio_source(source, sample=sample) + if youtube.is_youtube_url(audio): + # Fetch first; AssemblyAI can't read a YouTube watch URL itself. + with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: + local = youtube.download_audio(audio, Path(td)) + return client.transcribe(api_key, str(local), config=transcription_config) + return client.transcribe(api_key, audio, config=transcription_config) + + @app.command( rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( @@ -236,7 +264,7 @@ def body(state: AppState, json_mode: bool) -> None: flags.update(config_builder.auth_header_flags(webhook_auth_header)) merged = config_builder.merge_transcribe_config( - flags=flags, overrides=list(config_kv or []), config_file=config_file + flags=flags, overrides=config_kv, config_file=config_file ) if show_code: @@ -251,25 +279,7 @@ def body(state: AppState, json_mode: bool) -> None: tc = config_builder.construct_transcription_config(merged) api_key = config.resolve_api_key(profile=state.profile) - if source == "-": - # Audio piped on stdin (e.g. `ffmpeg -i v.mp4 -f wav - | aai transcribe -`). - # The SDK uploads a path, so buffer the bytes to a temp file first. - data = stdio.read_binary_stdin() - if not data: - raise UsageError("No audio received on stdin.") - with tempfile.TemporaryDirectory(prefix="aai-stdin-") as td: - local = Path(td) / "audio" - local.write_bytes(data) - transcript = client.transcribe(api_key, str(local), config=tc) - else: - audio = client.resolve_audio_source(source, sample=sample) - if youtube.is_youtube_url(audio): - # Fetch first; AssemblyAI can't read a YouTube watch URL itself. - with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: - local = youtube.download_audio(audio, Path(td)) - transcript = client.transcribe(api_key, str(local), config=tc) - else: - transcript = client.transcribe(api_key, audio, config=tc) + transcript = _transcribe_audio(api_key, source, sample=sample, transcription_config=tc) if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). @@ -279,19 +289,13 @@ def body(state: AppState, json_mode: bool) -> None: if llm_prompt: # Chain the prompts: the first runs over the transcript (injected server-side # via transcript_id); each subsequent prompt runs over the prior response. - steps: list[dict[str, str]] = [] - previous: str | None = None - for i, prompt_text in enumerate(llm_prompt): - # First prompt runs over the transcript (by id); each later one over - # the prior response. - target = ( - {"transcript_id": transcript.id} if i == 0 else {"transcript_text": previous} - ) - out = llm.transform_transcript( - api_key, prompt=prompt_text, model=model, max_tokens=max_tokens, **target - ) - steps.append({"prompt": prompt_text, "output": out}) - previous = out + steps = llm.run_chain_steps( + api_key, + llm_prompt, + transcript_id=transcript.id, + model=model, + max_tokens=max_tokens, + ) output.emit( { **client.transcript_summary(transcript), diff --git a/aai_cli/config.py b/aai_cli/config.py index c4e97d3f..8fe91dfc 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -1,30 +1,58 @@ from __future__ import annotations import contextlib -import json import os import re import tempfile import tomllib from pathlib import Path -from typing import Any import keyring import keyring.errors # keyring.errors is not re-exported by keyring/__init__ import platformdirs import tomli_w -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError from aai_cli.errors import NotAuthenticated KEYRING_SERVICE = "assemblyai-cli" ENV_API_KEY = "ASSEMBLYAI_API_KEY" DEFAULT_PROFILE = "default" -_JSON_OBJECT: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object]) _PROFILE_RE = re.compile(r"^[A-Za-z0-9_-]+$") +class Profile(BaseModel): + """A single profile's non-secret settings persisted in config.toml. + + ``extra="allow"`` so unknown keys written by a newer CLI survive a round-trip + through an older one instead of being silently dropped on the next ``_dump``. + """ + + model_config = ConfigDict(extra="allow") + + env: str | None = None + account_id: int | None = None + + +class Config(BaseModel): + """The whole config.toml document. ``active_profile`` stays optional so we can + tell "never set" apart from the default and only adopt a new profile as active + when the file had none (matching the historic ``setdefault`` semantics).""" + + model_config = ConfigDict(extra="allow") + + active_profile: str | None = None + profiles: dict[str, Profile] = Field(default_factory=dict) + + +class StoredSession(BaseModel): + """The browser-login Stytch session blob persisted in the OS keyring as JSON.""" + + jwt: str + token: str = "" + + def _validate_profile(name: str) -> None: if not _PROFILE_RE.match(name): from aai_cli.errors import CLIError @@ -45,13 +73,13 @@ def _config_file() -> Path: return config_dir() / "config.toml" -def _load() -> dict[str, Any]: +def _load() -> Config: path = _config_file() if not path.exists(): - return {} + return Config() with path.open("rb") as fh: try: - data: dict[str, Any] = tomllib.load(fh) + data = tomllib.load(fh) except tomllib.TOMLDecodeError as exc: from aai_cli.errors import CLIError @@ -60,20 +88,30 @@ def _load() -> dict[str, Any]: error_type="invalid_config", exit_code=2, ) from exc - return data + try: + return Config.model_validate(data) + except ValidationError as exc: + from aai_cli.errors import CLIError + + raise CLIError( + f"Config file at {path} has an unexpected shape ({exc}). Fix or delete it.", + error_type="invalid_config", + exit_code=2, + ) from exc -def _dump(data: dict[str, Any]) -> None: +def _dump(cfg: Config) -> None: path = _config_file() path.parent.mkdir(parents=True, exist_ok=True) # 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. + # exclude_none is required: TOML has no null and tomli_w rejects None values. 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) + tomli_w.dump(cfg.model_dump(exclude_none=True), fh) tmp.replace(path) except BaseException: with contextlib.suppress(OSError): @@ -82,16 +120,17 @@ def _dump(data: dict[str, Any]) -> None: def get_active_profile() -> str: - return str(_load().get("active_profile", DEFAULT_PROFILE)) + return _load().active_profile or DEFAULT_PROFILE def set_api_key(profile: str, api_key: str) -> None: _validate_profile(profile) keyring.set_password(KEYRING_SERVICE, profile, api_key) - data = _load() - data.setdefault("profiles", {}).setdefault(profile, {}) - data.setdefault("active_profile", profile) - _dump(data) + cfg = _load() + cfg.profiles.setdefault(profile, Profile()) + if cfg.active_profile is None: + cfg.active_profile = profile + _dump(cfg) def get_api_key(profile: str) -> str | None: @@ -100,17 +139,16 @@ def get_api_key(profile: str) -> str | None: def get_profile_env(profile: str) -> str | None: """The backend environment recorded for a profile, if any (e.g. 'sandbox000').""" - profiles = _load().get("profiles", {}) - value = profiles.get(profile, {}).get("env") - return str(value) if value is not None else None + prof = _load().profiles.get(profile) + return prof.env if prof else None def set_profile_env(profile: str, env: str) -> None: """Bind a backend environment to a profile so its key and hosts stay matched.""" _validate_profile(profile) - data = _load() - data.setdefault("profiles", {}).setdefault(profile, {})["env"] = env - _dump(data) + cfg = _load() + cfg.profiles.setdefault(profile, Profile()).env = env + _dump(cfg) def clear_api_key(profile: str) -> None: @@ -135,11 +173,11 @@ def set_session(profile: str, *, session_jwt: str, session_token: str, account_i keyring.set_password( KEYRING_SERVICE, _session_username(profile), - json.dumps({"jwt": session_jwt, "token": session_token}), + StoredSession(jwt=session_jwt, token=session_token).model_dump_json(), ) - data = _load() - data.setdefault("profiles", {}).setdefault(profile, {})["account_id"] = account_id - _dump(data) + cfg = _load() + cfg.profiles.setdefault(profile, Profile()).account_id = account_id + _dump(cfg) def get_session(profile: str) -> dict[str, str] | None: @@ -148,33 +186,26 @@ def get_session(profile: str) -> dict[str, str] | None: if not raw: return None try: - data: object = json.loads(raw) - mapping = _JSON_OBJECT.validate_python(data) - except (json.JSONDecodeError, ValidationError): - return None - if "jwt" not in mapping: - return None - jwt = mapping.get("jwt") - if not isinstance(jwt, str): + session = StoredSession.model_validate_json(raw) + except ValidationError: return None - token = mapping.get("token") - return {"jwt": jwt, "token": str(token or "")} + return {"jwt": session.jwt, "token": session.token} def get_account_id(profile: str) -> int | None: """The AMS account id recorded at login for a profile, if any.""" - value = _load().get("profiles", {}).get(profile, {}).get("account_id") - return int(value) if value is not None else None + prof = _load().profiles.get(profile) + return prof.account_id if prof else None def clear_session(profile: str) -> None: with contextlib.suppress(keyring.errors.PasswordDeleteError): keyring.delete_password(KEYRING_SERVICE, _session_username(profile)) - data = _load() - prof = data.get("profiles", {}).get(profile) - if prof and "account_id" in prof: - del prof["account_id"] - _dump(data) + cfg = _load() + prof = cfg.profiles.get(profile) + if prof and prof.account_id is not None: + prof.account_id = None + _dump(cfg) def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str: diff --git a/aai_cli/config_builder.py b/aai_cli/config_builder.py index b156f1a7..8491fd63 100644 --- a/aai_cli/config_builder.py +++ b/aai_cli/config_builder.py @@ -3,17 +3,15 @@ import enum import json import typing -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path import assemblyai as aai from assemblyai.streaming.v3 import SpeechModel, StreamingParameters -from pydantic import TypeAdapter, ValidationError +from aai_cli import jsonshape from aai_cli.errors import UsageError -_JSON_OBJECT: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object]) - # The curated set of user-settable config fields per command. This is the authoritative # allow-list (deliberately a subset of the SDK models — e.g. output-only and internal # fields are excluded). The coercion KIND for each field is derived from the SDK model @@ -240,10 +238,12 @@ def coerce_value(field: str, raw: str) -> object: 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]: +def parse_config_overrides( + fields: dict[str, str], pairs: Sequence[str] | None +) -> dict[str, object]: """Parse repeated KEY=VALUE strings into a coerced, validated dict.""" out: dict[str, object] = {} - for pair in pairs: + for pair in pairs or (): if "=" not in pair: raise UsageError(f"--config expects KEY=VALUE, got {pair!r}.") key, raw = pair.split("=", 1) @@ -263,10 +263,9 @@ def load_config_file(path: str | Path, fields: dict[str, str]) -> dict[str, obje raise UsageError(f"Config file not found: {path}") from exc except json.JSONDecodeError as exc: raise UsageError(f"Config file is not valid JSON: {exc}") from exc - try: - config = _JSON_OBJECT.validate_python(data) - except ValidationError as exc: - raise UsageError("Config file must contain a JSON object.") from exc + config = jsonshape.as_mapping(data) + if config is None: + raise UsageError("Config file must contain a JSON object.") unknown = [key for key in config if key not in fields] if unknown: valid = ", ".join(sorted(fields)) @@ -277,7 +276,7 @@ def load_config_file(path: str | Path, fields: dict[str, str]) -> dict[str, obje def _merge( fields: dict[str, str], flags: dict[str, object], - overrides: list[str], + overrides: Sequence[str] | None, config_file: str | None, ) -> dict[str, object]: data: dict[str, object] = {} @@ -289,7 +288,10 @@ def _merge( def merge_transcribe_config( - *, flags: dict[str, object], overrides: list[str], config_file: str | None + *, + flags: dict[str, object], + overrides: Sequence[str] | None = None, + config_file: str | None = None, ) -> dict[str, object]: """Merge config-file + --config overrides + curated flags into a kwargs dict.""" return _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) @@ -306,7 +308,10 @@ def construct_transcription_config(merged: dict[str, typing.Any]) -> aai.Transcr def merge_streaming_params( - *, flags: dict[str, object], overrides: list[str], config_file: str | None + *, + flags: dict[str, object], + overrides: Sequence[str] | None = None, + config_file: str | None = None, ) -> dict[str, object]: """Merge streaming config into a kwargs dict, coercing speech_model to a SpeechModel.""" merged = _merge(STREAM_FIELDS, flags, overrides, config_file) @@ -366,10 +371,10 @@ def load_custom_spelling(path: str) -> dict[str, object]: raise UsageError(f"Custom spelling file not found: {path}") from exc except json.JSONDecodeError as exc: raise UsageError(f"Custom spelling file is not valid JSON: {exc}") from exc - try: - return _JSON_OBJECT.validate_python(data) - except ValidationError as exc: - raise UsageError("Custom spelling file must contain a JSON object.") from exc + mapping = jsonshape.as_mapping(data) + if mapping is None: + raise UsageError("Custom spelling file must contain a JSON object.") + return mapping def translation_request(languages: list[str]) -> dict[str, object]: diff --git a/aai_cli/jsonshape.py b/aai_cli/jsonshape.py index 44789836..017cb55f 100644 --- a/aai_cli/jsonshape.py +++ b/aai_cli/jsonshape.py @@ -4,6 +4,9 @@ _JSON_OBJECT: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object]) _OBJECT_LIST: TypeAdapter[list[object]] = TypeAdapter(list[object]) +_OBJECT_DICT_LIST: TypeAdapter[list[dict[str, object]]] = TypeAdapter(list[dict[str, object]]) +_INT: TypeAdapter[int] = TypeAdapter(int) +_FLOAT: TypeAdapter[float] = TypeAdapter(float) def as_mapping(value: object) -> dict[str, object] | None: @@ -20,6 +23,19 @@ def object_list(value: object) -> list[object]: return [] +def as_object_list(value: object) -> list[dict[str, object]] | None: + """Validate ``value`` as a list of JSON objects, or None if it isn't one. + + Unlike ``mapping_list`` (which silently drops non-object items), this rejects + the whole value when any element isn't an object — for callers that must tell a + wrong-shaped response apart from an empty list. + """ + try: + return _OBJECT_DICT_LIST.validate_python(value) + except ValidationError: + return None + + def mapping_list(value: object) -> list[dict[str, object]]: valid: list[dict[str, object]] = [] for item in object_list(value): @@ -27,3 +43,31 @@ def mapping_list(value: object) -> list[dict[str, object]]: if mapped is not None: valid.append(mapped) return valid + + +def as_int(value: object, default: int = 0) -> int: + """Coerce an untyped JSON scalar to int, returning ``default`` on failure. + + ``bool`` is treated as non-numeric (a JSON ``true``/``false`` is not a count), + overriding pydantic's lax ``True`` -> ``1`` coercion. + """ + if isinstance(value, bool): + return default + try: + return _INT.validate_python(value) + except ValidationError: + return default + + +def as_float(value: object, default: float = 0.0) -> float: + """Coerce an untyped JSON scalar to float, returning ``default`` on failure. + + ``bool`` is treated as non-numeric (a JSON ``true``/``false`` is not a count), + overriding pydantic's lax ``True`` -> ``1.0`` coercion. + """ + if isinstance(value, bool): + return default + try: + return _FLOAT.validate_python(value) + except ValidationError: + return default diff --git a/aai_cli/llm.py b/aai_cli/llm.py index ef2f4c25..de31a105 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -1,14 +1,14 @@ from __future__ import annotations -from typing import Any - -import openai -from openai import OpenAI -from openai.types.chat import ChatCompletion +from typing import TYPE_CHECKING, Any from aai_cli import environments from aai_cli.errors import APIError +if TYPE_CHECKING: + from openai import OpenAI + from openai.types.chat import ChatCompletion + # The LLM Gateway is OpenAI-compatible, so we talk to it through the OpenAI SDK # pointed at this base URL. This is the production host used in generated code # snippets (code_gen); runtime calls use the active environment's gateway base. @@ -61,6 +61,8 @@ def build_messages( def _client(api_key: str) -> OpenAI: + from openai import OpenAI + return OpenAI(api_key=api_key, base_url=environments.active().llm_gateway_base) @@ -78,6 +80,8 @@ def complete( inject the transcript text server-side. Access/permission and other gateway errors surface the gateway's own message as APIError. """ + import openai + client = _client(api_key) extra_body = {"transcript_id": transcript_id} if transcript_id is not None else None try: @@ -150,11 +154,60 @@ def run_chain( previous prompt's response. Used by live streaming (`stream --llm`), where there is no transcript id to inject server-side, so the text is always inlined. """ - output = "" - text = transcript_text - for prompt in prompts: + steps = run_chain_steps( + api_key, + prompts, + transcript_text=transcript_text, + model=model, + max_tokens=max_tokens, + ) + return steps[-1]["output"] if steps else "" + + +def run_chain_steps( + api_key: str, + prompts: list[str], + *, + transcript_id: str | None = None, + transcript_text: str | None = None, + model: str = DEFAULT_MODEL, + max_tokens: int = DEFAULT_MAX_TOKENS, +) -> list[dict[str, str]]: + """Run a prompt chain and return each step's prompt/output pair. + + The first step runs over a server-injected transcript when `transcript_id` is + provided, otherwise over inline `transcript_text`. Later steps run over the + previous step's output. + """ + if not prompts: + return [] + + if transcript_id is not None: output = transform_transcript( - api_key, prompt=prompt, model=model, max_tokens=max_tokens, transcript_text=text + api_key, + prompt=prompts[0], + model=model, + max_tokens=max_tokens, + transcript_id=transcript_id, ) - text = output - return output + else: + output = transform_transcript( + api_key, + prompt=prompts[0], + model=model, + max_tokens=max_tokens, + transcript_text=transcript_text, + ) + steps = [{"prompt": prompts[0], "output": output}] + + for prompt in prompts[1:]: + output = transform_transcript( + api_key, + prompt=prompt, + model=model, + max_tokens=max_tokens, + transcript_text=output, + ) + steps.append({"prompt": prompt, "output": output}) + + return steps diff --git a/aai_cli/transcribe_render.py b/aai_cli/transcribe_render.py index 318f993b..380f46c2 100644 --- a/aai_cli/transcribe_render.py +++ b/aai_cli/transcribe_render.py @@ -32,34 +32,6 @@ def _mapping(value: object) -> dict[str, object]: return jsonshape.as_mapping(value) or {} -def _int_value(value: object) -> int: - if isinstance(value, bool): - return 0 - if isinstance(value, int): - return value - if isinstance(value, float): - return int(value) - if isinstance(value, str): - try: - return int(value) - except ValueError: - return 0 - return 0 - - -def _float_value(value: object) -> float: - if isinstance(value, bool): - return 0.0 - if isinstance(value, int | float): - return float(value) - if isinstance(value, str): - try: - return float(value) - except ValueError: - return 0.0 - return 0.0 - - def render_transcript_result(transcript: object, console: Console) -> None: """Print the transcript text, then a section per analysis feature present.""" _render_text(transcript, console) @@ -102,7 +74,7 @@ def _render_chapters(transcript: object, console: Console) -> None: return console.print("\n[bold]Chapters:[/bold]") for ch in chapters: - span = f"{_fmt_ms(_int_value(getattr(ch, 'start', 0)))}-{_fmt_ms(_int_value(getattr(ch, 'end', 0)))}" + span = f"{_fmt_ms(jsonshape.as_int(getattr(ch, 'start', 0)))}-{_fmt_ms(jsonshape.as_int(getattr(ch, 'end', 0)))}" console.print(f" {span} {getattr(ch, 'headline', '')}") @@ -142,8 +114,10 @@ def _render_topics(transcript: object, console: Console) -> None: return console.print("\n[bold]Topics:[/bold]") items: Mapping[str, object] = summary - for label, relevance in sorted(items.items(), key=lambda kv: _float_value(kv[1]), reverse=True): - console.print(f" {label} ({_float_value(relevance):.2f})") + for label, relevance in sorted( + items.items(), key=lambda kv: jsonshape.as_float(kv[1]), reverse=True + ): + console.print(f" {label} ({jsonshape.as_float(relevance):.2f})") def _render_content_safety(transcript: object, console: Console) -> None: @@ -153,6 +127,6 @@ def _render_content_safety(transcript: object, console: Console) -> None: console.print("\n[bold]Content Safety:[/bold]") items: Mapping[str, object] = summary for label, confidence in sorted( - items.items(), key=lambda kv: _float_value(kv[1]), reverse=True + items.items(), key=lambda kv: jsonshape.as_float(kv[1]), reverse=True ): - console.print(f" {_enum_value(label)} ({_float_value(confidence):.2f})") + console.print(f" {_enum_value(label)} ({jsonshape.as_float(confidence):.2f})") diff --git a/pyproject.toml b/pyproject.toml index c48210d8..43cd3e2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,6 @@ dev = [ "vulture>=2.14", "deptry>=0.23.0", "import-linter>=2.3", - "mutmut>=3.5.0", ] [tool.uv] @@ -219,24 +218,3 @@ audioop-lts = "audioop" # dedicated install tests, not by the root package metadata. DEP002 = ["fastapi", "python-dotenv", "python-multipart", "uvicorn"] DEP004 = ["fastapi", "httpx", "hypothesis", "pytest"] - -[tool.mutmut] -paths_to_mutate = [ - "aai_cli/auth/ams.py", - "aai_cli/config_builder.py", - "aai_cli/context.py", - "aai_cli/errors.py", - "aai_cli/output.py", -] -also_copy = ["aai_cli"] -pytest_add_cli_args = ["--strict-config", "--strict-markers"] -pytest_add_cli_args_test_selection = [ - "tests/test_auth_ams.py", - "tests/test_ams_account.py", - "tests/test_config_builder.py", - "tests/test_context.py", - "tests/test_errors.py", - "tests/test_login.py", - "tests/test_output.py", -] -mutate_only_covered_lines = true diff --git a/scripts/check.sh b/scripts/check.sh index 2d2e8498..bd5607e2 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -18,13 +18,6 @@ cleanup_generated_code_dir() { fi } -cleanup_mutants_dir() { - rm -rf mutants -} - -# Make reruns deterministic after an interrupted mutation run. -cleanup_mutants_dir - echo "==> uv lock freshness" uv lock --check @@ -172,13 +165,6 @@ else echo " origin/main not found; skipping escape-hatch diff gate (CI provides it)" fi -echo "==> mutation testing (focused core modules)" -cleanup_mutants_dir -trap cleanup_mutants_dir EXIT -uv run python scripts/mutation_gate.py -cleanup_mutants_dir -trap - EXIT - echo "==> build + twine check (PyPI publish readiness)" # Build sdist + wheel into ./dist, then validate the metadata and README render # the way PyPI requires. --strict fails on any warning (e.g. a missing readme). diff --git a/scripts/mutation_gate.py b/scripts/mutation_gate.py deleted file mode 100644 index 59f68a56..00000000 --- a/scripts/mutation_gate.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -import fnmatch -import os -import shutil -import subprocess -import sys - -_MUTANTS_DIR = "mutants" -_PASSING_STATUSES = {"killed", "caught by type check"} -_SMOKE_MUTANTS = ( - "aai_cli.auth.ams.x__json_or_raise__mutmut_*", - "aai_cli.config_builder.x__merge__mutmut_*", - "aai_cli.context.x__persist_browser_login__mutmut_*", - "aai_cli.errors.x_is_auth_failure__mutmut_*", - "aai_cli.output.x_mask_secret__mutmut_*", -) - - -def _run(cmd: list[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]: - return subprocess.run( - cmd, - capture_output=capture, - check=False, - text=True, - ) - - -def _selected(name: str) -> bool: - return any(fnmatch.fnmatch(name, pattern) for pattern in _SMOKE_MUTANTS) - - -def _bad_results(output: str) -> tuple[list[str], int]: - bad: list[str] = [] - seen = 0 - for line in output.splitlines(): - stripped = line.strip() - if not stripped or ": " not in stripped: - continue - name, status = stripped.rsplit(": ", 1) - if not _selected(name): - continue - seen += 1 - if status not in _PASSING_STATUSES: - bad.append(stripped) - return bad, seen - - -def main() -> int: - shutil.rmtree(_MUTANTS_DIR, ignore_errors=True) - max_children = os.environ.get("MUTMUT_MAX_CHILDREN", "1") - run = _run(["mutmut", "run", "--max-children", max_children, *_SMOKE_MUTANTS], capture=True) - if run.returncode != 0: - if run.stdout: - sys.stdout.write(run.stdout) - if run.stderr: - sys.stderr.write(run.stderr) - return run.returncode - - results = _run(["mutmut", "results", "--all", "true"], capture=True) - if results.returncode != 0: - if results.stdout: - sys.stdout.write(results.stdout) - if results.stderr: - sys.stderr.write(results.stderr) - return results.returncode - - bad, seen = _bad_results(results.stdout) - if seen == 0: - sys.stderr.write("Mutation testing did not report any configured smoke mutants.\n") - return 1 - if bad: - sys.stderr.write("\nMutation testing found surviving or untested mutants:\n") - for line in bad: - sys.stderr.write(f" {line}\n") - return 1 - sys.stdout.write(f"mutation smoke killed {seen} mutants\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_account_command.py b/tests/test_account_command.py index 7894983d..da1dc4e8 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -102,10 +102,6 @@ def test_usage_helpers_handle_unparseable_values(): assert account._parse_usage_timestamp("") is None assert account._parse_usage_timestamp("not-a-date") is None assert account._format_usage_day(None) == "" - assert account._usage_number(True) == 0.0 - assert account._usage_number("12.5") == 12.5 - assert account._usage_number("bad") == 0.0 - assert account._usage_number(object()) == 0.0 def test_usage_helpers_format_windows_and_line_items(): diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index fcdd237d..a9af240f 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -19,21 +19,12 @@ def test_find_or_create_reuses_existing_cli_key(monkeypatch): assert flow.find_or_create_cli_key(1, "jwt") == "sk_cli" -def test_json_shape_helpers_filter_invalid_values(): - assert flow._as_mapping("bad") is None - assert flow._mapping_list("bad") == [] - assert flow._mapping_list([{"ok": 1}, "bad"]) == [{"ok": 1}] - - -@pytest.mark.parametrize("value", [True, "bad", object()]) -def test_require_int_rejects_invalid_values(value): - with pytest.raises(APIError): - flow._require_int({"id": value}, "id", "an id") - - -def test_require_mapping_rejects_non_object_value(): +def test_find_or_create_rejects_malformed_project_list(monkeypatch): + # A 200 with an unexpected shape (here: a project id that isn't an int) becomes a + # clean "run login again" APIError rather than a traceback. + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: [{"project": {"id": "x"}}]) with pytest.raises(APIError): - flow._require_mapping({"project": "bad"}, "project", "a project") + flow.find_or_create_cli_key(1, "jwt") def test_find_or_create_creates_when_absent(monkeypatch): @@ -51,6 +42,15 @@ def fake_create(account_id, project_id, token_name, session_jwt): assert created == {"project_id": 7, "token_name": "AssemblyAI CLI"} +def test_find_or_create_raises_when_first_project_has_no_project(monkeypatch): + # A project entry that omits its "project" object can't be created into; surface a + # clean APIError instead of crashing when reaching for the project id. + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: [{"tokens": []}]) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) + with pytest.raises(APIError): + flow.find_or_create_cli_key(1, "jwt") + + def test_find_or_create_creates_when_existing_cli_token_disabled(monkeypatch): projects = [ { diff --git a/tests/test_config.py b/tests/test_config.py index 499c58bd..4f4daab4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -91,6 +91,15 @@ def test_malformed_config_raises_clean_error(tmp_config): config.get_active_profile() +def test_unexpected_config_shape_raises_clean_error(tmp_config): + # Valid TOML but the wrong shape (profiles must be a table, not a string) is + # surfaced as a clean invalid_config CLIError, not a pydantic traceback. + (tmp_config / "config.toml").write_text('profiles = "oops"\n') + with pytest.raises(CLIError) as exc: + config.get_active_profile() + assert exc.value.error_type == "invalid_config" + + 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") @@ -117,7 +126,7 @@ def boom(_data, _fh): monkeypatch.setattr(config.tomli_w, "dump", boom) with pytest.raises(RuntimeError): - config._dump({"profiles": {}}) + config._dump(config.Config()) names = sorted(p.name for p in tmp_config.iterdir()) assert names == ["config.toml"] # no .config-*.toml.tmp left behind diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index 98dc45c6..7c7ccbb4 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -58,6 +58,10 @@ def test_parse_config_overrides_requires_equals(): cb.parse_config_overrides(cb.TRANSCRIBE_FIELDS, ["speaker_labels"]) +def test_parse_config_overrides_accepts_none(): + assert cb.parse_config_overrides(cb.TRANSCRIBE_FIELDS, None) == {} + + def test_transcribe_config_layer_precedence(tmp_path): cfg = tmp_path / "c.json" cfg.write_text(json.dumps({"speaker_labels": False, "speakers_expected": 5})) @@ -74,7 +78,7 @@ def test_transcribe_config_layer_precedence(tmp_path): def test_transcribe_config_ignores_unset_flags(): tc = cb.construct_transcription_config( - cb.merge_transcribe_config(flags={"speaker_labels": None}, overrides=[], config_file=None) + cb.merge_transcribe_config(flags={"speaker_labels": None}) ) assert tc.speaker_labels is None # None means "not set", does not override diff --git a/tests/test_jsonshape.py b/tests/test_jsonshape.py index ea2a2fa8..16b6653f 100644 --- a/tests/test_jsonshape.py +++ b/tests/test_jsonshape.py @@ -17,3 +17,22 @@ def test_mapping_list_filters_non_objects(): {"also": "ok"}, ] assert jsonshape.mapping_list("bad") == [] + + +def test_as_int_coerces_scalars_and_defaults(): + assert jsonshape.as_int(True) == 0 # bool is not a count + assert jsonshape.as_int(12) == 12 + assert jsonshape.as_int(12.9) == 0 # non-integral float is not a valid int + assert jsonshape.as_int("13") == 13 + assert jsonshape.as_int("bad") == 0 + assert jsonshape.as_int(object()) == 0 + assert jsonshape.as_int(None, default=-1) == -1 + + +def test_as_float_coerces_scalars_and_defaults(): + assert jsonshape.as_float(True) == 0.0 # bool is not a count + assert jsonshape.as_float(1) == 1.0 + assert jsonshape.as_float("1.5") == 1.5 + assert jsonshape.as_float("bad") == 0.0 + assert jsonshape.as_float(object()) == 0.0 + assert jsonshape.as_float(None, default=-1.0) == -1.0 diff --git a/tests/test_keys.py b/tests/test_keys.py index da706add..28b39173 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -38,9 +38,6 @@ def test_keys_list_flattens_tokens(): def test_keys_shape_helpers_filter_invalid_values(): - assert keys._mapping("bad") is None - assert keys._mapping_list("bad") == [] - assert keys._mapping_list([{"id": 1}, "bad"]) == [{"id": 1}] assert keys._project_id({"id": True}) is None assert keys._project_id({"id": 7}) == 7 assert keys._project_id({"id": "8"}) == 8 diff --git a/tests/test_llm.py b/tests/test_llm.py index 36ca6006..a16351c9 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -40,6 +40,13 @@ def _fake_client(monkeypatch, *, result=None, error=None): return seen +def test_client_targets_active_gateway_base(): + from aai_cli import environments + + client = llm._client("sk_live") + assert str(client.base_url).rstrip("/") == environments.active().llm_gateway_base.rstrip("/") + + def test_complete_sends_model_and_messages(monkeypatch): seen = _fake_client(monkeypatch, result=_response("hi there")) resp = llm.complete( @@ -175,3 +182,37 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): assert "summarize" in calls[0] and "hola mundo" in calls[0] # Second prompt runs over the FIRST step's output, not the transcript. assert "translate to french" in calls[1] and "out1" in calls[1] + + +def test_run_chain_empty_prompts_do_not_call_gateway(monkeypatch): + def fail_complete(*args, **kwargs): + raise AssertionError("gateway should not be called") + + monkeypatch.setattr(llm, "complete", fail_complete) + assert llm.run_chain_steps("sk", []) == [] + assert llm.run_chain("sk", [], transcript_text="hola mundo") == "" + + +def test_run_chain_steps_uses_transcript_id_then_prior_output(monkeypatch): + calls = [] + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + calls.append({"content": messages[-1]["content"], "transcript_id": transcript_id}) + return _response(f"out{len(calls)}") + + monkeypatch.setattr(llm, "complete", fake_complete) + steps = llm.run_chain_steps( + "sk", + ["summarize", "translate"], + transcript_id="t_1", + model="m", + max_tokens=50, + ) + assert steps == [ + {"prompt": "summarize", "output": "out1"}, + {"prompt": "translate", "output": "out2"}, + ] + assert calls[0]["transcript_id"] == "t_1" + assert llm.TRANSCRIPT_TAG in calls[0]["content"] + assert calls[1]["transcript_id"] is None + assert "out1" in calls[1]["content"] diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py index 4b4cb170..4af697af 100644 --- a/tests/test_transcribe_render.py +++ b/tests/test_transcribe_render.py @@ -43,18 +43,8 @@ def test_renders_text_only_when_no_analysis(): def test_render_helpers_handle_invalid_dynamic_values(): + # Numeric coercion now lives in jsonshape.as_int/as_float (see test_jsonshape). assert tr._mapping("bad") == {} - assert tr._int_value(True) == 0 - assert tr._int_value(12) == 12 - assert tr._int_value(12.9) == 12 - assert tr._int_value("13") == 13 - assert tr._int_value("bad") == 0 - assert tr._int_value(object()) == 0 - assert tr._float_value(True) == 0.0 - assert tr._float_value(1) == 1.0 - assert tr._float_value("1.5") == 1.5 - assert tr._float_value("bad") == 0.0 - assert tr._float_value(object()) == 0.0 def test_renders_per_speaker_utterances(): diff --git a/uv.lock b/uv.lock index 7faf067d..69a5df49 100644 --- a/uv.lock +++ b/uv.lock @@ -37,7 +37,6 @@ dev = [ { name = "httpx" }, { name = "hypothesis" }, { name = "import-linter" }, - { name = "mutmut" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pyright" }, @@ -78,7 +77,6 @@ dev = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "hypothesis", specifier = ">=6.155.1" }, { name = "import-linter", specifier = ">=2.3" }, - { name = "mutmut", specifier = ">=3.5.0" }, { name = "mypy", specifier = ">=2.1.0" }, { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pyright", specifier = ">=1.1.409" }, @@ -1109,66 +1107,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] -[[package]] -name = "libcst" -version = "1.8.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, - { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" }, - { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" }, - { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, - { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, - { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, - { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, - { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, - { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, - { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, - { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, - { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, - { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, - { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, - { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, - { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, - { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, - { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, - { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, - { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, -] - [[package]] name = "librt" version = "0.11.0" @@ -1242,18 +1180,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, -] - [[package]] name = "mando" version = "0.7.1" @@ -1278,11 +1204,6 @@ 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.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1357,18 +1278,6 @@ wheels = [ { 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 = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1387,23 +1296,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] -[[package]] -name = "mutmut" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "coverage" }, - { name = "libcst" }, - { name = "pytest" }, - { name = "setproctitle" }, - { name = "textual" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/0d/9ce4fc8b219504a336eb814c5a7ea8e379ad93ce05327ff3842aea93bf0b/mutmut-3.5.0.tar.gz", hash = "sha256:548186d4b0c494b7b9895db82871cb1f229b9271c9ff7cd633e348dd9afcc772", size = 36389, upload-time = "2026-02-22T18:46:41.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/23/ac475f6db39643946feb09290a2178d603d2b623034d56d3f5059cddb769/mutmut-3.5.0-py3-none-any.whl", hash = "sha256:f19f2dd2e977eb9dc17255d8cb11e24fbfc3191620fba3108cac25779c9d78c9", size = 34242, upload-time = "2026-02-22T18:46:43.113Z" }, -] - [[package]] name = "mypy" version = "2.1.0" @@ -1843,30 +1735,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "pyyaml-ft" -version = "8.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, - { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, - { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, - { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, - { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, - { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, - { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, -] - [[package]] name = "questionary" version = "2.1.1" @@ -1970,77 +1838,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] -[[package]] -name = "setproctitle" -version = "1.3.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, - { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, - { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, - { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, - { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, - { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, - { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, - { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, - { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, - { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, - { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, - { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, - { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, - { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, - { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, - { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, - { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, - { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, - { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, - { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, - { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, - { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, - { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, - { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, - { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, - { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, - { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, - { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, - { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, - { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, - { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, - { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, - { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, - { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, - { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, - { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, - { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" @@ -2118,23 +1915,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/18/dc99a152bea18a898a8ac387bfeb9ec0829e0f5bed11cfec2e2ca189c5a2/syrupy-5.2.0-py3-none-any.whl", hash = "sha256:798cb493a6e20f4839e58ea8f10eb1b0d85684c676442f79786e219bf32618e6", size = 51828, upload-time = "2026-05-16T21:11:34.984Z" }, ] -[[package]] -name = "textual" -version = "8.2.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify"] }, - { name = "mdit-py-plugins" }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, -] - [[package]] name = "tomli" version = "2.4.1" @@ -2246,15 +2026,6 @@ 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 = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "urllib3" version = "2.7.0"