diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fbe747b..9574103a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,10 @@ jobs: - name: Audit runtime dependencies for known CVEs run: | + # Keep build tooling current first: pip-audit scans the whole environment, + # so a pip/setuptools advisory that a one-line upgrade fixes would otherwise + # fail the gate on something that isn't one of our runtime dependencies. + python -m pip install --upgrade pip setuptools python -m pip install -e . pip-audit # Append `--ignore-vuln ` to accept an unfixable transitive advisory. python -m pip_audit diff --git a/CLAUDE.md b/CLAUDE.md index 0edff340..0756f0a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ uv run pytest tests/test_transcribe.py::test_name -q # a single test The default suite **excludes** two slow/credentialed marker sets (see `scripts/check.sh` and `pyproject.toml`): ```sh -uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY + kokoro, else skips +uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY, else skips uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx ``` diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index ad9cd763..1581be6b 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -7,9 +7,14 @@ from collections.abc import Callable from typing import Any +from aai_cli import environments from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure -WS_URL = "wss://agents.assemblyai.com/v1/ws" + +def _ws_url() -> str: + """Voice Agent socket URL for the active environment (set at CLI startup).""" + return f"wss://{environments.active().agents_host}/v1/ws" + DEFAULT_PROMPT = ( "You are a friendly voice assistant having a casual conversation. Keep replies " @@ -166,7 +171,7 @@ def _auth_or_api_error(exc: Exception, message: str) -> CLIError: def _open_ws(connect: Any, api_key: str) -> Any: """Open the Voice Agent socket, mapping a connect failure to a clean CLIError.""" try: - return connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) + return connect(_ws_url(), additional_headers={"Authorization": f"Bearer {api_key}"}) except Exception as exc: raise _auth_or_api_error(exc, "Could not connect to the voice agent") from exc diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index e483ec9f..4fd1bca9 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -2,7 +2,7 @@ from typing import Any, cast -import httpx +import httpx2 as httpx from aai_cli.auth import endpoints from aai_cli.errors import APIError, NotAuthenticated diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index a97a96e5..f7c10226 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from typing import Any +from rich.markup import escape + from aai_cli import output from aai_cli.auth import ams, discovery, endpoints, loopback from aai_cli.errors import APIError @@ -42,7 +44,7 @@ def _require(mapping: Mapping[str, Any], key: str, what: str) -> Any: def _open_browser(url: str) -> None: """Open the system browser, falling back to printing the URL.""" - output.console.print(f"Opening your browser to sign in:\n {url}") + output.console.print(f"Opening your browser to sign in:\n [aai.url]{escape(url)}[/aai.url]") try: webbrowser.open(url) except Exception: # noqa: BLE001 - opening a browser is best-effort diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 507cdc83..2e546662 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -1,26 +1,43 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from typing import Any import typer from rich.markup import escape from rich.table import Table -from aai_cli import output +from aai_cli import help_panels, output from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.errors import UsageError from aai_cli.help_text import examples_epilog + +def _utc_day_start(day: str) -> str: + """Render a ``YYYY-MM-DD`` date as a tz-aware UTC ISO-8601 timestamp. + + The AMS billing endpoint compares the bounds against tz-aware datetimes and + rejects naive ones ("can't compare offset-naive and offset-aware datetimes"), + so the wire value always carries an explicit ``+00:00`` offset. + """ + try: + parsed = date.fromisoformat(day) + except ValueError as exc: + raise UsageError(f"Invalid date {day!r}; expected YYYY-MM-DD.") from exc + return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC).isoformat() + + app = typer.Typer(help="Account billing, usage, and limits.") @app.command( + rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Show your remaining balance", "aai balance"), ] - ) + ), ) def balance( ctx: typer.Context, @@ -42,12 +59,13 @@ def body(state: AppState, json_mode: bool) -> None: @app.command( + rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Usage over the last 30 days", "aai usage"), ("A specific date range", "aai usage --start 2026-05-01 --end 2026-06-01"), ] - ) + ), ) def usage( ctx: typer.Context, @@ -61,8 +79,8 @@ def usage( def body(state: AppState, json_mode: bool) -> None: _, jwt = resolve_session(state) today = datetime.now(UTC).date() - end_date = end or today.isoformat() - start_date = start or (today - timedelta(days=30)).isoformat() + start_date = _utc_day_start(start or (today - timedelta(days=30)).isoformat()) + end_date = _utc_day_start(end or today.isoformat()) data = ams.get_usage(jwt, start_date, end_date, window) def render(d: dict[str, Any]) -> Table: @@ -81,11 +99,12 @@ def render(d: dict[str, Any]) -> Table: @app.command( + rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Show rate limits per service", "aai limits"), ] - ) + ), ) def limits( ctx: typer.Context, diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 5846c8ba..d58f08ac 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -6,7 +6,7 @@ import typer -from aai_cli import client, code_gen, config, output +from aai_cli import client, code_gen, config, help_panels, output from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from aai_cli.agent.render import AgentRenderer from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session @@ -20,6 +20,7 @@ @app.command( + rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ ("Start a live voice conversation", "aai agent"), @@ -27,7 +28,7 @@ ("See available voices", "aai agent --list-voices"), ("Print equivalent Python instead of running", "aai agent --show-code"), ] - ) + ), ) def agent( ctx: typer.Context, diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index b4bc4b39..6fb2b4eb 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.table import Table -from aai_cli import output +from aai_cli import help_panels, output from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.help_text import examples_epilog @@ -13,12 +13,13 @@ @app.command( + rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Recent audit-log entries", "aai audit --limit 20"), ("Filter by action", "aai audit --action token.create"), ] - ) + ), ) def audit( ctx: typer.Context, diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index abd62613..d7e53437 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -7,7 +7,7 @@ import typer from rich.markup import escape -from aai_cli import client, config, output +from aai_cli import client, config, help_panels, output, theme from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import CLIError, NotAuthenticated from aai_cli.help_text import examples_epilog @@ -25,8 +25,13 @@ class Check(TypedDict): fix: str | None -# Status -> render style. "fail" is a blocker; "warn" is degraded-but-usable. -_STYLE = {"ok": "aai.success", "warn": "aai.warn", "fail": "aai.error"} +# Status -> (affordance symbol, render style). "fail" is a blocker; "warn" is +# degraded-but-usable. Drives the per-check glyph in `_render`. +_SYMBOL = { + "ok": (theme.SYMBOL_SUCCESS, "aai.success"), + "warn": (theme.SYMBOL_WARN, "aai.warn"), + "fail": (theme.SYMBOL_ERROR, "aai.error"), +} def _check_python() -> Check: @@ -172,28 +177,30 @@ def _check_coding_agent() -> Check: def _render(data: dict[str, object]) -> str: checks: list[Check] = data["checks"] # type: ignore[assignment] - lines = ["[aai.heading]AssemblyAI environment check:[/aai.heading]"] + lines = [output.heading("Environment check")] for c in checks: - style = _STYLE.get(c["status"], "aai.muted") + symbol, style = _SYMBOL.get(c["status"], (theme.SYMBOL_HINT, "aai.muted")) lines.append( - f" {escape(c['name'])}: " - f"[{style}]{escape(c['status'])}[/{style}] — {escape(c['detail'])}" + f" [{style}]{escape(symbol)}[/{style}] {escape(c['name'])} — {escape(c['detail'])}" ) if c["fix"]: - lines.append(f" [aai.muted]fix:[/aai.muted] {escape(c['fix'])}") + lines.append(" " + output.hint(f"fix: {escape(c['fix'])}")) if data["ok"]: - lines.append(" [aai.success]Ready.[/aai.success]") + lines.append(" " + output.success("Everything looks good.")) else: - lines.append(" [aai.error]Problems found — see fixes above.[/aai.error]") + failed = sum(1 for c in checks if c["status"] == "fail") + noun = "problem" if failed == 1 else "problems" + lines.append(" " + output.fail(f"{failed} {noun} found — see fixes above.")) return "\n".join(lines) @app.command( + rich_help_panel=help_panels.SETUP, epilog=examples_epilog( [ ("Check your environment is ready", "aai doctor"), ] - ) + ), ) def doctor( ctx: typer.Context, diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index d64a903a..97607054 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -7,7 +7,7 @@ import typer from rich.markup import escape -from aai_cli import __version__, environments, output, steps +from aai_cli import __version__, environments, help_panels, output, steps from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError from aai_cli.help_text import examples_epilog @@ -60,13 +60,14 @@ def _resolve_dir(directory: str | None, template: str, *, here: bool) -> Path: @app.command( + rich_help_panel=help_panels.QUICK_START, epilog=examples_epilog( [ ("Scaffold a new app interactively", "aai init"), ("Scaffold a transcribe app into ./my-app", "aai init transcribe my-app"), ("Scaffold into the current directory", "aai init transcribe --here"), ] - ) + ), ) def init( ctx: typer.Context, @@ -177,9 +178,7 @@ def body(state: AppState, json_mode: bool) -> None: } ) - output.emit( - report, lambda d: steps.render_steps(d, heading="aai init:"), json_mode=json_mode - ) + output.emit(report, lambda d: steps.render_steps(d, heading="Setup"), json_mode=json_mode) if any(s["status"] == "failed" for s in report): raise typer.Exit(code=1) @@ -188,12 +187,19 @@ def body(state: AppState, json_mode: bool) -> None: url = f"http://localhost:{chosen_port}" if not json_mode: output.console.print( - f"[aai.heading]Starting[/aai.heading] {escape(url)} (Ctrl-C to stop)" + f"[aai.heading]Starting[/aai.heading] [aai.url]{escape(url)}[/aai.url]" + " [aai.muted](Ctrl-C to stop)[/aai.muted]" ) code = runner.launch_and_open( target, port=chosen_port, use_uv=use_uv, open_browser=not no_open ) if code: raise typer.Exit(code=code) + elif not json_mode: + # Scaffolded but not launched (no key, or --no-install): leave the user with + # the one command that starts their app, the way `vercel`/`supabase` sign off. + output.console.print( + output.hint(f"Run `cd {escape(str(target))} && uv run uvicorn api.index:app`.") + ) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index a36dc316..54fd2850 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -92,8 +92,9 @@ def body(state: AppState, json_mode: bool) -> None: output.emit( created, lambda d: ( - f"Created key '[aai.success]{escape(name)}[/aai.success]': " - f"{escape(str(d['api_key']))}" + output.success(f"Created API key '{escape(name)}'.") + + f"\n {escape(str(d['api_key']))}\n" + + output.warn("Shown once — copy it now.") ), json_mode=json_mode, ) diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index 6851b8d3..a9407429 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -3,7 +3,7 @@ import typer from rich.markup import escape -from aai_cli import config, output, stdio +from aai_cli import config, help_panels, output, stdio from aai_cli import llm as gateway from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError @@ -14,13 +14,14 @@ @app.command( + rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ ("Summarize a past transcript", 'aai llm "summarize" --transcript-id 5551234-abcd'), ("Pipe any text in", 'echo "meeting notes" | aai llm "turn into action items"'), ("See available models", "aai llm --list-models"), ] - ) + ), ) def llm( ctx: typer.Context, diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 517b7ab1..7b172bbe 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -2,8 +2,9 @@ import typer from rich.markup import escape +from rich.table import Table -from aai_cli import client, config, environments, output +from aai_cli import client, config, environments, help_panels, output from aai_cli.auth import run_login_flow from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import APIError, NotAuthenticated @@ -13,12 +14,13 @@ @app.command( + rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Log in with your browser", "aai login"), ("Log in non-interactively (CI)", "aai login --api-key sk_..."), ] - ) + ), ) def login( ctx: typer.Context, @@ -57,8 +59,9 @@ def body(state: AppState, json_mode: bool) -> None: output.emit( {"authenticated": True, "profile": profile, "env": env}, lambda _d: ( - f"[aai.success]Authenticated[/aai.success] on profile " - f"'{escape(profile)}' (env: {escape(env)})." + output.success(f"Signed in as {escape(profile)} ({escape(env)}).") + + "\n" + + output.hint("Run `aai transcribe ` to make your first transcript.") ), json_mode=json_mode, ) @@ -67,11 +70,12 @@ def body(state: AppState, json_mode: bool) -> None: @app.command( + rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Clear stored credentials for the active profile", "aai logout"), ] - ) + ), ) def logout( ctx: typer.Context, @@ -85,7 +89,11 @@ def body(state: AppState, json_mode: bool) -> None: config.clear_session(profile) output.emit( {"logged_out": True, "profile": profile}, - lambda _d: f"Logged out of profile '{escape(profile)}'.", + lambda _d: ( + output.success(f"Signed out of {escape(profile)}.") + + "\n" + + output.hint("Run `aai login` to sign back in.") + ), json_mode=json_mode, ) @@ -93,11 +101,12 @@ def body(state: AppState, json_mode: bool) -> None: @app.command( + rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Show the active profile and whether its key works", "aai whoami"), ] - ) + ), ) def whoami( ctx: typer.Context, @@ -115,21 +124,30 @@ def body(state: AppState, json_mode: bool) -> None: reachable = client.validate_key(key) session_label = "stored" if config.get_session(profile) else "none" account_id = config.get_account_id(profile) - output.emit( - { - "profile": profile, - "env": env, - "api_key": masked, - "reachable": reachable, - "account_id": account_id, - "session": session_label, - }, - lambda _d: ( - f"profile={escape(profile)} env={escape(env)} " - f"key={escape(masked)} reachable={reachable} " - f"account={account_id} session={session_label}" - ), - json_mode=json_mode, - ) + + def render(_d: dict[str, object]) -> Table: + table = Table.grid(padding=(0, 3)) + table.add_column(style="aai.muted") + table.add_column() + table.add_row("Profile", escape(profile)) + table.add_row("Env", escape(env)) + table.add_row("API key", escape(masked)) + table.add_row( + "Status", + output.success("reachable") if reachable else output.fail("key rejected"), + ) + table.add_row("Account", escape(str(account_id)) if account_id else "—") + table.add_row("Session", escape(session_label)) + return table + + data: dict[str, object] = { + "profile": profile, + "env": env, + "api_key": masked, + "reachable": reachable, + "account_id": account_id, + "session": session_label, + } + output.emit(data, render, json_mode=json_mode) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 6cdd51ea..8705221b 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -6,7 +6,7 @@ import typer from assemblyai.streaming.v3 import SpeechModel -from aai_cli import client, code_gen, config, config_builder, llm, output, youtube +from aai_cli import client, code_gen, config, config_builder, help_panels, llm, output, youtube from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.follow import FollowRenderer @@ -17,10 +17,11 @@ app = typer.Typer() -DEFAULT_SPEECH_MODEL = SpeechModel.universal_streaming_multilingual.value +DEFAULT_SPEECH_MODEL = SpeechModel.u3_rt_pro.value @app.command( + rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ ("Stream from your microphone", "aai stream"), @@ -31,7 +32,7 @@ ), ("Print equivalent Python instead of running", "aai stream --show-code"), ] - ) + ), ) def stream( ctx: typer.Context, @@ -120,7 +121,7 @@ def stream( help="Print the equivalent Python SDK code and exit (does not stream).", ), ) -> None: - """Transcribe live audio in real time with the full StreamingParameters surface. + """Transcribe live audio in real time — from your mic, a file, or a URL. --prompt biases the speech model. --llm-gateway-prompt transforms the full transcript through LLM Gateway once the stream ends (e.g. "summarize the call"). diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index fdd2be6d..4e9e910d 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -11,6 +11,7 @@ code_gen, config, config_builder, + help_panels, llm, output, stdio, @@ -33,6 +34,7 @@ def _render_transform_steps(d: dict[str, Any]) -> str: @app.command( + rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ ("Transcribe a local file", "aai transcribe call.mp3"), @@ -44,7 +46,7 @@ def _render_transform_steps(d: dict[str, Any]) -> str: ("Get just the text for a pipeline", "aai transcribe call.mp3 -o text"), ("Print equivalent Python instead of running", "aai transcribe call.mp3 --show-code"), ] - ) + ), ) def transcribe( ctx: typer.Context, @@ -146,7 +148,7 @@ def transcribe( help="Print the equivalent Python SDK code and exit (does not transcribe).", ), ) -> None: - """Transcribe an audio file, URL, or YouTube URL with the full TranscriptionConfig surface. + """Transcribe an audio file, URL, or YouTube link. A YouTube URL is downloaded first, then transcribed. Curated flags cover common features; --config KEY=VALUE and --config-file reach every other field. Analysis diff --git a/aai_cli/environments.py b/aai_cli/environments.py index fa6044b7..72aa0ddf 100644 --- a/aai_cli/environments.py +++ b/aai_cli/environments.py @@ -17,6 +17,7 @@ class Environment: name: str api_base: str # SDK base_url for /v2/upload + /v2/transcript streaming_host: str # StreamingClientOptions.api_host (SDK builds wss://host/v3/ws) + agents_host: str # Voice Agent host; the agent client builds wss://host/v1/ws llm_gateway_base: str # OpenAI base_url for the LLM Gateway (…/v1) ams_base: str # Accounts Management Service stytch_domain: str # Stytch API domain for B2B OAuth discovery @@ -29,6 +30,7 @@ class Environment: name="production", api_base="https://api.assemblyai.com", streaming_host="streaming.assemblyai.com", + agents_host="agents.assemblyai.com", llm_gateway_base="https://llm-gateway.assemblyai.com/v1", # NOTE: production AMS + Stytch are not provisioned yet — the values below are # placeholders (see the REPLACE_ME token), which is why DEFAULT_ENV stays @@ -42,6 +44,7 @@ class Environment: name="sandbox000", api_base="https://api.sandbox000.assemblyai-labs.com", streaming_host="streaming.sandbox000.assemblyai-labs.com", + agents_host="agents.sandbox000.assemblyai-labs.com", llm_gateway_base="https://llm-gateway.sandbox000.assemblyai-labs.com/v1", ams_base="https://ams.sandbox000.assemblyai-labs.com", stytch_domain="https://test.stytch.com", diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py new file mode 100644 index 00000000..8486bd89 --- /dev/null +++ b/aai_cli/help_panels.py @@ -0,0 +1,19 @@ +"""Named help panels for `aai --help`. + +Rich groups top-level commands under these headings (via each command's +``rich_help_panel``), so the root help reads as a journey rather than a flat +list — the same approach the Vercel and Supabase CLIs take. Panels render in +the order their first command appears (see ``_COMMAND_ORDER`` in ``main.py``); +most-used commands first, account/setup last. + +Centralized here so the heading strings have one source of truth — a typo in a +decorator would otherwise silently spawn a duplicate panel. +""" + +from __future__ import annotations + +QUICK_START = "Quick Start" # zero-to-running onboarding: init +TRANSCRIPTION = "Transcription & AI" # the verbs you run: transcribe, stream, agent, llm +HISTORY = "History" # browse past work: transcripts, sessions +ACCOUNT = "Account" # auth, billing, keys: login/logout/whoami, balance/usage/limits, keys, audit +SETUP = "Setup & Tools" # get set up & maintain: samples, doctor, claude, version diff --git a/aai_cli/init/templates/agent/api/index.py b/aai_cli/init/templates/agent/api/index.py index 452d0488..643a3e3d 100644 --- a/aai_cli/init/templates/agent/api/index.py +++ b/aai_cli/init/templates/agent/api/index.py @@ -12,7 +12,7 @@ import os from pathlib import Path -import httpx +import httpx2 from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse @@ -36,7 +36,7 @@ def token() -> dict[str, str]: """Mint a one-time Voice Agent token. The browser uses it to open the WebSocket.""" # NOTE: the Voice Agent token uses Bearer auth (unlike the streaming token). try: - resp = httpx.get( + resp = httpx2.get( f"https://{AGENTS_HOST}/v1/token", params={"expires_in_seconds": 60}, headers={"Authorization": f"Bearer {API_KEY}"}, diff --git a/aai_cli/init/templates/agent/requirements.txt b/aai_cli/init/templates/agent/requirements.txt index 3263361e..57a6fd76 100644 --- a/aai_cli/init/templates/agent/requirements.txt +++ b/aai_cli/init/templates/agent/requirements.txt @@ -1,4 +1,4 @@ fastapi uvicorn -httpx +httpx2 python-dotenv diff --git a/aai_cli/init/templates/stream/api/index.py b/aai_cli/init/templates/stream/api/index.py index fc12a7fd..34d396e2 100644 --- a/aai_cli/init/templates/stream/api/index.py +++ b/aai_cli/init/templates/stream/api/index.py @@ -12,7 +12,7 @@ import os from pathlib import Path -import httpx +import httpx2 from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse @@ -36,7 +36,7 @@ def token() -> dict[str, str]: """Mint a one-time streaming token. The browser uses it to open the WebSocket.""" # NOTE: the streaming token uses the raw API key as Authorization (no 'Bearer'). try: - resp = httpx.get( + resp = httpx2.get( f"https://{STREAMING_HOST}/v3/token", params={"expires_in_seconds": 60}, headers={"Authorization": API_KEY}, diff --git a/aai_cli/init/templates/stream/requirements.txt b/aai_cli/init/templates/stream/requirements.txt index 3263361e..57a6fd76 100644 --- a/aai_cli/init/templates/stream/requirements.txt +++ b/aai_cli/init/templates/stream/requirements.txt @@ -1,4 +1,4 @@ fastapi uvicorn -httpx +httpx2 python-dotenv diff --git a/aai_cli/main.py b/aai_cli/main.py index d3973ccf..2eefbd23 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -11,7 +11,7 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from aai_cli import __version__, environments, stdio +from aai_cli import __version__, environments, help_panels, stdio from aai_cli.commands import ( account, agent, @@ -30,30 +30,37 @@ ) from aai_cli.context import AppState, env_override_warning, resolve_environment from aai_cli.errors import CLIError +from aai_cli.help_text import examples_epilog -# The order commands appear under `aai --help`: core transcription first, then -# voice/LLM, then account, then tooling, with `version` last. Names not listed -# fall to the end, sorted alphabetically. +# The order commands appear under `aai --help`. Commands are grouped into named +# Rich panels (see `help_panels.py`); panels render in the order their first +# command appears here, so keep each panel's commands contiguous and ordered +# most-common-first. Names not listed fall to the end, sorted alphabetically. _COMMAND_ORDER = ( + # Quick Start — zero-to-running onboarding + "init", + # Setup & Tools — get set up & maintain; `version` last + "samples", + "doctor", + "claude", + "version", + # Transcription & AI — the verbs you run "transcribe", "stream", - "transcripts", - "sessions", "agent", "llm", + # History — browse past work + "transcripts", + "sessions", + # Account — auth, then billing, then keys + "login", + "logout", + "whoami", "balance", "usage", "limits", "keys", "audit", - "login", - "logout", - "whoami", - "doctor", - "samples", - "init", - "claude", - "version", ) @@ -73,14 +80,22 @@ def list_commands(self, ctx: ClickContext) -> list[str]: app = typer.Typer( name="aai", - help="Command-line interface for AssemblyAI.", + help="AssemblyAI from your terminal — transcribe, stream, and build voice AI.", no_args_is_help=True, add_completion=False, cls=_OrderedGroup, ) -@app.callback() +@app.callback( + epilog=examples_epilog( + [ + ("Sign in with your browser", "aai login"), + ("Transcribe a file", "aai transcribe call.mp3"), + ("Scaffold a starter app", "aai init"), + ] + ) +) def main( ctx: typer.Context, profile: str = typer.Option(None, "--profile", "-p", help="Named credential profile."), @@ -101,26 +116,27 @@ def main( typer.echo(warning, err=True) -# Commands are registered in the order they should appear in `aai --help`: -# core transcription first, then voice/LLM, then account, then tooling. `version` -# is defined last so it sorts to the bottom (registration order is preserved). +# Help-panel grouping: named sub-typers carry their panel on `add_typer`; merged +# (nameless) sub-typers don't propagate it, so those commands set `rich_help_panel` +# on their own `@app.command()` (see each command module). Final ordering within a +# panel is controlled by `_COMMAND_ORDER` via `_OrderedGroup`, not registration order. app.add_typer(transcribe.app) app.add_typer(stream.app) -app.add_typer(transcripts.app, name="transcripts") -app.add_typer(sessions.app, name="sessions") +app.add_typer(transcripts.app, name="transcripts", rich_help_panel=help_panels.HISTORY) +app.add_typer(sessions.app, name="sessions", rich_help_panel=help_panels.HISTORY) app.add_typer(audit.app) # audit app.add_typer(agent.app) app.add_typer(llm.app) app.add_typer(account.app) # balance, usage, limits app.add_typer(login.app) # login, logout, whoami app.add_typer(doctor.app) -app.add_typer(samples.app, name="samples") +app.add_typer(samples.app, name="samples", rich_help_panel=help_panels.SETUP) app.add_typer(init.app) -app.add_typer(claude.app, name="claude") -app.add_typer(keys.app, name="keys") +app.add_typer(claude.app, name="claude", rich_help_panel=help_panels.SETUP) +app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) -@app.command() +@app.command(rich_help_panel=help_panels.SETUP) def version() -> None: """Show the CLI version.""" typer.echo(__version__) diff --git a/aai_cli/output.py b/aai_cli/output.py index 8431c14d..55662d6c 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -61,6 +61,36 @@ def mask_secret(value: str) -> str: return f"{value[:3]}…{value[-4:]}" if len(value) > 7 else "***" +def success(text: str) -> str: + """A success line — green ``✓`` + message — as a Rich-markup string. + + Helpers here return markup for a human renderer to print; they do NOT escape + interpolated values, so callers escape any dynamic text (matching the inline + ``escape(...)`` convention used throughout the command layer). + """ + return f"[aai.success]{theme.SYMBOL_SUCCESS}[/aai.success] {text}" + + +def fail(text: str) -> str: + """A failure line: red ``✗`` + message (for inline status, not the error path).""" + return f"[aai.error]{theme.SYMBOL_ERROR}[/aai.error] {text}" + + +def warn(text: str) -> str: + """A warning line: yellow ``!`` + message.""" + return f"[aai.warn]{theme.SYMBOL_WARN}[/aai.warn] {text}" + + +def hint(text: str) -> str: + """A dim next-step hint, prefixed with the hint glyph to point at what's next.""" + return f"[aai.muted]{theme.SYMBOL_HINT} {text}[/aai.muted]" + + +def heading(text: str) -> str: + """A section heading in the brand accent — the one voice for multi-line output.""" + return f"[aai.heading]{text}[/aai.heading]" + + def emit(data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> None: if json_mode: print(json.dumps(data, default=str)) diff --git a/aai_cli/theme.py b/aai_cli/theme.py index b29847cc..16206db5 100644 --- a/aai_cli/theme.py +++ b/aai_cli/theme.py @@ -8,6 +8,15 @@ # AssemblyAI brand accent. Defined once so the whole CLI can be re-tinted here. BRAND = "#2545D3" +# A fixed affordance vocabulary used across all human-facing output, mirroring the +# Vercel/Supabase convention of a small, consistent status alphabet: one glyph per +# meaning so a user learns it once. Unicode (the CLI already prints …/—); Rich +# re-encodes for terminals that can't render it. +SYMBOL_SUCCESS = "✓" +SYMBOL_ERROR = "✗" +SYMBOL_WARN = "!" +SYMBOL_HINT = "›" # noqa: RUF001 — deliberate angle-quote glyph, not a '>' typo + # Per-speaker label colors, rotated deterministically by speaker_style(). SPEAKER_STYLES: tuple[str, ...] = ( "aai.speaker.0", @@ -26,7 +35,13 @@ # distinct hue so "you:" and "agent:" are easy to tell apart at a glance. "aai.you": BRAND, "aai.agent": "cyan", - "aai.success": "green", + # Links/URLs in cyan, the convention both the Vercel and Supabase CLIs use so + # a clickable target stands out from prose without shouting. + "aai.url": "cyan", + # Semantic status colors. Success is bold so the ✓ reads as a confident "done" + # (Supabase-style); error/warn follow the universal red/yellow; muted secondary + # text stays dim so it recedes (the Vercel "quiet by default" look). + "aai.success": "bold green", "aai.error": "bold red", "aai.warn": "yellow", "aai.muted": "dim", diff --git a/pyproject.toml b/pyproject.toml index b8465ed5..947508f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,12 @@ dependencies = [ "assemblyai>=0.64.4", "rich>=15.0.0", "keyring>=25.7.0", - "httpx>=0.28.1", + # httpx2 is Pydantic's maintained fork of httpx (github.com/pydantic/httpx2, + # forked from httpx 0.28.1, API-identical bar the package rename). The exact + # version is pinned in uv.lock; safe-chain's minimum-package-age check governs + # which releases the lock may adopt, so no manual version cap is needed here. + # (Plain httpx still arrives transitively via the assemblyai SDK.) + "httpx2>=2.0.0", "platformdirs>=4.10.0", "tomli-w>=1.2.0", "websockets>=16.0", @@ -69,7 +74,6 @@ dev = [ "python-dotenv>=1.0.0", "python-multipart>=0.0.9", "syrupy>=4.0", - "numpy>=2.0.0", # e2e tests resample kokoro audio (tests/e2e/test_cli_e2e.py) "xenon>=0.9.3", "diff-cover>=9.0.0", ] @@ -94,12 +98,9 @@ testpaths = ["tests"] # the targeted ignores below override the blanket `error`. filterwarnings = [ "error", - # Starlette's TestClient warns that it rides on httpx (not the unreleased httpx2) - # when the `aai init` template apps are exercised; we don't control this. - "ignore:Using .httpx. with .starlette.testclient. is deprecated:", ] markers = [ - "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY + kokoro; skip otherwise)", + "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY; skip otherwise)", "install_script: real install via install.sh from a locally-built wheel; asserts `aai` runs (slow; needs network + uv/pipx; skip otherwise)", "install: install each init template's requirements.txt into a clean venv and import it (slow; needs network + uv; skip otherwise)", ] diff --git a/scripts/check.sh b/scripts/check.sh index 16fdb795..7b8de961 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -47,9 +47,9 @@ fi echo "==> pytest (with branch-coverage gate)" # Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need -# a live API key + kokoro. Exclude install (real per-template dep install, slow + -# network) and install_script (builds a wheel and runs install.sh for real; slow, -# needs network + uv/pipx). All are uncounted by coverage. Run them with: +# a live API key. Exclude install (real per-template dep install, slow + network) +# and install_script (builds a wheel and runs install.sh for real; slow, needs +# network + uv/pipx). All are uncounted by coverage. Run them with: # uv run pytest -m e2e # uv run pytest -m install # uv run pytest -m install_script diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 7bca0a3f..4adbc48e 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -504,7 +504,7 @@ Usage: aai stream [OPTIONS] [SOURCE] - Transcribe live audio in real time with the full StreamingParameters surface. + Transcribe live audio in real time — from your mic, a file, or a URL. --prompt biases the speech model. --llm-gateway-prompt transforms the full transcript through LLM Gateway once the stream ends (e.g. "summarize the @@ -526,8 +526,7 @@ │ index. │ │ --speech-model TEXT Streaming speech │ │ model. │ - │ [default: │ - │ universal-streaming… │ + │ [default: u3-rt-pro] │ │ --encoding TEXT pcm_s16le or │ │ pcm_mulaw. │ │ --language-detection Auto-detect the │ @@ -622,8 +621,7 @@ Usage: aai transcribe [OPTIONS] [SOURCE] - Transcribe an audio file, URL, or YouTube URL with the full - TranscriptionConfig surface. + Transcribe an audio file, URL, or YouTube link. A YouTube URL is downloaded first, then transcribed. Curated flags cover common diff --git a/tests/e2e/fixtures/fox.wav b/tests/e2e/fixtures/fox.wav new file mode 100644 index 00000000..8e6c6358 Binary files /dev/null and b/tests/e2e/fixtures/fox.wav differ diff --git a/tests/e2e/fixtures/hello.wav b/tests/e2e/fixtures/hello.wav new file mode 100644 index 00000000..7dd1b71b Binary files /dev/null and b/tests/e2e/fixtures/hello.wav differ diff --git a/tests/e2e/test_cli_e2e.py b/tests/e2e/test_cli_e2e.py index df82b28d..0dce476e 100644 --- a/tests/e2e/test_cli_e2e.py +++ b/tests/e2e/test_cli_e2e.py @@ -1,12 +1,17 @@ """End-to-end tests that drive the real `aai` CLI against the live AssemblyAI API. -Speech is synthesized locally with kokoro TTS, then fed through the CLI as a -subprocess so the binary, argument parsing, auth, audio decoding, and network -path are all exercised for real — no mocks. - -These tests are marked `e2e` and skip (never fail) when the API key, kokoro, or -numpy is unavailable, so CI and keyless contributors are not blocked. The -precommit `pytest-e2e` hook runs them; the default unit run excludes them. +Committed speech WAV fixtures are fed through the CLI as a subprocess so the +binary, argument parsing, auth, audio decoding, and network path are all +exercised for real — no mocks. Batch tests reuse the hosted ``--sample`` clip +(wildfires.mp3). + +These tests are marked `e2e` and skip (never fail) when the API key is +unavailable, so CI and keyless contributors are not blocked. The precommit +`pytest-e2e` hook runs them; the default unit run excludes them. + +Coverage: batch transcribe (plain + summarization, auto-chapters, sentiment, +diarization, LLM transform), live streaming, the voice agent, the LLM command, +the transcripts list/get roundtrip, `doctor`, and the auth-failure path. """ from __future__ import annotations @@ -15,7 +20,6 @@ import os import subprocess import sys -import wave from pathlib import Path from typing import Any @@ -23,48 +27,12 @@ pytestmark = pytest.mark.e2e -KOKORO_RATE = 24000 # kokoro emits 24 kHz float32 mono -STREAM_RATE = 16000 # what the CLI's fast WAV path expects (16 kHz mono PCM16) - - -@pytest.fixture(scope="session") -def kokoro_pipeline() -> Any: - """Build the kokoro TTS pipeline once per session, or skip if unavailable.""" - pytest.importorskip("numpy") - kokoro = pytest.importorskip("kokoro") - return kokoro.KPipeline(lang_code="a") # American English - - -def _synthesize_wav(pipeline: Any, text: str, path: Path, *, lead_silence_s: float = 0.6) -> Path: - """Synthesize `text` to a 16 kHz mono PCM16 WAV the CLI can stream directly. +FIXTURES = Path(__file__).resolve().with_name("fixtures") +FOX_WAV = FIXTURES / "fox.wav" +HELLO_WAV = FIXTURES / "hello.wav" - Resamples kokoro's 24 kHz output to 16 kHz (linear) and prepends a short - silence so nothing is clipped before the realtime session is ready. - """ - import numpy as np - - chunks = [] - for _gs, _ps, audio in pipeline(text, voice="af_heart"): - arr = audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio) - chunks.append(np.asarray(arr, dtype=np.float32).reshape(-1)) - samples = np.concatenate(chunks) - - n_dst = round(len(samples) * STREAM_RATE / KOKORO_RATE) - resampled = np.interp( - np.linspace(0.0, len(samples) - 1, n_dst), - np.arange(len(samples)), - samples, - ) - pcm = (np.clip(resampled, -1.0, 1.0) * 32767.0).astype(" subprocess.CompletedProcess[str]: @@ -84,11 +52,73 @@ def _ndjson(stdout: str) -> list[dict[str, Any]]: return [json.loads(line) for line in stdout.splitlines() if line.strip()] -def test_stream_file_transcribes_spoken_text(real_api_key, kokoro_pipeline, tmp_path): - spoken = "the quick brown fox jumps over the lazy dog" - wav = _synthesize_wav(kokoro_pipeline, spoken, tmp_path / "fox.wav") +def _transcribe_sample(key: str, *flags: str, timeout: int = 180) -> dict[str, Any]: + """Transcribe the hosted sample with `flags`, asserting success, return JSON.""" + proc = _run_cli(["transcribe", "--sample", *flags, "--json"], key, timeout=timeout) + assert proc.returncode == 0, f"args={flags} stderr:\n{proc.stderr}" + return json.loads(proc.stdout) # type: ignore[no-any-return] + + +# --- Batch transcription -------------------------------------------------- + + +def test_transcribe_sample_basic(real_api_key): + data = _transcribe_sample(real_api_key) + assert data["status"] == "completed", data + text = data["text"].lower() + assert text.strip(), f"no transcript text: {data!r}" + for word in SAMPLE_WORDS: + assert word in text, f"{word!r} missing from transcript: {text[:200]!r}" + assert data["words"], "expected word-level timestamps" + assert data["audio_duration"] > 0 + + +def test_transcribe_summarization(real_api_key): + # summarization and auto_chapters are mutually exclusive on the API, so each + # analysis feature gets its own run. + data = _transcribe_sample(real_api_key, "--summarization") + assert data["summary"] and data["summary"].strip(), f"no summary: {data!r}" + + +def test_transcribe_auto_chapters(real_api_key): + data = _transcribe_sample(real_api_key, "--auto-chapters") + chapters = data["chapters"] + assert chapters, f"no chapters: {data!r}" + assert all(c.get("summary") for c in chapters), f"chapter missing summary: {chapters!r}" + + +def test_transcribe_sentiment_analysis(real_api_key): + data = _transcribe_sample(real_api_key, "--sentiment-analysis") + results = data["sentiment_analysis_results"] + assert results, f"no sentiment results: {data!r}" + sentiments = {r["sentiment"] for r in results} + assert sentiments <= {"POSITIVE", "NEGATIVE", "NEUTRAL"}, sentiments + - proc = _run_cli(["stream", str(wav), "--json"], real_api_key) +def test_transcribe_speaker_labels(real_api_key): + data = _transcribe_sample(real_api_key, "--speaker-labels") + utterances = data["utterances"] + assert utterances, f"no utterances: {data!r}" + assert all(u.get("text") for u in utterances), "utterance missing text" + assert all(u.get("speaker") for u in utterances), "utterance missing speaker label" + + +def test_transcribe_prompt_transforms_via_gateway(real_api_key): + data = _transcribe_sample( + real_api_key, "--llm", "Summarize this transcript in one short sentence." + ) + assert data["text"].strip(), f"no transcript produced: {data!r}" + # The LLM transform is {model, steps:[{prompt, output}, ...]}. + steps = data["transform"]["steps"] + assert steps, f"gateway returned no transform steps: {data!r}" + assert steps[0]["output"].strip(), f"transform step had no output: {data!r}" + + +# --- Streaming ------------------------------------------------------------ + + +def test_stream_file_transcribes_spoken_text(real_api_key): + proc = _run_cli(["stream", str(FOX_WAV), "--json"], real_api_key) assert proc.returncode == 0, f"stderr:\n{proc.stderr}" events = _ndjson(proc.stdout) @@ -100,11 +130,30 @@ def test_stream_file_transcribes_spoken_text(real_api_key, kokoro_pipeline, tmp_ assert word in transcript, f"{word!r} missing from streamed transcript: {transcript!r}" -def test_agent_file_gets_reply(real_api_key, kokoro_pipeline, tmp_path): - spoken = "Hi there. Can you say hello back to me in one short sentence?" - wav = _synthesize_wav(kokoro_pipeline, spoken, tmp_path / "hello.wav") +def test_stream_prompt_transforms_live(real_api_key): + proc = _run_cli( + [ + "stream", + str(FOX_WAV), + "--llm", + "Summarize the transcript in one short sentence.", + "--json", + ], + real_api_key, + ) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + events = _ndjson(proc.stdout) + # Live mode re-runs the prompt over the growing transcript, emitting one refresh + # ({"turns": N, "output": ...}) per finalized turn. + refreshes = [e for e in events if e.get("output")] + assert refreshes, f"no live transform refresh came back; events={events}" + - proc = _run_cli(["agent", str(wav), "--json"], real_api_key) +# --- Voice agent ---------------------------------------------------------- + + +def test_agent_file_gets_reply(real_api_key): + proc = _run_cli(["agent", str(HELLO_WAV), "--json"], real_api_key) assert proc.returncode == 0, f"stderr:\n{proc.stderr}" events = _ndjson(proc.stdout) @@ -119,7 +168,7 @@ def test_agent_file_gets_reply(real_api_key, kokoro_pipeline, tmp_path): assert agent_replies, f"agent never replied; events={events}" -# --- LLM Gateway ----------------------------------------------------------- +# --- LLM Gateway ---------------------------------------------------------- def test_llm_command_answers(real_api_key): @@ -131,55 +180,45 @@ def test_llm_command_answers(real_api_key): assert "4" in data["output"], f"unexpected LLM output: {data!r}" -def test_transcribe_prompt_transforms_via_gateway(real_api_key): - proc = _run_cli( - [ - "transcribe", - "--sample", - "--llm", - "Summarize this transcript in one short sentence.", - "--json", - ], - real_api_key, - timeout=180, - ) - assert proc.returncode == 0, f"stderr:\n{proc.stderr}" - data = json.loads(proc.stdout) - assert data["text"].strip(), f"no transcript produced: {data!r}" - assert data["transform"]["output"].strip(), f"gateway returned no transform: {data!r}" +# --- Transcripts list / get ----------------------------------------------- -def test_e2e_transcribe_analysis(real_api_key): - # Drives a full analysis run through the real API using the hosted --sample - # clip, so summarization + auto-chapters are exercised end to end. - proc = _run_cli( - ["transcribe", "--sample", "--summarization", "--auto-chapters", "--json"], - real_api_key, - timeout=180, - ) +def test_transcripts_list_and_get_roundtrip(real_api_key): + # The batch tests above leave completed transcripts; list then fetch one by id. + proc = _run_cli(["transcripts", "list", "--limit", "5", "--json"], real_api_key, timeout=60) assert proc.returncode == 0, f"stderr:\n{proc.stderr}" - payload = json.loads(proc.stdout) - # The full transcript object is returned; at least one analysis field is present. - assert payload.get("summary") or payload.get("chapters"), f"no analysis fields: {payload!r}" + listing = json.loads(proc.stdout) + assert isinstance(listing, list) and listing, f"empty transcripts listing: {listing!r}" + first = listing[0] + assert first["id"] and first["status"], f"listing row missing id/status: {first!r}" + + tid = first["id"] + got = _run_cli(["transcripts", "get", tid, "--json"], real_api_key, timeout=60) + assert got.returncode == 0, f"stderr:\n{got.stderr}" + fetched = json.loads(got.stdout) + assert fetched["id"] == tid, f"id mismatch: asked {tid}, got {fetched!r}" + + +# --- Diagnostics & auth --------------------------------------------------- + +def test_doctor_reports_healthy(real_api_key): + proc = _run_cli(["doctor", "--json"], real_api_key, timeout=60) + assert proc.returncode == 0, f"stderr:\n{proc.stderr}" + report = json.loads(proc.stdout) + assert report["ok"] is True, f"doctor not ok: {report!r}" + checks = {c["name"]: c["status"] for c in report["checks"]} + assert checks.get("api-key") == "ok", f"api-key check not ok: {checks!r}" -def test_stream_prompt_transforms_live(real_api_key, kokoro_pipeline, tmp_path): - spoken = "the quick brown fox jumps over the lazy dog" - wav = _synthesize_wav(kokoro_pipeline, spoken, tmp_path / "fox.wav") +def test_auth_failure_is_clean(real_api_key): + # A rejected key must produce a clean JSON error on stderr (not a traceback), + # leave stdout empty for pipelines, and exit non-zero. proc = _run_cli( - [ - "stream", - str(wav), - "--llm", - "Summarize the transcript in one short sentence.", - "--json", - ], - real_api_key, + ["transcripts", "list", "--json"], "deadbeefdeadbeefdeadbeefdeadbeef", timeout=60 ) - assert proc.returncode == 0, f"stderr:\n{proc.stderr}" - events = _ndjson(proc.stdout) - # Live mode re-runs the prompt over the growing transcript, emitting one refresh - # ({"turns": N, "output": ...}) per finalized turn. - refreshes = [e for e in events if e.get("output")] - assert refreshes, f"no live transform refresh came back; events={events}" + assert proc.returncode != 0, "expected non-zero exit on auth failure" + assert proc.stdout.strip() == "", f"stdout should stay clean: {proc.stdout!r}" + err = json.loads(proc.stderr) + assert err["error"]["type"] == "not_authenticated", f"unexpected error shape: {err!r}" + assert "Traceback" not in proc.stderr diff --git a/tests/test_account_command.py b/tests/test_account_command.py index c2830023..72f3612a 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -54,8 +54,10 @@ def fake_usage(jwt, start, end, window): with patch("aai_cli.commands.account.ams.get_usage", side_effect=fake_usage): result = runner.invoke(app, ["usage", "--json"]) assert result.exit_code == 0 - # both bounds are ISO dates (YYYY-MM-DD), defaulted when not passed - assert len(captured["start"]) == 10 and len(captured["end"]) == 10 + # both bounds are tz-aware UTC ISO-8601 timestamps, defaulted when not passed + # (AMS rejects naive datetimes with a 400). + for bound in (captured["start"], captured["end"]): + assert bound.endswith("+00:00") and "T" in bound, bound data = json.loads(result.output) assert data["usage_items"][0]["total"] == 12.5 @@ -86,7 +88,19 @@ def test_usage_passes_explicit_dates(): ) as get_usage: result = runner.invoke(app, ["usage", "--start", "2026-01-01", "--end", "2026-02-01"]) assert result.exit_code == 0 - get_usage.assert_called_once_with("jwt", "2026-01-01", "2026-02-01", None) + # Dates are normalized to tz-aware UTC timestamps before hitting AMS. + get_usage.assert_called_once_with( + "jwt", "2026-01-01T00:00:00+00:00", "2026-02-01T00:00:00+00:00", None + ) + + +def test_usage_rejects_invalid_date(): + _auth() + with patch("aai_cli.commands.account.ams.get_usage") as get_usage: + result = runner.invoke(app, ["usage", "--start", "not-a-date"]) + assert result.exit_code == 2 + assert "Invalid date" in result.output + get_usage.assert_not_called() def test_limits_renders_services(monkeypatch): diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index 302977d4..cc7804f5 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -405,3 +405,34 @@ def close(self): finals = [c for c in renderer.calls if c[0] == "user_final"] assert ("user_final", "what time is it") in finals assert ("user_final", "SHOULD NOT BE SEEN") not in finals # stopped after the reply + + +def test_run_session_ws_url_follows_active_environment(): + # The Voice Agent socket must target the active environment's host, not a + # hardcoded production URL. Capture the URL connect() is handed, short- + # circuiting with a benign close once we've seen it. + from aai_cli import environments + + seen: dict[str, str] = {} + + def capture(url, **kwargs): + seen["url"] = url + raise _CloseError(1008) + + for env_name, expected in ( + ("sandbox000", "wss://agents.sandbox000.assemblyai-labs.com/v1/ws"), + ("production", "wss://agents.assemblyai.com/v1/ws"), + ): + environments.set_active(environments.get(env_name)) + with pytest.raises(NotAuthenticated): + run_session( + "sk", + renderer=FakeRenderer(), + player=FakePlayer(), + mic=[], + voice="ivy", + system_prompt="x", + greeting="hi", + connect=capture, + ) + assert seen["url"] == expected diff --git a/tests/test_ams_account.py b/tests/test_ams_account.py index 2fc737d9..70c7428a 100644 --- a/tests/test_ams_account.py +++ b/tests/test_ams_account.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 as httpx import pytest from aai_cli.auth import ams diff --git a/tests/test_auth_ams.py b/tests/test_auth_ams.py index 3f1dc4fc..fedcfa42 100644 --- a/tests/test_auth_ams.py +++ b/tests/test_auth_ams.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 as httpx import pytest from aai_cli.auth import ams diff --git a/tests/test_claude_render.py b/tests/test_claude_render.py index b4cc1fb7..695770b4 100644 --- a/tests/test_claude_render.py +++ b/tests/test_claude_render.py @@ -24,5 +24,5 @@ def test_render_steps_colors_status(): out = buf.getvalue() assert "installed" in out assert "failed" in out - assert "\x1b[32m" in out # aai.success (green) → "installed" + assert "\x1b[1;32m" in out # aai.success (bold green) → "installed" assert "\x1b[1;31m" in out # aai.error (bold red) → "failed" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4dafb43a..cabc7753 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -155,7 +155,7 @@ def test_render_ok_payload_shows_ready(): } text = doctor._render(payload) assert "python" in text - assert "Ready." in text + assert "Everything looks good." in text def test_render_problem_payload_shows_fix_and_problem_banner(): @@ -174,4 +174,4 @@ def test_render_problem_payload_shows_fix_and_problem_banner(): text = doctor._render(payload) assert "fix:" in text assert "Run 'aai login'." in text - assert "Problems found" in text + assert "1 problem found" in text diff --git a/tests/test_init_template_agent.py b/tests/test_init_template_agent.py index 2a22bcec..dba6d71b 100644 --- a/tests/test_init_template_agent.py +++ b/tests/test_init_template_agent.py @@ -24,7 +24,7 @@ def _ok_response(token="tok-123"): def test_token_returns_token_and_agent_ws_url(monkeypatch): mod = _load_app() - monkeypatch.setattr(mod.httpx, "get", lambda *a, **k: _ok_response()) + monkeypatch.setattr(mod.httpx2, "get", lambda *a, **k: _ok_response()) resp = TestClient(mod.app).post("/api/token") assert resp.status_code == 200 body = resp.json() @@ -42,7 +42,7 @@ def fake_get(url, params=None, headers=None): captured.update(url=url, headers=headers) return _ok_response() - monkeypatch.setattr(mod.httpx, "get", fake_get) + monkeypatch.setattr(mod.httpx2, "get", fake_get) TestClient(mod.app).post("/api/token") assert captured["headers"]["Authorization"] == "Bearer sk-test" assert "/v1/token" in captured["url"] @@ -61,6 +61,6 @@ def test_token_surfaces_error_as_502(monkeypatch): def boom(*a, **k): raise RuntimeError("network down") - monkeypatch.setattr(mod.httpx, "get", boom) + monkeypatch.setattr(mod.httpx2, "get", boom) resp = TestClient(mod.app).post("/api/token") assert resp.status_code == 502 diff --git a/tests/test_init_template_stream.py b/tests/test_init_template_stream.py index 84a948fa..4f56fb20 100644 --- a/tests/test_init_template_stream.py +++ b/tests/test_init_template_stream.py @@ -24,7 +24,7 @@ def _ok_response(token="tok-123"): def test_token_returns_token_and_streaming_ws_url(monkeypatch): mod = _load_app() - monkeypatch.setattr(mod.httpx, "get", lambda *a, **k: _ok_response()) + monkeypatch.setattr(mod.httpx2, "get", lambda *a, **k: _ok_response()) resp = TestClient(mod.app).post("/api/token") assert resp.status_code == 200 body = resp.json() @@ -42,7 +42,7 @@ def fake_get(url, params=None, headers=None): captured.update(url=url, headers=headers) return _ok_response() - monkeypatch.setattr(mod.httpx, "get", fake_get) + monkeypatch.setattr(mod.httpx2, "get", fake_get) TestClient(mod.app).post("/api/token") assert captured["headers"]["Authorization"] == "sk-test" assert "/v3/token" in captured["url"] @@ -54,6 +54,6 @@ def test_token_surfaces_error_as_502(monkeypatch): def boom(*a, **k): raise RuntimeError("network down") - monkeypatch.setattr(mod.httpx, "get", boom) + monkeypatch.setattr(mod.httpx2, "get", boom) resp = TestClient(mod.app).post("/api/token") assert resp.status_code == 502 diff --git a/tests/test_output.py b/tests/test_output.py index 16799cd2..2afaf923 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -91,6 +91,35 @@ def test_emit_error_no_suggestion_line_when_absent(capsys): assert "Suggestion:" not in captured.err +def test_affordance_helpers_carry_their_symbol(): + from aai_cli import theme + + assert theme.SYMBOL_SUCCESS in output.success("done") + assert theme.SYMBOL_WARN in output.warn("careful") + assert theme.SYMBOL_HINT in output.hint("do this next") + # heading has no glyph, just the brand style wrapper + assert "aai.heading" in output.heading("Section") + + +def test_affordance_helpers_use_resolvable_styles(capsys): + from aai_cli import theme + + # Rendering through the themed console proves the markup parses and the + # aai.* style names resolve (a bad name would raise MissingStyle). + console = theme.make_console(force_terminal=True, color_system="truecolor") + for line in ( + output.success("ok"), + output.warn("hmm"), + output.hint("next"), + output.heading("H"), + ): + console.print(line) + out = capsys.readouterr().out + assert theme.SYMBOL_SUCCESS in out + assert theme.SYMBOL_HINT in out + assert "\x1b[" in out # themed -> ANSI present + + def test_print_code_plain_when_piped(monkeypatch, capsys): monkeypatch.setattr(output, "_is_agentic", lambda: True) output.print_code("import os\nprint(os.getcwd())\n") diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e4a97d55..a56de456 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -39,25 +39,31 @@ def test_help_lists_commands_in_workflow_order(): assert isinstance(cmd, TyperGroup) ctx = cmd.make_context("aai", [], resilient_parsing=True) names = cmd.list_commands(ctx) # the order shown under --help - # Core transcription first, then voice/LLM, account, tooling, version last. + # Grouped into Rich help panels (see help_panels.py): Quick Start, + # Setup & Tools, Transcription & AI, History, then Account. assert names == [ + # Quick Start + "init", + # Setup & Tools + "samples", + "doctor", + "claude", + "version", + # Transcription & AI "transcribe", "stream", - "transcripts", - "sessions", "agent", "llm", + # History + "transcripts", + "sessions", + # Account + "login", + "logout", + "whoami", "balance", "usage", "limits", "keys", "audit", - "login", - "logout", - "whoami", - "doctor", - "samples", - "init", - "claude", - "version", ] diff --git a/tests/test_theme.py b/tests/test_theme.py index b5724604..94666b38 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -10,6 +10,7 @@ def test_make_console_resolves_named_styles(): "aai.brand", "aai.heading", "aai.label", + "aai.url", "aai.success", "aai.error", "aai.warn", diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 12fc0724..ea539d85 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -106,5 +106,5 @@ def test_list_table_colors_status(monkeypatch): assert result.exit_code == 0 assert "completed" in result.output assert "error" in result.output - assert "\x1b[32m" in result.output # aai.success (green) → "completed" cell + assert "\x1b[1;32m" in result.output # aai.success (bold green) → "completed" cell assert "\x1b[1;31m" in result.output # aai.error (bold red) → "error" cell diff --git a/uv.lock b/uv.lock index 6634984b..7ce15bb7 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,7 @@ source = { editable = "." } dependencies = [ { name = "assemblyai" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "keyring" }, { name = "openai" }, { name = "platformdirs" }, @@ -34,7 +34,6 @@ dev = [ { name = "fastapi" }, { name = "hypothesis" }, { name = "mypy" }, - { name = "numpy" }, { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, @@ -51,7 +50,7 @@ dev = [ requires-dist = [ { name = "assemblyai", specifier = ">=0.64.4" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2" }, - { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx2", specifier = ">=2.0.0" }, { name = "keyring", specifier = ">=25.7.0" }, { name = "openai", specifier = ">=2.41.0" }, { name = "platformdirs", specifier = ">=4.10.0" }, @@ -70,7 +69,6 @@ dev = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "hypothesis", specifier = ">=6.155.1" }, { name = "mypy", specifier = ">=2.1.0" }, - { name = "numpy", specifier = ">=2.0.0" }, { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pyright", specifier = ">=1.1.409" }, { name = "pytest", specifier = ">=9.0.3" }, @@ -624,7 +622,7 @@ wheels = [ [[package]] name = "diff-cover" -version = "10.3.0" +version = "10.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, @@ -632,9 +630,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/21/057e816125c162662d2a2cc2ebcd72dd333e78e51678298d07dd3146011a/diff_cover-10.3.0.tar.gz", hash = "sha256:474dbc63e815fbb7567d7b7ca5b104123e96129f25426ebdbc9a1bdbb935b2c6", size = 106546, upload-time = "2026-05-30T14:17:14.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/72/fc43790759f3824801359249611f8785541407541fc756ba6922aa5781ba/diff_cover-10.2.1.tar.gz", hash = "sha256:33bae6a1bdcc8aea2c626bdcff4efb9f4e9cbf00f929060159e7c959306f85bf", size = 102405, upload-time = "2026-05-23T12:55:07.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/0a/a96e57a7a3fca419cd5ceff0d13dee2166520fa67103fb82624ad64700fb/diff_cover-10.3.0-py3-none-any.whl", hash = "sha256:2e47d5ab3868d1e92131c11f364f3f4a8583c97123d3bbc6b6cc8ce0a4cc2202", size = 58989, upload-time = "2026-05-30T14:17:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/037da0abb5241fdb22d786307af6a74e3c4cf27f241e461314893071d19e/diff_cover-10.2.1-py3-none-any.whl", hash = "sha256:1f98191f18953091672598c1cb3eb62efc1288d96f8d92f24c898e993bf3aae1", size = 56744, upload-time = "2026-05-23T12:55:05.99Z" }, ] [[package]] @@ -702,6 +700,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/7e/8ab39aab1d392845b6512009a9be57d24a5bd4ec7a22d02e513d0645e7a8/httpcore2-2.2.0.tar.gz", hash = "sha256:10e0e142f1ecc1c1cb2a9ebbce82e57f16169f61d163ea336abf36799e89294b", size = 63533, upload-time = "2026-05-17T05:29:55.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/22/64de17e7956e8c002f7558ed667d924c2a288344aeff4bd8ff5dc5fdb70b/httpcore2-2.2.0-py3-none-any.whl", hash = "sha256:ce859f268bf8d34fa2d7753e09e4dd5194f557e1b3038439b68a89b2999572fa", size = 79288, upload-time = "2026-05-17T05:29:52.56Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -717,6 +728,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore2" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/aa/c3119de1aa7ad870a01aaddbf3bc3445ed9a681c31d45e3838fd8b7bc155/httpx2-2.2.0.tar.gz", hash = "sha256:f3428d59b1752b8f5629826277262fb4d65e3a683f48af8a5b16c4d012e0b801", size = 80477, upload-time = "2026-05-17T05:29:57.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/e0/e0a52596c14194e428c20de4903f4abec38c0dfb5364d20f1d4a2b6266ef/httpx2-2.2.0-py3-none-any.whl", hash = "sha256:12347ebd2daeaefd50b529359778fff767082a09c5826752c963e71269722ff0", size = 74083, upload-time = "2026-05-17T05:29:54.543Z" }, +] + [[package]] name = "hypothesis" version = "6.155.1" @@ -1191,85 +1217,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] -[[package]] -name = "numpy" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, - { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, - { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, - { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, - { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, - { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, - { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, - { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, - { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, - { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, - { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, - { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, - { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, - { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, - { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, - { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, - { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, - { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, - { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, -] - [[package]] name = "openai" version = "2.41.0"