From deef92f1d241f8ce60cc487e1fc65afe30ca06ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 05:54:13 +0000 Subject: [PATCH 1/2] Simplify: dedupe optional-key resolution, status cells, doctor checks - config.resolve_api_key_optional centralizes the NotAuthenticated-swallowing that init/keys and onboard's _has_key each re-implemented. - theme.status_text wraps the repeated Text(value, status_style(value)) used by transcripts/sessions list rendering (drops two rich.text imports). - doctor._check collapses the repeated Check dict literals into one builder. https://claude.ai/code/session_01VCThQ9Rcvt28CQhyx6qPts --- aai_cli/commands/doctor.py | 173 ++++++++++++++++---------------- aai_cli/commands/sessions.py | 4 +- aai_cli/commands/transcripts.py | 4 +- aai_cli/config.py | 10 ++ aai_cli/init/keys.py | 6 +- aai_cli/onboard/sections.py | 8 +- aai_cli/theme.py | 6 ++ tests/test_doctor.py | 1 + 8 files changed, 106 insertions(+), 106 deletions(-) diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index ebfe15cb..6894f4b8 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -44,59 +44,64 @@ def query_devices(self) -> Sequence[Mapping[str, object]]: ... } +def _check( + name: str, + status: str, + detail: str, + *, + fix: str | None = None, + affects: list[str] | None = None, +) -> Check: + """Assemble a Check. ``affects`` defaults to empty — an 'ok' check blocks nothing.""" + return {"name": name, "status": status, "affects": affects or [], "detail": detail, "fix": fix} + + def check_python() -> Check: v = sys.version_info version = f"{v.major}.{v.minor}.{v.micro}" if v >= (3, 12): - return {"name": "python", "status": "ok", "affects": [], "detail": version, "fix": None} - return { - "name": "python", - "status": "fail", - "affects": ["everything"], - "detail": f"Python {version} is too old; the CLI needs 3.12+", - "fix": "Install Python 3.12 or newer, then reinstall the CLI.", - } + return _check("python", "ok", version) + return _check( + "python", + "fail", + f"Python {version} is too old; the CLI needs 3.12+", + fix="Install Python 3.12 or newer, then reinstall the CLI.", + affects=["everything"], + ) def _check_api_key(profile: str) -> Check: - affects = ["everything"] try: key = config.resolve_api_key(profile=profile) except NotAuthenticated: - return { - "name": "api-key", - "status": "fail", - "affects": affects, - "detail": "No API key found.", - "fix": "Run 'aai login' (or set ASSEMBLYAI_API_KEY).", - } + return _check( + "api-key", + "fail", + "No API key found.", + fix="Run 'aai login' (or set ASSEMBLYAI_API_KEY).", + affects=["everything"], + ) # validate_key doubles as the connectivity probe: it makes one cheap authed call, # so a pass means the key is valid AND api.assemblyai.com is reachable. try: valid = client.validate_key(key) except CLIError as exc: - return { - "name": "api-key", - "status": "fail", - "affects": affects, - "detail": f"Could not reach AssemblyAI: {exc.message}", - "fix": "Check your network/proxy and that api.assemblyai.com is reachable.", - } + return _check( + "api-key", + "fail", + f"Could not reach AssemblyAI: {exc.message}", + fix="Check your network/proxy and that api.assemblyai.com is reachable.", + affects=["everything"], + ) if valid: - return { - "name": "api-key", - "status": "ok", - "affects": [], - "detail": "API key is valid and AssemblyAI is reachable.", - "fix": None, - } - return { - "name": "api-key", - "status": "fail", - "affects": affects, - "detail": "API key was rejected (HTTP 401).", - "fix": "Run 'aai login' with a valid key.", - } + return _check("api-key", "ok", "API key is valid and AssemblyAI is reachable.") + return _check( + "api-key", + "fail", + "API key was rejected (HTTP 401).", + fix="Run 'aai login' with a valid key.", + affects=["everything"], + ) def check_ffmpeg() -> Check: @@ -104,20 +109,19 @@ def check_ffmpeg() -> Check: # decodes them to 16 kHz mono PCM on the fly. Plain `transcribe` (including # YouTube URLs) uploads the file to AssemblyAI and never invokes ffmpeg, so it is # not required for transcription. - affects = ["stream/agent (non-WAV file or URL input)"] if shutil.which("ffmpeg"): - return {"name": "ffmpeg", "status": "ok", "affects": [], "detail": "found", "fix": None} - return { - "name": "ffmpeg", - "status": "warn", - "affects": affects, - "detail": ( + return _check("ffmpeg", "ok", "found") + return _check( + "ffmpeg", + "warn", + ( "ffmpeg not found. Only needed to stream non-WAV files or URLs; " "transcription (including YouTube) works without it, as does streaming a " "16 kHz mono WAV." ), - "fix": "Install ffmpeg (macOS: brew install ffmpeg; Debian/Ubuntu: apt-get install ffmpeg).", - } + fix="Install ffmpeg (macOS: brew install ffmpeg; Debian/Ubuntu: apt-get install ffmpeg).", + affects=["stream/agent (non-WAV file or URL input)"], + ) def _probe_input_devices() -> int: @@ -144,59 +148,50 @@ def check_audio() -> Check: try: inputs = _probe_input_devices() except ImportError: - return { - "name": "audio", - "status": "warn", - "affects": affects, - "detail": "sounddevice is not importable; the microphone can't be used.", - "fix": "pip install --force-reinstall sounddevice", - } + return _check( + "audio", + "warn", + "sounddevice is not importable; the microphone can't be used.", + fix="pip install --force-reinstall sounddevice", + affects=affects, + ) except Exception as exc: # noqa: BLE001 - any PortAudio/device failure is a soft warning - return { - "name": "audio", - "status": "warn", - "affects": affects, - "detail": f"audio system unavailable: {exc}", - "fix": "On Linux install PortAudio: sudo apt-get install libportaudio2", - } + return _check( + "audio", + "warn", + f"audio system unavailable: {exc}", + fix="On Linux install PortAudio: sudo apt-get install libportaudio2", + affects=affects, + ) if inputs == 0: - return { - "name": "audio", - "status": "warn", - "affects": affects, - "detail": "No microphone (input device) found.", - "fix": "Connect a microphone; live mic input is needed for stream/agent.", - } - return { - "name": "audio", - "status": "ok", - "affects": [], - "detail": f"{inputs} microphone input device(s) available.", - "fix": None, - } + return _check( + "audio", + "warn", + "No microphone (input device) found.", + fix="Connect a microphone; live mic input is needed for stream/agent.", + affects=affects, + ) + return _check("audio", "ok", f"{inputs} microphone input device(s) available.") def _check_coding_agent() -> Check: - affects = ["aai setup install"] missing = [tool for tool in ("claude", "npx") if shutil.which(tool) is None] if not missing: - return { - "name": "coding-agent", - "status": "ok", - "affects": [], - "detail": "claude and npx found; run 'aai setup install' to wire up the docs MCP + skills.", - "fix": None, - } - return { - "name": "coding-agent", - "status": "warn", - "affects": affects, - "detail": f"not found: {', '.join(missing)}.", - "fix": ( + return _check( + "coding-agent", + "ok", + "claude and npx found; run 'aai setup install' to wire up the docs MCP + skills.", + ) + return _check( + "coding-agent", + "warn", + f"not found: {', '.join(missing)}.", + fix=( "Install Claude Code (https://claude.com/claude-code) and Node.js, " "then run 'aai setup install'." ), - } + affects=["aai setup install"], + ) def render(data: DoctorResult) -> str: diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index a4414283..0771a36e 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -3,7 +3,6 @@ import typer from rich.markup import escape from rich.table import Table -from rich.text import Text from aai_cli import jsonshape, output, theme from aai_cli.auth import ams @@ -72,10 +71,9 @@ def render(data: list[dict[str, object]]) -> Table: "model", ) for s in data: - status_str = str(s["status"]) table.add_row( escape(str(s["session_id"])), - Text(status_str, style=theme.status_style(status_str)), + theme.status_text(str(s["status"])), escape(str(s.get("created_at") or "")), escape(str(s.get("audio_duration_sec") or "")), escape(str(s.get("speech_model") or "")), diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index 3d80069d..47f293de 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -3,7 +3,6 @@ import typer from rich.markup import escape from rich.table import Table -from rich.text import Text from aai_cli import choices, client, config, output, theme from aai_cli.context import AppState, run_command @@ -86,10 +85,9 @@ def body(state: AppState, json_mode: bool) -> None: def render(data: list[dict[str, object]]) -> Table: table = output.data_table("id", "status", "created") for row in data: - status = str(row["status"]) table.add_row( escape(str(row["id"])), - Text(status, style=theme.status_style(status)), + theme.status_text(str(row["status"])), escape(str(row.get("created", ""))), ) return table diff --git a/aai_cli/config.py b/aai_cli/config.py index 9d0ba783..68544dd9 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -300,3 +300,13 @@ def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = No if stored: return stored raise NotAuthenticated() + + +def resolve_api_key_optional(*, profile: str | None = None) -> str | None: + """The same key chain as ``resolve_api_key`` (env -> keyring), but ``None`` instead + of raising when no key is configured — for callers that work without one + (``aai init`` scaffolding, the onboarding wizard's signed-in check).""" + try: + return resolve_api_key(profile=profile) + except NotAuthenticated: + return None diff --git a/aai_cli/init/keys.py b/aai_cli/init/keys.py index 892c4604..671e12bd 100644 --- a/aai_cli/init/keys.py +++ b/aai_cli/init/keys.py @@ -1,7 +1,6 @@ from __future__ import annotations from aai_cli import config -from aai_cli.errors import NotAuthenticated def resolve_optional_api_key(*, profile: str | None) -> str | None: @@ -10,7 +9,4 @@ def resolve_optional_api_key(*, profile: str | None) -> str | None: `aai init` scaffolds even without a key (writing a placeholder), so it must not fail the way run commands do. """ - try: - return config.resolve_api_key(profile=profile) - except NotAuthenticated: - return None + return config.resolve_api_key_optional(profile=profile) diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index 0b27de47..deeadaca 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -11,7 +11,7 @@ from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd from aai_cli.context import AppState, persist_browser_login -from aai_cli.errors import CLIError, NotAuthenticated +from aai_cli.errors import CLIError from aai_cli.onboard.prompter import Prompter @@ -29,11 +29,7 @@ class WizardContext: def _has_key(profile: str) -> bool: - try: - config.resolve_api_key(profile=profile) - except NotAuthenticated: - return False - return True + return config.resolve_api_key_optional(profile=profile) is not None def welcome(prompter: Prompter, _ctx: WizardContext) -> SectionResult: diff --git a/aai_cli/theme.py b/aai_cli/theme.py index fcee2d67..68f0af45 100644 --- a/aai_cli/theme.py +++ b/aai_cli/theme.py @@ -3,6 +3,7 @@ from typing import IO, Any from rich.console import Console +from rich.text import Text from rich.theme import Theme # AssemblyAI brand accent. Defined once so the whole CLI can be re-tinted here. @@ -87,3 +88,8 @@ def status_style(status: str) -> str: if normalized in _WARN: return "aai.warn" return "aai.muted" + + +def status_text(status: str) -> Text: + """A status string rendered in its semantic status color (see ``status_style``).""" + return Text(status, style=status_style(status)) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index ad5dc319..d14414d0 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -136,6 +136,7 @@ def test_check_python_flags_old_interpreter(monkeypatch): check = doctor.check_python() assert check["status"] == "fail" assert "3.9.0" in check["detail"] + assert check["affects"] == ["everything"] def test_check_audio_handles_portaudio_failure(monkeypatch): From fddf802d657423b56aacc6524b73777490f65a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 13:46:58 +0000 Subject: [PATCH 2/2] Fix flaky auth loopback tests: unique port per test These tests bound the single fixed LOOPBACK_PORT in a worker thread for every case. Under CI load + random ordering a server could still hold the port when the next test's worker bound it, raising APIError inside the thread (surfacing as PytestUnhandledThreadExceptionWarning, which the suite treats as an error) and cascading into neighbouring tests. Give each test its own OS-assigned port so the cases stop sharing a network resource; capture_callback is unchanged. https://claude.ai/code/session_01VCThQ9Rcvt28CQhyx6qPts --- tests/test_auth_loopback.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index 8574de86..c587fdbf 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -9,6 +9,23 @@ from aai_cli.errors import APIError +@pytest.fixture(autouse=True) +def _unique_loopback_port(monkeypatch): + """Give every test its own OS-assigned loopback port. + + Production binds the single fixed ``LOOPBACK_PORT`` (one login at a time), but the + suite runs many capture cycles back-to-back. Sharing one fixed port makes the tests + flaky under load/random ordering: if one test's server is still bound when the next + starts, the bind raises inside the worker thread (surfacing as an unhandled-thread + warning) and cascades into the neighbours. A fresh port per test removes that + coupling without changing what `capture_callback` does. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind((endpoints.LOOPBACK_HOST, 0)) + port = probe.getsockname()[1] + monkeypatch.setattr(endpoints, "LOOPBACK_PORT", port) + + def _hit(path: str) -> int | None: """Request `path` against the loopback server, returning the HTTP status code.