From bc87aa052555bd3c2cf6372bf354e0796a328457 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 14:43:54 +0000 Subject: [PATCH] Simplify codebase: dedupe shared plumbing, derive hand-synced lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality pass from a four-angle review (reuse, simplification, efficiency, altitude) over aai_cli/: - stdio.interactive_stdio(): one TTY predicate for the bare-`assembly` setup offer, the onboarding prompter, and the init template picker (was three inline copies, one shadowing context's different _interactive_session) - context: drop the four module-level resolve_* pass-throughs in favor of the AppState methods; fold the six emit_error+Exit pairs into one _fail helper; lazy-import the auth package so the login stack stays off every command's import path - procs.spawn_detached(): the shared detached-spawn recipe for the telemetry flusher and update-check refresh (was two hand-synced Popen incantations) - environments.SANDBOX_ENV: single source for the sandbox name; --env help is derived from ENVIRONMENTS - main: derive the misplaced-global-flag hint set from the root callback's own declarations (the hand list had already drifted — --version got no hint); JSON flag spellings shared via argscan.JSON_FLAGS - doctor.render_check_lines(): shared with the onboarding wizard's environment section (was a verbatim copy including the symbol map) - output.hidden_note(): one phrasing for the "Hidden: N …" footnotes in usage/audit - speak_exec: reuse stdio.piped_stdin_text; merge the duplicated single/multi emit fork - config: collapse the validate_profile pass-through; flow: single _no_project_error; llm: collapse the --list-models closure factory - tests: contract test pinning that the -o field maps cover every TranscriptOutput member exactly https://claude.ai/code/session_01APHW6krqHMsPQ7MsMuBR9n --- .importlinter | 1 + aai_cli/argscan.py | 7 +++- aai_cli/auth/flow.py | 19 +++++---- aai_cli/commands/account.py | 16 +++---- aai_cli/commands/audit.py | 12 ++---- aai_cli/commands/doctor.py | 28 +++++++++---- aai_cli/commands/init.py | 5 +-- aai_cli/commands/keys.py | 8 ++-- aai_cli/commands/llm.py | 37 ++++++---------- aai_cli/commands/login.py | 8 ++-- aai_cli/commands/onboard.py | 10 ++--- aai_cli/commands/sessions.py | 6 +-- aai_cli/config.py | 12 ++---- aai_cli/context.py | 77 ++++++++++++++-------------------- aai_cli/environments.py | 4 ++ aai_cli/main.py | 38 +++++++++-------- aai_cli/onboard/sections.py | 22 ++-------- aai_cli/output.py | 9 ++++ aai_cli/procs.py | 30 +++++++++++++ aai_cli/speak_exec.py | 74 ++++++++++++++++---------------- aai_cli/stdio.py | 9 ++++ aai_cli/telemetry.py | 11 +---- aai_cli/update_check.py | 20 +++------ tests/test_account_command.py | 8 ++-- tests/test_agent_command.py | 2 +- tests/test_audit_command.py | 2 +- tests/test_code_gen.py | 15 +++++++ tests/test_context.py | 62 +++++++++++++-------------- tests/test_help_rendering.py | 6 ++- tests/test_keys.py | 4 +- tests/test_llm_command.py | 2 +- tests/test_login.py | 26 ++++++------ tests/test_login_guards.py | 4 +- tests/test_onboard_command.py | 25 ++++------- tests/test_sessions_command.py | 2 +- tests/test_stream_command.py | 2 +- tests/test_transcribe.py | 2 +- tests/test_transcripts.py | 4 +- tests/test_update_check.py | 2 +- 39 files changed, 317 insertions(+), 314 deletions(-) create mode 100644 aai_cli/procs.py diff --git a/.importlinter b/.importlinter index 6fde2af9..a25a96b7 100644 --- a/.importlinter +++ b/.importlinter @@ -28,6 +28,7 @@ source_modules = aai_cli.microphone aai_cli.options aai_cli.output + aai_cli.procs aai_cli.render aai_cli.speak_exec aai_cli.stdio diff --git a/aai_cli/argscan.py b/aai_cli/argscan.py index ea101470..5e7aad11 100644 --- a/aai_cli/argscan.py +++ b/aai_cli/argscan.py @@ -9,13 +9,18 @@ from __future__ import annotations +# The standalone "give me JSON" flag spellings. Shared with main's misplaced-flag +# hint (which recognizes a `--json`/`-j` passed at the root level), so the two +# can't drift on which forms count. +JSON_FLAGS = ("--json", "-j") + def requests_json(raw_args: list[str]) -> bool: """Whether the token list opts into JSON output: ``--json``, ``-j``, ``-o json``, ``--output json``, or their glued forms (``--output=json``, ``-ojson``).""" for index, token in enumerate(raw_args): - if token in ("--json", "-j", "--output=json", "-ojson"): + if token in (*JSON_FLAGS, "--output=json", "-ojson"): return True if token in ("-o", "--output") and raw_args[index + 1 : index + 2] == ["json"]: return True diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index 02235bad..daa43b0b 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -141,24 +141,27 @@ def _reusable_cli_key(token: _Token) -> str | None: return None +def _no_project_error() -> APIError: + """The one failure mode behind both guards in ``find_or_create_cli_key``: + nowhere to mint a key (no project entries at all, or an entry without a project).""" + return APIError( + "Your account has no project to create an API key in.", + suggestion="Create a project in the AssemblyAI dashboard, then run 'assembly login' again.", + ) + + def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: """Return the existing 'AssemblyAI CLI' key, or create one in the first project.""" projects = _parse(_PROJECT_LIST, ams.list_projects(account_id, session_jwt)) if not projects: - raise APIError( - "Your account has no project to create an API key in.", - suggestion="Create a project in the AssemblyAI dashboard, then run 'assembly login' again.", - ) + raise _no_project_error() for entry in projects: for token in entry.tokens: if key := _reusable_cli_key(token): return key project = projects[0].project if project is None: - raise APIError( - "Your account has no project to create an API key in.", - suggestion="Create a project in the AssemblyAI dashboard, then run 'assembly login' again.", - ) + raise _no_project_error() created = ams.create_token(account_id, project.id, endpoints.CLI_TOKEN_NAME, session_jwt) return _parse(_CREATED_TOKEN, created).api_key diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index ad7f25d0..0d8cac7a 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -10,7 +10,7 @@ from aai_cli import help_panels, jsonshape, options, output, timeparse from aai_cli.auth import ams -from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.help_text import examples_epilog @@ -144,7 +144,7 @@ def balance( """Show your remaining account balance.""" def body(state: AppState, json_mode: bool) -> None: - _, jwt = resolve_session(state) + _, jwt = state.resolve_session() data = ams.get_balance(jwt) cents = jsonshape.as_float(data.get("balance_in_cents")) output.emit( @@ -207,7 +207,7 @@ def body(state: AppState, json_mode: bool) -> None: ) start_date = _utc_day_start(start_day) end_date = _utc_day_start(end_day) - _, jwt = resolve_session(state) + _, jwt = state.resolve_session() data = ams.get_usage(jwt, start_date, end_date, window) def render(d: dict[str, object]) -> object: @@ -243,13 +243,7 @@ def render(d: dict[str, object]) -> object: if show_breakdown: row.append(escape(breakdown)) table.add_row(*row) - hidden_note = ( - output.muted( - f"Hidden: {hidden_count} zero-usage window(s). Use --include-zero to show them." - ) - if hidden_count - else None - ) + hidden_note = output.hidden_note(hidden_count, "zero-usage window", "--include-zero") return output.stack(summary, table, hidden_note) output.emit(data, render, json_mode=json_mode) @@ -273,7 +267,7 @@ def limits( """Show your account's rate limits per service.""" def body(state: AppState, json_mode: bool) -> None: - account_id, jwt = resolve_session(state) + account_id, jwt = state.resolve_session() data = ams.get_rate_limits(account_id, jwt) def render(d: dict[str, object]) -> object: diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index 6d3674c1..0a5ed656 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -7,7 +7,7 @@ from aai_cli import help_panels, jsonshape, options, output, timeparse from aai_cli.auth import ams -from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.context import AppState, run_command from aai_cli.help_text import examples_epilog app = typer.Typer(help="View your account's audit log.") @@ -98,7 +98,7 @@ def audit( """List recent audit-log entries for your account.""" def body(state: AppState, json_mode: bool) -> None: - _, jwt = resolve_session(state) + _, jwt = state.resolve_session() payload = ams.list_audit_logs(jwt, limit=limit, action_taken=action, resource_type=resource) rows = _audit_rows(payload) @@ -106,13 +106,7 @@ def render(data: list[dict[str, object]]) -> object: hide_logins = not include_logins and action is None shown = [entry for entry in data if not (hide_logins and _is_login(entry))] hidden_logins = len(data) - len(shown) - hidden_note = ( - output.muted( - f"Hidden: {hidden_logins} login event(s). Use --include-logins to show them." - ) - if hidden_logins - else None - ) + hidden_note = output.hidden_note(hidden_logins, "login event", "--include-logins") if not shown: message = ( "No notable audit events in the recent log." diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index 5fe4328b..4bd7faa0 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -10,7 +10,7 @@ from rich.markup import escape from aai_cli import client, coding_agent, config, environments, help_panels, options, output, theme -from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError, NotAuthenticated from aai_cli.help_text import examples_epilog @@ -232,6 +232,22 @@ def _check_coding_agent() -> Check: ) +def render_check_lines(checks: list[Check]) -> list[str]: + """The per-check report lines (glyph, name — detail, indented fix hint). + + Shared with the onboarding wizard's environment section (which renders the same + checks with its own summary line), so the two renders can't drift.""" + lines: list[str] = [] + for c in checks: + symbol, style = _SYMBOL.get(c["status"], (theme.SYMBOL_HINT, "aai.muted")) + lines.append( + f" [{style}]{escape(symbol)}[/{style}] {escape(c['name'])} — {escape(c['detail'])}" + ) + if c["fix"]: + lines.append(" " + output.hint(f"fix: {escape(c['fix'])}")) + return lines + + def render(data: DoctorResult) -> str: checks = data["checks"] lines = [output.heading("Environment check")] @@ -240,13 +256,7 @@ def render(data: DoctorResult) -> str: lines.append( " " + output.hint(f"profile: {escape(profile)} · environment: {escape(environment)}") ) - for c in checks: - symbol, style = _SYMBOL.get(c["status"], (theme.SYMBOL_HINT, "aai.muted")) - lines.append( - f" [{style}]{escape(symbol)}[/{style}] {escape(c['name'])} — {escape(c['detail'])}" - ) - if c["fix"]: - lines.append(" " + output.hint(f"fix: {escape(c['fix'])}")) + lines.extend(render_check_lines(checks)) if data["ok"]: lines.append(" " + output.success("Everything looks good.")) # Only the real `assembly doctor` carries profile context; the onboarding wizard @@ -277,7 +287,7 @@ def doctor( """Check that your environment is ready to use AssemblyAI.""" def body(state: AppState, json_mode: bool) -> None: - profile = resolve_profile(state) + profile = state.resolve_profile() checks = [ check_python(), _check_credentials(profile), diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index 28672f93..705a9bcf 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -1,13 +1,12 @@ # aai_cli/commands/init.py from __future__ import annotations -import sys from pathlib import Path import typer from rich.markup import escape -from aai_cli import __version__, environments, help_panels, options, output, steps +from aai_cli import __version__, environments, help_panels, options, output, stdio, steps from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError, UsageError from aai_cli.help_text import examples_epilog @@ -23,7 +22,7 @@ def _pick_template() -> str: """Interactive picker; raises a usage error when there's no TTY to prompt on.""" - if not sys.stdin.isatty() or not sys.stdout.isatty(): + if not stdio.interactive_stdio(): raise CLIError( "No template given and not running interactively. " f"Pass one of: {', '.join(templates.TEMPLATE_ORDER)}.", diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index 1b7985f3..b6dc50b2 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -5,7 +5,7 @@ from aai_cli import jsonshape, options, output from aai_cli.auth import ams -from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.context import AppState, run_command from aai_cli.errors import APIError, UsageError from aai_cli.help_text import examples_epilog @@ -60,7 +60,7 @@ def list_( """List API keys across your projects (keys shown masked).""" def body(state: AppState, json_mode: bool) -> None: - account_id, jwt = resolve_session(state) + account_id, jwt = state.resolve_session() projects = ams.list_projects(account_id, jwt) rows: list[dict[str, object]] = [] for entry in projects: @@ -126,7 +126,7 @@ def body(state: AppState, json_mode: bool) -> None: "--name must not be empty.", suggestion="Pass a label for the key, e.g. --name ci-pipeline.", ) - account_id, jwt = resolve_session(state) + account_id, jwt = state.resolve_session() pid = project_id if project_id is not None else _default_project_id(account_id, jwt) created = ams.create_token(account_id, pid, name, jwt) output.emit( @@ -158,7 +158,7 @@ def rename( """Rename an existing API key.""" def body(state: AppState, json_mode: bool) -> None: - account_id, jwt = resolve_session(state) + account_id, jwt = state.resolve_session() ams.rename_token(account_id, token_id, new_name, jwt) output.emit( {"id": token_id, "name": new_name}, diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index 14d088fd..3bcc71e8 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -1,41 +1,28 @@ from __future__ import annotations -from collections.abc import Callable - import typer from aai_cli import choices, help_panels, llm_exec, options, output from aai_cli import llm as gateway -from aai_cli.context import AppState, run_command +from aai_cli.context import run_command from aai_cli.errors import UsageError from aai_cli.help_text import examples_epilog app = typer.Typer() -def _emit_model_list(_state: AppState, json_mode: bool) -> None: - """--list-models body, routed through run_command so --json yields a - machine-readable array instead of the human list; needs no auth.""" +def _list_models(output_field: choices.TextOrJson | None, json_mode: bool) -> None: + """The --list-models body, routed through run_command so --json yields a + machine-readable array instead of the human list; needs no auth. Rejects -o + (it only applies to one-shot mode, mirroring how --follow rejects it).""" + if output_field is not None: + raise UsageError( + "--output applies to one-shot mode; --list-models prints the plain " + "list (use --json for a machine-readable array)." + ) output.emit(list(gateway.KNOWN_MODELS), "\n".join, json_mode=json_mode) -def _list_models_body( - output_field: choices.TextOrJson | None, -) -> Callable[[AppState, bool], None]: - """The --list-models command body: rejects -o (it only applies to one-shot - mode, mirroring how --follow rejects it) before printing the known models.""" - - def body(state: AppState, json_mode: bool) -> None: - if output_field is not None: - raise UsageError( - "--output applies to one-shot mode; --list-models prints the plain " - "list (use --json for a machine-readable array)." - ) - _emit_model_list(state, json_mode) - - return body - - @app.command( rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( @@ -94,7 +81,9 @@ def llm( """ if list_models: - run_command(ctx, _list_models_body(output_field), json=json_out) + run_command( + ctx, lambda _state, json_mode: _list_models(output_field, json_mode), json=json_out + ) return opts = llm_exec.LlmOptions( diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index c9032819..38571368 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -5,7 +5,7 @@ from rich.table import Table from aai_cli import client, config, environments, help_panels, options, output -from aai_cli.context import AppState, persist_browser_login, resolve_profile, run_command +from aai_cli.context import AppState, persist_browser_login, run_command from aai_cli.errors import APIError, CLIError, UsageError from aai_cli.help_text import examples_epilog @@ -29,7 +29,7 @@ def login( """Authenticate via your browser; stores a CLI API key.""" def body(state: AppState, json_mode: bool) -> None: - profile = resolve_profile(state) + profile = state.resolve_profile() env = environments.active().name if api_key is not None and not api_key.strip(): # An explicitly-passed empty/whitespace key (e.g. --api-key "$UNSET_VAR") @@ -120,7 +120,7 @@ def logout( """Clear stored credentials for the active profile.""" def body(state: AppState, json_mode: bool) -> None: - profile = resolve_profile(state) + profile = state.resolve_profile() # Look before clearing so the report is truthful: "Signed out" on a fresh # machine (or a typo'd --profile) would claim something happened when # nothing was stored. Still exit 0 either way — logout is idempotent. @@ -161,7 +161,7 @@ def whoami( """Show the active profile and whether its key is usable.""" def body(state: AppState, json_mode: bool) -> None: - profile = resolve_profile(state) + profile = state.resolve_profile() # The full env -> keyring chain (raises NotAuthenticated when empty), so a CI # box authenticated via ASSEMBLYAI_API_KEY can use whoami as a preflight check. key = state.resolve_api_key() diff --git a/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py index 340769a6..ab32c38d 100644 --- a/aai_cli/commands/onboard.py +++ b/aai_cli/commands/onboard.py @@ -1,11 +1,9 @@ from __future__ import annotations -import sys - import typer -from aai_cli import help_panels, options, output -from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli import help_panels, options, output, stdio +from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError from aai_cli.help_text import examples_epilog from aai_cli.onboard import wizard @@ -20,7 +18,7 @@ def build_prompter(*, non_interactive: bool = False) -> Prompter: otherwise never block for input.""" if non_interactive: return NonInteractivePrompter() - if sys.stdin.isatty() and sys.stdout.isatty(): + if stdio.interactive_stdio(): return InteractivePrompter() return NonInteractivePrompter() @@ -45,7 +43,7 @@ def onboard( """Guided setup: sign in, run your first transcription, and start building.""" def body(state: AppState, json_mode: bool) -> None: - profile = resolve_profile(state) + profile = state.resolve_profile() wiz_ctx = WizardContext(state=state, profile=profile, json_mode=json_mode) # --json also forces non-interactive: a machine-output run can't block on # prompts, and the interactive prompter would write prose onto the JSON stdout. diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index 145a2971..f1ad5e5e 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -8,7 +8,7 @@ from aai_cli import jsonshape, options, output, theme, timeparse from aai_cli.auth import ams -from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.context import AppState, run_command from aai_cli.help_text import examples_epilog app = typer.Typer(help="Browse your past streaming (real-time) sessions.", no_args_is_help=True) @@ -70,7 +70,7 @@ def list_( """List recent streaming sessions.""" def body(state: AppState, json_mode: bool) -> None: - _, jwt = resolve_session(state) + _, jwt = state.resolve_session() payload = ams.list_streaming( jwt, limit=limit, status=None if status is None else status.value ) @@ -124,7 +124,7 @@ def get( """Show details for one streaming session.""" def body(state: AppState, json_mode: bool) -> None: - _, jwt = resolve_session(state) + _, jwt = state.resolve_session() data = ams.get_streaming(session_id, jwt) def render(d: dict[str, object]) -> Table: diff --git a/aai_cli/config.py b/aai_cli/config.py index 6c69559a..15e1848f 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -68,10 +68,6 @@ def validate_profile(name: str) -> None: fail fast on a typo'd ``--profile`` before any network work, instead of only tripping over it at keyring-write time. """ - _validate_profile(name) - - -def _validate_profile(name: str) -> None: if not _PROFILE_RE.match(name): from aai_cli.errors import CLIError @@ -213,7 +209,7 @@ def _keyring_restore(username: str, prior: str | None) -> None: def set_api_key(profile: str, api_key: str) -> None: - _validate_profile(profile) + validate_profile(profile) _keyring_set(profile, api_key) cfg = _load() cfg.profiles.setdefault(profile, Profile()) @@ -262,7 +258,7 @@ def get_profile_env(profile: str) -> str | None: def set_profile_env(profile: str, env: str) -> None: """Bind a backend environment to a profile so its key and hosts stay matched.""" - _validate_profile(profile) + validate_profile(profile) cfg = _load() cfg.profiles.setdefault(profile, Profile()).env = env _dump(cfg) @@ -288,7 +284,7 @@ def set_session(profile: str, *, session_jwt: str, session_token: str, account_i AMS self-service endpoints authenticate with this session cookie, not the API key. The JWT is short-lived; an expired session surfaces as NotAuthenticated. """ - _validate_profile(profile) + validate_profile(profile) _keyring_set( _session_username(profile), StoredSession(jwt=session_jwt, token=session_token).model_dump_json(), @@ -344,7 +340,7 @@ def persist_login( is rewritten verbatim in one atomic dump, and the two keyring entries are restored best-effort. """ - _validate_profile(profile) + validate_profile(profile) prior_api_key = _keyring_get(profile) prior_session = _keyring_get(_session_username(profile)) prior_cfg = _load() diff --git a/aai_cli/context.py b/aai_cli/context.py index 1b70af82..578278d2 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -10,7 +10,6 @@ import typer from aai_cli import config, environments, output, telemetry, update_check -from aai_cli.auth import run_login_flow from aai_cli.environments import Environment from aai_cli.errors import APIError, CLIError, NotAuthenticated @@ -21,8 +20,7 @@ class AppState: that turns it into a concrete profile, environment, session, or API key. Centralizing resolution here keeps the precedence rules in one spot instead of - being re-derived per command. The module-level ``resolve_*`` functions below are - thin adapters retained for existing call sites and tests. + being re-derived per command. """ profile: str | None = None @@ -102,28 +100,17 @@ def env_override_warning(self) -> str | None: ) -def resolve_profile(state: AppState) -> str: - return state.resolve_profile() - - -def resolve_environment(state: AppState) -> Environment: - return state.resolve_environment() - - -def resolve_session(state: AppState) -> tuple[int, str]: - return state.resolve_session() - - -def env_override_warning(state: AppState) -> str | None: - return state.env_override_warning() - - def persist_browser_login(profile: str, env: str, *, json_mode: bool = False) -> None: """Run the browser login flow and persist its credentials for `profile`/`env`. ``json_mode`` keeps the flow's stderr progress notes machine-readable under ``--json`` (each becomes a ``{"hint": …}`` object instead of prose). """ + # Imported here, not at module top: context is on every command's import path, + # and the auth package (httpx, Stytch discovery, loopback server) is only needed + # when a browser login actually starts. + from aai_cli.auth import run_login_flow + result = run_login_flow(json_mode=json_mode) config.persist_login( profile, @@ -139,20 +126,11 @@ def _persist_browser_login(state: AppState, *, json_mode: bool) -> None: persist_browser_login(state.resolve_profile(), environments.active().name, json_mode=json_mode) -def _login_persistence_error(exc: object) -> APIError: - return APIError( - f"Signed in, but could not save the credentials locally: {exc}", - suggestion="Run 'assembly login' again, or check your keyring/config permissions.", - ) - - -def _rerun_after_login_error() -> CLIError: - return CLIError( - "Signed in. Run the command again to continue.", - error_type="login_required", - exit_code=4, - suggestion="Run the same command again.", - ) +def _fail(err: CLIError, *, json_mode: bool) -> NoReturn: + """Emit a CLIError and exit with its code — the one error-exit shape every + failure path in this module shares.""" + output.emit_error(err, json_mode=json_mode) + raise typer.Exit(code=err.exit_code) from None def _interactive_session() -> bool: @@ -190,17 +168,26 @@ def _auto_login_and_exit(state: AppState, *, json_mode: bool) -> NoReturn: ) _persist_browser_login(state, json_mode=json_mode) except CLIError as login_err: - output.emit_error(login_err, json_mode=json_mode) - raise typer.Exit(code=login_err.exit_code) from None + _fail(login_err, json_mode=json_mode) except (OSError, RuntimeError, TypeError, keyring.errors.KeyringError) as exc: # TypeError covers a value the TOML writer can't serialize: the login itself # succeeded, so the user must see "could not save", not "unexpected error". - persistence_err = _login_persistence_error(exc) - output.emit_error(persistence_err, json_mode=json_mode) - raise typer.Exit(code=persistence_err.exit_code) from None - rerun_err = _rerun_after_login_error() - output.emit_error(rerun_err, json_mode=json_mode) - raise typer.Exit(code=rerun_err.exit_code) from None + _fail( + APIError( + f"Signed in, but could not save the credentials locally: {exc}", + suggestion="Run 'assembly login' again, or check your keyring/config permissions.", + ), + json_mode=json_mode, + ) + _fail( + CLIError( + "Signed in. Run the command again to continue.", + error_type="login_required", + exit_code=4, + suggestion="Run the same command again.", + ), + json_mode=json_mode, + ) def run_command( @@ -221,12 +208,10 @@ def run_command( update_check.maybe_notify(json_mode=json_mode) except NotAuthenticated as err: if not auto_login or not _should_auto_login(err): - output.emit_error(err, json_mode=json_mode) - raise typer.Exit(code=err.exit_code) from None + _fail(err, json_mode=json_mode) _auto_login_and_exit(state, json_mode=json_mode) except CLIError as err: - output.emit_error(err, json_mode=json_mode) - raise typer.Exit(code=err.exit_code) from None + _fail(err, json_mode=json_mode) except (typer.Exit, typer.Abort, BrokenPipeError): # Deliberate control flow (and the closed-pipe contract handled in main.run); # these must reach Click/the entry point untouched. @@ -243,4 +228,6 @@ def run_command( ), ) output.emit_error(internal, json_mode=json_mode) + # `from exc`, unlike _fail's `from None`: the original exception is a bug + # worth keeping on the chain for anyone re-raising with tracebacks enabled. raise typer.Exit(code=internal.exit_code) from exc diff --git a/aai_cli/environments.py b/aai_cli/environments.py index 4373314d..980e9f08 100644 --- a/aai_cli/environments.py +++ b/aai_cli/environments.py @@ -64,6 +64,10 @@ class Environment: # --env sandbox000 / AAI_ENV) to target the sandbox instead. DEFAULT_ENV = "production" +# The environment the --sandbox shortcut expands to; the single place the sandbox +# name lives outside ENVIRONMENTS itself (help text and hints derive from it). +SANDBOX_ENV = "sandbox000" + # The environment in effect for this process, set once at CLI startup (like # aai.settings). Resolved from --env / AAI_ENV / the profile's stored env. _active: Environment | None = None diff --git a/aai_cli/main.py b/aai_cli/main.py index 5c02aa3b..916d7f28 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -43,7 +43,7 @@ transcripts, webhooks, ) -from aai_cli.context import AppState, env_override_warning, resolve_environment +from aai_cli.context import AppState from aai_cli.errors import CLIError, NotAuthenticated, UsageError from aai_cli.help_text import examples_epilog from aai_cli.onboard import wizard @@ -158,12 +158,17 @@ def _patch_module(module: ModuleType, **attrs: object) -> None: _format_click_error = rich_utils.rich_format_error # Flags users habitually pass at the wrong level: `--json` belongs on the subcommand -# (`assembly transcribe --json`), while these live on the root callback +# (`assembly transcribe --json`), while the root callback's flags belong before it # (`assembly --sandbox transcribe`). A bare "No such option" — or worse, a similarity # guess like "(Possible options: --version)" — is unlearnable, so the Click error # formatter appends the correct placement instead. -_JSON_FLAGS = ("--json", "-j") -_ROOT_ONLY_FLAGS = ("--quiet", "-q", "--sandbox", "--env", "--profile", "-p") + + +def _root_only_flags(ctx: ClickContext) -> frozenset[str]: + """Every flag the root callback declares (--quiet, --sandbox, --env, …), read off + the declarations themselves so a new global flag gets the placement hint without + a hand-maintained parallel list.""" + return frozenset(opt for param in ctx.find_root().command.params for opt in param.opts) def _misplaced_flag_hint(err: NoSuchOption) -> str | None: @@ -172,10 +177,10 @@ def _misplaced_flag_hint(err: NoSuchOption) -> str | None: if ctx is None: return None if ctx.parent is None: - if err.option_name in _JSON_FLAGS: + if err.option_name in argscan.JSON_FLAGS: return "Pass --json after the subcommand: assembly --json" return None - if err.option_name in _ROOT_ONLY_FLAGS: + if err.option_name in _root_only_flags(ctx): command = ctx.command_path.removeprefix("assembly ") return ( "This is a global flag; pass it before the subcommand: " @@ -273,11 +278,6 @@ def _profile_has_key(state: AppState) -> bool: return True -def _interactive_session() -> bool: - """True only when both ends are a real TTY (so we never block a piped/CI run).""" - return sys.stdin.isatty() and sys.stdout.isatty() - - # The root callback runs before the subcommand parses its own ``--json``, so a failure # raised there (e.g. a bad ``--env``) would otherwise always render human text — leaving a # ``… --json`` pipeline without the uniform ``{"error": …}`` shape it relies on. The group @@ -293,7 +293,7 @@ def _sandbox_conflict_warning(sandbox: bool, env: str | None) -> str | None: Credentials are environment-bound, so the conflict must not be resolved silently: ``--env`` wins, and the warning names the loser so the user can drop a flag. """ - if sandbox and env is not None and env != "sandbox000": + if sandbox and env is not None and env != environments.SANDBOX_ENV: return f"--sandbox ignored: --env {env} takes precedence." return None @@ -304,7 +304,7 @@ def _offer_or_help(ctx: typer.Context, state: AppState) -> None: `--help` (Click handles that eagerly before the callback).""" if not state.quiet: output.print_banner() - if _interactive_session() and not _profile_has_key(state): + if stdio.interactive_stdio() and not _profile_has_key(state): if not state.quiet: output.console.print() # blank line so the prompt isn't flush against the banner if typer.confirm("Welcome to AssemblyAI. Run guided setup now?", default=True): @@ -333,9 +333,11 @@ def main( ctx: typer.Context, profile: str | None = typer.Option(None, "--profile", "-p", help="Named credential profile."), env: str | None = typer.Option( - None, "--env", help="Backend environment (production, sandbox000)." + None, "--env", help=f"Backend environment ({', '.join(environments.ENVIRONMENTS)})." + ), + sandbox: bool = typer.Option( + False, "--sandbox", help=f"Shortcut for --env {environments.SANDBOX_ENV}." ), - sandbox: bool = typer.Option(False, "--sandbox", help="Shortcut for --env sandbox000."), quiet: bool = typer.Option( False, "--quiet", "-q", help="Suppress non-essential messages (warnings, hints)." ), @@ -360,15 +362,15 @@ def main( json_mode = output.resolve_json(explicit=argscan.requests_json(raw_args)) conflict_warning = _sandbox_conflict_warning(sandbox, env) if sandbox and env is None: - env = "sandbox000" + env = environments.SANDBOX_ENV state = AppState(profile=profile, env=env, quiet=quiet) ctx.obj = state try: - environments.set_active(resolve_environment(state)) + environments.set_active(state.resolve_environment()) except CLIError as err: output.emit_error(err, json_mode=json_mode) raise typer.Exit(code=err.exit_code) from None - for warning in (conflict_warning, env_override_warning(state)): + for warning in (conflict_warning, state.env_override_warning()): if warning and not quiet: # Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets # a machine-readable hint instead of an unexplained downstream auth failure. diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index 63500a0e..0f1340d5 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -6,9 +6,8 @@ import assemblyai as aai import typer -from rich.markup import escape -from aai_cli import config, environments, output, theme, transcribe_exec, transcribe_render +from aai_cli import config, environments, output, transcribe_exec, transcribe_render from aai_cli.commands import doctor as doctor_cmd from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd @@ -100,14 +99,6 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: ] -# Status -> (glyph, style) for the wizard's environment render (same look as doctor's). -_CHECK_SYMBOLS = { - "ok": (theme.SYMBOL_SUCCESS, "aai.success"), - "warn": (theme.SYMBOL_WARN, "aai.warn"), - "fail": (theme.SYMBOL_ERROR, "aai.error"), -} - - def _environment_summary(checks: list[doctor_cmd.Check]) -> str: """The closing line, computed from the actual statuses: doctor.render's all-or-nothing `ok` flag can't say "warnings only", which previously put @@ -124,16 +115,9 @@ def _environment_summary(checks: list[doctor_cmd.Check]) -> str: def _render_environment(checks: list[doctor_cmd.Check]) -> str: - """The wizard's render of the doctor checks: doctor-style per-check lines, with + """The wizard's render of the doctor checks: doctor's own per-check lines, with the summary derived from what the checks actually reported.""" - lines = [output.heading("Environment check")] - for c in checks: - symbol, style = _CHECK_SYMBOLS[c["status"]] - lines.append( - f" [{style}]{escape(symbol)}[/{style}] {escape(c['name'])} — {escape(c['detail'])}" - ) - if c["fix"]: - lines.append(" " + output.hint(f"fix: {escape(c['fix'])}")) + lines = [output.heading("Environment check"), *doctor_cmd.render_check_lines(checks)] lines.append(" " + _environment_summary(checks)) return "\n".join(lines) diff --git a/aai_cli/output.py b/aai_cli/output.py index 96383068..39fd508d 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -149,6 +149,15 @@ def muted(text: str) -> Text: return Text(text, style="aai.muted") +def hidden_note(count: int, noun: str, flag: str) -> Text | None: + """The muted "Hidden: N (s). Use to show them." footnote, or None + when nothing was hidden (so it drops out of a `stack`). Pins the phrasing once + for the listing commands that elide rows behind an --include-* flag.""" + if not count: + return None + return muted(f"Hidden: {count} {noun}(s). Use {flag} to show them.") + + def stack(*items: RenderableType | None) -> RenderableType: """Stack renderables top-to-bottom, dropping any ``None``. diff --git a/aai_cli/procs.py b/aai_cli/procs.py new file mode 100644 index 00000000..f0f81713 --- /dev/null +++ b/aai_cli/procs.py @@ -0,0 +1,30 @@ +"""Detached-subprocess plumbing shared by the background flushers. + +Both telemetry delivery (`assembly telemetry flush`) and the update-check refresh +(`assembly _update-check`) run as detached children so the user's command never +waits on the network. The spawn recipe lives here so the two can't drift on the +parts that make it safe: own session, discarded stdio, and a self-disable env var +so a child can never spawn another of itself. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + + +def spawn_detached(cli_args: list[str], *, disable_env_var: str) -> None: + """Spawn ``python -m aai_cli `` detached; return immediately. + + ``disable_env_var`` is set to ``"1"`` in the child's environment to suppress + the spawning subsystem there. S603 is ignored project-wide for the CLI's own + shell-outs. + """ + subprocess.Popen( + [sys.executable, "-m", "aai_cli", *cli_args], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + env={**os.environ, disable_env_var: "1"}, + ) diff --git a/aai_cli/speak_exec.py b/aai_cli/speak_exec.py index 96f9ca3d..1dd16e58 100644 --- a/aai_cli/speak_exec.py +++ b/aai_cli/speak_exec.py @@ -8,11 +8,10 @@ from __future__ import annotations -import sys from dataclasses import dataclass from pathlib import Path -from aai_cli import output +from aai_cli import environments, output, stdio from aai_cli.context import AppState from aai_cli.errors import CLIError, UsageError from aai_cli.tts import audio, dialogue, session @@ -44,10 +43,8 @@ def _read_text(text: str | None) -> str: if text is not None and text.strip(): return text # `text is None` (argument omitted), not merely blank: see the docstring rationale. - if text is None and not sys.stdin.isatty(): - piped = sys.stdin.read().strip() - if piped: - return piped + if text is None and (piped := stdio.piped_stdin_text()) is not None: + return piped.strip() raise UsageError( "No text to speak.", suggestion='Pass text as an argument: assembly speak "Hello" — or pipe it via stdin.', @@ -66,6 +63,14 @@ def _disposition(out: Path | None) -> str: return f"saved to {out}" if out is not None else "played" +def _emit(payload: dict[str, object], human_line: str, *, json_mode: bool) -> None: + """One result summary: the JSON object on stdout, or a muted human note on stderr.""" + if json_mode: + output.emit_ndjson(payload) + else: + output.error_console.print(f"[aai.muted]{human_line}[/aai.muted]") + + def _emit_single( result: session.SpeakResult, cfg: session.SpeakConfig, @@ -73,22 +78,18 @@ def _emit_single( *, json_mode: bool, ) -> None: - """Single-voice result: a JSON object on stdout, or a human note on stderr.""" duration = round(result.audio_duration_seconds, 3) - if json_mode: - output.emit_ndjson( - { - "voice": cfg.voice, - "language": cfg.language, - "sample_rate": result.sample_rate, - "audio_duration_seconds": duration, - "bytes": len(result.pcm), - "out": str(out) if out is not None else None, - } - ) - return - output.error_console.print( - f"[aai.muted]Spoke {duration}s of audio ({_disposition(out)}).[/aai.muted]" + _emit( + { + "voice": cfg.voice, + "language": cfg.language, + "sample_rate": result.sample_rate, + "audio_duration_seconds": duration, + "bytes": len(result.pcm), + "out": str(out) if out is not None else None, + }, + f"Spoke {duration}s of audio ({_disposition(out)}).", + json_mode=json_mode, ) @@ -100,25 +101,20 @@ def _emit_multi( *, json_mode: bool, ) -> None: - """Multi-voice result: a JSON object on stdout, or a human note on stderr.""" duration = round(result.audio_duration_seconds, 3) - if json_mode: - output.emit_ndjson( - { - "mode": "multi", - "speakers": speakers, - "segments": segment_count, - "sample_rate": result.sample_rate, - "audio_duration_seconds": duration, - "bytes": len(result.pcm), - "out": str(out) if out is not None else None, - } - ) - return voices = ", ".join(f"{spk}={voice}" for spk, voice in speakers.items()) - output.error_console.print( - f"[aai.muted]Spoke {duration}s across {len(speakers)} voices " - f"({voices}) ({_disposition(out)}).[/aai.muted]" + _emit( + { + "mode": "multi", + "speakers": speakers, + "segments": segment_count, + "sample_rate": result.sample_rate, + "audio_duration_seconds": duration, + "bytes": len(result.pcm), + "out": str(out) if out is not None else None, + }, + f"Spoke {duration}s across {len(speakers)} voices ({voices}) ({_disposition(out)}).", + json_mode=json_mode, ) @@ -187,7 +183,7 @@ def run_speak(opts: SpeakOptions, state: AppState, *, json_mode: bool) -> None: error_type="unsupported_environment", exit_code=2, suggestion="Re-run as: assembly --sandbox speak … " - "(--sandbox goes before the command; or use --env sandbox000).", + f"(--sandbox goes before the command; or use --env {environments.SANDBOX_ENV}).", ) spoken = _read_text(opts.text) api_key = state.resolve_api_key() diff --git a/aai_cli/stdio.py b/aai_cli/stdio.py index 1ec00737..48268f01 100644 --- a/aai_cli/stdio.py +++ b/aai_cli/stdio.py @@ -24,6 +24,15 @@ def silence_stdout() -> None: os.close(devnull_fd) +def interactive_stdio() -> bool: + """True only when stdin and stdout are both real TTYs — i.e. a human can answer + a prompt and see it. The shared "may we prompt here?" predicate for the bare-`assembly` + setup offer, the onboarding prompter, and the `assembly init` template picker, so the + three can't drift on what counts as interactive. + """ + return sys.stdin.isatty() and sys.stdout.isatty() + + def stdin_is_piped() -> bool: """True when stdin is a pipe/redirect rather than an interactive terminal.""" stream = sys.stdin diff --git a/aai_cli/telemetry.py b/aai_cli/telemetry.py index 082e1e20..f5b36961 100644 --- a/aai_cli/telemetry.py +++ b/aai_cli/telemetry.py @@ -19,7 +19,6 @@ import json import os import platform -import subprocess import sys import time from collections.abc import Generator, Mapping @@ -27,7 +26,7 @@ import typer -from aai_cli import __version__, argscan, config +from aai_cli import __version__, argscan, config, procs from aai_cli.errors import CLIError ENV_DISABLED = "AAI_TELEMETRY_DISABLED" @@ -187,13 +186,7 @@ def dispatch(event: Mapping[str, object]) -> None: env disables telemetry so a flush can never spawn another flusher. """ payload = json.dumps({"url": intake_url(), "token": client_token(), "event": event}) - subprocess.Popen( - [sys.executable, "-m", "aai_cli", "telemetry", "flush", payload], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - env={**os.environ, ENV_DISABLED: "1"}, - ) + procs.spawn_detached(["telemetry", "flush", payload], disable_env_var=ENV_DISABLED) def flush_payload(raw: str) -> None: diff --git a/aai_cli/update_check.py b/aai_cli/update_check.py index 108f06e2..1c80c228 100644 --- a/aai_cli/update_check.py +++ b/aai_cli/update_check.py @@ -2,15 +2,14 @@ Best-effort and never-blocking, in the style of npm's update-notifier / Vercel: the notice always renders from a ``config.toml`` cache (zero latency), and the -cache is refreshed by a detached ``assembly _update-check`` process — the same -detached-spawn shape as ``telemetry.dispatch`` (see ``aai_cli/telemetry.py``). +cache is refreshed by a detached ``assembly _update-check`` process — the shared +detached-spawn recipe in ``aai_cli/procs.py``, same as ``telemetry.dispatch``. Every failure is swallowed: the notice must never delay or break a command. """ from __future__ import annotations import os -import subprocess import sys import time @@ -19,7 +18,7 @@ from rich.panel import Panel from rich.text import Text -from aai_cli import __version__, config, output +from aai_cli import __version__, config, output, procs from aai_cli.errors import CLIError ENV_DISABLED = "AAI_NO_UPDATE_CHECK" @@ -86,17 +85,10 @@ def fetch_and_cache() -> None: def spawn_refresh() -> None: """Spawn the detached ``assembly _update-check`` child to refresh the cache. - Own session + discarded stdio so the user's command never waits; the child's - env disables the notifier so a refresh can never spawn another (mirrors - ``telemetry.dispatch``). S603 is ignored project-wide for the CLI's own shell-outs. + The shared recipe (own session, discarded stdio, self-disabling env) keeps the + user's command from ever waiting and a refresh from spawning another. """ - subprocess.Popen( - [sys.executable, "-m", "aai_cli", "_update-check"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - env={**os.environ, ENV_DISABLED: "1"}, - ) + procs.spawn_detached(["_update-check"], disable_env_var=ENV_DISABLED) def _should_notify(*, json_mode: bool) -> bool: diff --git a/tests/test_account_command.py b/tests/test_account_command.py index 6b60cfa1..4b0c8067 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -40,7 +40,7 @@ def test_balance_formats_dollars(monkeypatch, mocker): def test_balance_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) get_balance = mocker.patch( "aai_cli.commands.account.ams.get_balance", autospec=True, @@ -347,7 +347,7 @@ def _no_login(**_kwargs): raise AssertionError("login flow must not start for an invalid date") monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _no_login) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _no_login) get_usage = mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True) result = runner.invoke(app, ["usage", "--end", "not-a-date"]) assert result.exit_code == 2 @@ -407,7 +407,7 @@ def test_usage_rejects_end_before_start(monkeypatch, mocker): # any AMS call — even when not logged in. monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("login must not start")), ) get_usage = mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True) @@ -433,7 +433,7 @@ def test_usage_rejects_unknown_window(monkeypatch, mocker): # client-side, before session resolution or any AMS call. monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("login must not start")), ) get_usage = mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 41e16659..f058ba41 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -58,7 +58,7 @@ def test_list_voices_json_emits_machine_readable_array(monkeypatch): def test_agent_unauthenticated_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) monkeypatch.setattr("aai_cli.agent_exec.FileSource", lambda src: f"filesrc:{src}") def fake_run_session(api_key, *, renderer, player, mic, config): diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py index 33b7711b..5cb77974 100644 --- a/tests/test_audit_command.py +++ b/tests/test_audit_command.py @@ -176,7 +176,7 @@ def test_audit_summarizes_all_login_rows(monkeypatch, mocker): def test_audit_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) logs = mocker.patch( "aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value={"data": []} ) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index befcbfeb..27147112 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -248,6 +248,21 @@ def test_transcribe_render_output_field_generates_matching_code(field, fragment) assert fragment in code +def test_output_field_maps_cover_every_transcript_output_choice(): + # Both the run path's renderer map and the snippet map silently fall back to + # plain transcript text for unknown fields, so an exact key-set check is what + # turns "added a TranscriptOutput member, forgot a map" into a test failure + # instead of silently-wrong output. + from aai_cli import client + from aai_cli.choices import TranscriptOutput + from aai_cli.code_gen.transcribe import _OUTPUT_SNIPPETS + + values = {member.value for member in TranscriptOutput} + assert set(_OUTPUT_SNIPPETS) == values + # `text` is the run path's documented fallback; every other choice is explicit. + assert set(client._FIELD_RENDERERS) == values - {"text"} + + def test_transcribe_render_output_json_imports_json_only_when_needed(): assert "import json" in render_transcribe_code({}, "audio.mp3", output="json") assert "import json" not in render_transcribe_code({}, "audio.mp3", output="srt") diff --git a/tests/test_context.py b/tests/test_context.py index b8cbf9fe..2d6af38a 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -7,7 +7,7 @@ from aai_cli import config, environments from aai_cli.auth.flow import LoginResult -from aai_cli.context import AppState, _interactive_session, env_override_warning, run_command +from aai_cli.context import AppState, _interactive_session, run_command from aai_cli.errors import APIError, NotAuthenticated, auth_failure runner = CliRunner() @@ -63,7 +63,7 @@ def test_run_command_skips_auto_login_when_session_not_interactive(monkeypatch): # session: no browser login may start (it would bind a port and block 120s), # and the ORIGINAL NotAuthenticated must surface with its actionable suggestion. monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("non-interactive must not auto-login")), ) @@ -80,7 +80,7 @@ def body(state, json_mode): def test_run_command_not_interactive_json_keeps_clean_error_shape(monkeypatch): monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("non-interactive must not auto-login")), ) @@ -101,7 +101,7 @@ def test_run_command_auto_login_notice_suppressed_in_json_mode(monkeypatch): # shape is emitted. _force_interactive(monkeypatch) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: LoginResult( api_key="sk_auto", session_jwt="j", session_token="t", account_id=1 ), @@ -128,7 +128,7 @@ def body(state, json_mode): def test_run_command_auto_logs_in_and_asks_for_rerun(monkeypatch): _force_interactive(monkeypatch) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: LoginResult( api_key="sk_auto", session_jwt="jwt_auto", @@ -156,7 +156,7 @@ def body(state, json_mode): def test_run_command_auto_login_persistence_failure_is_clean(monkeypatch): _force_interactive(monkeypatch) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: LoginResult( api_key="sk_auto", session_jwt="jwt_auto", @@ -184,7 +184,7 @@ def test_run_command_auto_login_persistence_type_error_is_clean(monkeypatch): # message — not the generic "Unexpected error" internal-bug line. _force_interactive(monkeypatch) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: LoginResult( api_key="sk_auto", session_jwt="jwt_auto", @@ -212,7 +212,7 @@ def test_run_command_auto_login_failure_is_clean(monkeypatch): def fail_login(**_kwargs): raise APIError("Login failed: the server returned an unexpected response.") - monkeypatch.setattr("aai_cli.context.run_login_flow", fail_login) + monkeypatch.setattr("aai_cli.auth.run_login_flow", fail_login) def body(state, json_mode): raise NotAuthenticated() @@ -230,7 +230,7 @@ def test_run_command_auto_login_timeout_maps_to_auth_error(monkeypatch): def fail_login(**_kwargs): raise NotAuthenticated("Login timed out waiting for the browser.") - monkeypatch.setattr("aai_cli.context.run_login_flow", fail_login) + monkeypatch.setattr("aai_cli.auth.run_login_flow", fail_login) def body(state, json_mode): raise NotAuthenticated() @@ -246,7 +246,7 @@ def test_run_command_skips_auto_login_for_rejected_env_key(monkeypatch): _force_interactive(monkeypatch) monkeypatch.setenv(config.ENV_API_KEY, "sk_bad") monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("env key retry cannot be fixed")), ) @@ -270,21 +270,21 @@ def body(state, json_mode): def test_env_override_warning_when_flag_contradicts_profile(): config.set_profile_env("default", "sandbox000") - assert env_override_warning(AppState(env="production")) is not None + assert AppState(env="production").env_override_warning() is not None def test_env_override_warning_none_when_flag_matches_profile(): config.set_profile_env("default", "sandbox000") - assert env_override_warning(AppState(env="sandbox000")) is None + assert AppState(env="sandbox000").env_override_warning() is None def test_env_override_warning_none_without_explicit_flag(): config.set_profile_env("default", "sandbox000") - assert env_override_warning(AppState(env=None)) is None + assert AppState(env=None).env_override_warning() is None def test_env_override_warning_none_when_profile_has_no_env(): - assert env_override_warning(AppState(env="production")) is None + assert AppState(env="production").env_override_warning() is None def test_env_override_warning_when_aai_env_contradicts_profile(monkeypatch): @@ -292,7 +292,7 @@ def test_env_override_warning_when_aai_env_contradicts_profile(monkeypatch): # does, so it must warn too — and name AAI_ENV as the source. config.set_profile_env("default", "sandbox000") monkeypatch.setenv("AAI_ENV", "production") - warning = env_override_warning(AppState(env=None)) + warning = AppState(env=None).env_override_warning() assert warning is not None assert "AAI_ENV" in warning # The warning now ends with an actionable remediation naming how to fix it. @@ -303,7 +303,7 @@ def test_env_override_warning_flag_beats_aai_env(monkeypatch): # An explicit --env wins precedence and is the named source, not AAI_ENV. config.set_profile_env("default", "sandbox000") monkeypatch.setenv("AAI_ENV", "sandbox000") - warning = env_override_warning(AppState(env="production")) + warning = AppState(env="production").env_override_warning() assert warning is not None assert "--env" in warning assert "Drop --env" in warning @@ -312,15 +312,15 @@ def test_env_override_warning_flag_beats_aai_env(monkeypatch): def test_env_override_warning_none_when_aai_env_matches_profile(monkeypatch): config.set_profile_env("default", "sandbox000") monkeypatch.setenv("AAI_ENV", "sandbox000") - assert env_override_warning(AppState(env=None)) is None + assert AppState(env=None).env_override_warning() is None def test_resolve_session_returns_account_and_jwt(): from aai_cli import config - from aai_cli.context import AppState, resolve_session + from aai_cli.context import AppState config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) - account_id, jwt = resolve_session(AppState()) + account_id, jwt = AppState().resolve_session() assert account_id == 42 assert jwt == "jwt_1" @@ -328,11 +328,11 @@ def test_resolve_session_returns_account_and_jwt(): def test_resolve_session_raises_when_no_session(): import pytest - from aai_cli.context import AppState, resolve_session + from aai_cli.context import AppState from aai_cli.errors import NotAuthenticated with pytest.raises(NotAuthenticated): - resolve_session(AppState()) + AppState().resolve_session() def test_resolve_session_raises_when_only_account_id_missing(monkeypatch): @@ -341,26 +341,26 @@ def test_resolve_session_raises_when_only_account_id_missing(monkeypatch): # fall through and return a None account id instead of failing cleanly). import pytest - from aai_cli.context import AppState, resolve_session + from aai_cli.context import AppState from aai_cli.errors import NotAuthenticated monkeypatch.setattr(config, "get_session", lambda _profile: {"jwt": "j", "token": "t"}) monkeypatch.setattr(config, "get_account_id", lambda _profile: None) with pytest.raises(NotAuthenticated): - resolve_session(AppState()) + AppState().resolve_session() def test_resolve_session_raises_when_only_jwt_missing(monkeypatch): # The mirror case: an account id but no stored session must also raise. import pytest - from aai_cli.context import AppState, resolve_session + from aai_cli.context import AppState from aai_cli.errors import NotAuthenticated monkeypatch.setattr(config, "get_session", lambda _profile: None) monkeypatch.setattr(config, "get_account_id", lambda _profile: 42) with pytest.raises(NotAuthenticated): - resolve_session(AppState()) + AppState().resolve_session() def test_run_command_auto_logs_in_when_env_key_set_but_error_is_not_a_rejection(monkeypatch): @@ -375,7 +375,7 @@ def fake_login(*, json_mode=False): ran["login"] += 1 return LoginResult(api_key="sk_auto", session_jwt="j", session_token="t", account_id=7) - monkeypatch.setattr("aai_cli.context.run_login_flow", fake_login) + monkeypatch.setattr("aai_cli.auth.run_login_flow", fake_login) def body(state, json_mode): raise NotAuthenticated() # rejected_key is False: not a key rejection @@ -387,14 +387,12 @@ def body(state, json_mode): def test_appstate_methods_are_the_single_source_of_truth(): - # The module-level resolve_* helpers are thin adapters over the AppState methods; - # both must agree, and the precedence (default profile + default env) must hold. + # The precedence (default profile + default env) must hold. config.set_profile_env("default", "sandbox000") state = AppState(env="production") assert state.resolve_profile() == "default" assert state.resolve_environment().name == "production" # --env wins over profile - assert state.env_override_warning() == env_override_warning(state) assert state.env_override_warning() is not None # production contradicts sandbox000 @@ -446,10 +444,8 @@ def test_resolve_session_suggestion_never_offers_api_key_env_var(): # never satisfy AMS session commands (they authenticate with the browser-session # JWT, not the API key). The session-specific suggestion must say what actually # works and drop the dead-end env-var advice. - from aai_cli.context import resolve_session - with pytest.raises(NotAuthenticated) as exc: - resolve_session(AppState()) + AppState().resolve_session() assert exc.value.suggestion is not None assert "browser" in exc.value.suggestion assert "API key alone" in exc.value.suggestion @@ -480,7 +476,7 @@ def fake(*, json_mode): seen["json_mode"] = json_mode return LoginResult(api_key="sk", session_jwt="j", session_token="t", account_id=1) - monkeypatch.setattr("aai_cli.context.run_login_flow", fake) + monkeypatch.setattr("aai_cli.auth.run_login_flow", fake) persist_browser_login("default", "production") assert seen["json_mode"] is False # human prose stays the default persist_browser_login("default", "production", json_mode=True) diff --git a/tests/test_help_rendering.py b/tests/test_help_rendering.py index 431dc071..42d7960c 100644 --- a/tests/test_help_rendering.py +++ b/tests/test_help_rendering.py @@ -121,8 +121,12 @@ def test_root_level_json_flag_points_at_subcommand_placement(): [ (["doctor", "-q"], "assembly -q doctor"), (["speak", "hi", "--sandbox"], "assembly --sandbox speak"), + # --version is declared on the root callback like any other global flag; the + # hint set is derived from those declarations, so it gets the hint too (the + # old hand-maintained list missed it). + (["doctor", "--version"], "assembly --version doctor"), ], - ids=["-q", "--sandbox"], + ids=["-q", "--sandbox", "--version"], ) def test_global_flag_on_subcommand_points_at_root_placement(argv, expected): result = runner.invoke(app, argv, env={"COLUMNS": "300"}) diff --git a/tests/test_keys.py b/tests/test_keys.py index 53ff2bb6..9bd40524 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -86,7 +86,7 @@ def test_keys_create_rejects_default_project_without_int_id(mocker): def test_keys_list_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) list_projects = mocker.patch( "aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=[] ) @@ -214,7 +214,7 @@ def test_keys_create_rejects_empty_name(monkeypatch, mocker): # not logged in (no login flow may start for a request that can never be valid). monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("login must not start")), ) create = mocker.patch("aai_cli.commands.keys.ams.create_token", autospec=True) diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index e66c4fd5..9c453cc6 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -240,7 +240,7 @@ def test_llm_missing_prompt_exits_2(monkeypatch): def test_llm_unauthenticated_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): raise AssertionError(f"LLM request should not run after auto-login: {api_key}") diff --git a/tests/test_login.py b/tests/test_login.py index 993e12fc..4f20bc9c 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -67,7 +67,7 @@ def test_whoami_human_render_shows_detail_rows(monkeypatch, mocker): def test_whoami_unauthenticated_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _fake_login_result) validate = mocker.patch( "aai_cli.commands.login.client.validate_key", autospec=True, return_value=True ) @@ -87,14 +87,14 @@ def test_logout_clears_key(): def test_login_oauth_flow_stores_returned_key(monkeypatch): - monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _fake_login_result) result = runner.invoke(app, ["login"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_from_oauth" def test_login_oauth_persists_session(monkeypatch): - monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _fake_login_result) result = runner.invoke(app, ["login"]) assert result.exit_code == 0 assert config.get_session("default") == {"jwt": "jwt_x", "token": "tok_x"} @@ -120,7 +120,7 @@ def test_login_api_key_flag_warns_account_commands_need_browser_login(mocker): def test_login_browser_path_has_no_api_key_only_note(monkeypatch): # The browser login DOES create a session, so the api-key-only caveat must not show. - monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _fake_login_result) result = runner.invoke(app, ["login"]) assert result.exit_code == 0 assert "Signed in as default" in result.output @@ -155,7 +155,7 @@ def test_login_oauth_flow_failure_exits_nonzero(monkeypatch): def boom(**_kwargs): raise APIError("Login failed: the server returned an unexpected response.") - monkeypatch.setattr("aai_cli.context.run_login_flow", boom) + monkeypatch.setattr("aai_cli.auth.run_login_flow", boom) result = runner.invoke(app, ["login"]) assert result.exit_code != 0 assert config.get_api_key("default") is None @@ -168,7 +168,7 @@ def test_login_timeout_is_auth_typed_with_exit_4(monkeypatch): def timed_out(**_kwargs): raise NotAuthenticated("Login timed out waiting for the browser.") - monkeypatch.setattr("aai_cli.context.run_login_flow", timed_out) + monkeypatch.setattr("aai_cli.auth.run_login_flow", timed_out) result = runner.invoke(app, ["login", "--json"]) assert result.exit_code == 4 payload = json.loads(result.output) @@ -180,7 +180,7 @@ def test_login_empty_api_key_flag_is_usage_error(monkeypatch): # `--api-key "$UNSET_VAR"` (an explicit empty value) must not silently fall # into the browser flow. monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw( AssertionError("empty --api-key must not start a browser") ), @@ -193,7 +193,7 @@ def test_login_empty_api_key_flag_is_usage_error(monkeypatch): def test_login_whitespace_api_key_flag_is_usage_error(monkeypatch): monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw( AssertionError("blank --api-key must not start a browser") ), @@ -213,7 +213,7 @@ def test_login_empty_api_key_flag_json_error_shape(): def test_login_api_key_flag_still_bypasses_oauth(monkeypatch, mocker): monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("OAuth must not run with --api-key")), ) mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) @@ -223,7 +223,7 @@ def test_login_api_key_flag_still_bypasses_oauth(monkeypatch, mocker): def test_login_binds_env_to_profile(monkeypatch): - monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _fake_login_result) result = runner.invoke(app, ["--env", "sandbox000", "login"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_from_oauth" @@ -232,7 +232,7 @@ def test_login_binds_env_to_profile(monkeypatch): def test_sandbox_flag_is_shortcut_for_env(monkeypatch): monkeypatch.setattr( - "aai_cli.context.run_login_flow", lambda *, json_mode=False: _login_result("sk_x") + "aai_cli.auth.run_login_flow", lambda *, json_mode=False: _login_result("sk_x") ) result = runner.invoke(app, ["--sandbox", "login"]) assert result.exit_code == 0 @@ -412,7 +412,7 @@ def timed_out(**_kwargs): raise NotAuthenticated("Login timed out waiting for the browser.") monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", timed_out) + monkeypatch.setattr("aai_cli.auth.run_login_flow", timed_out) result = runner.invoke(app, ["login"]) assert result.exit_code == 4 assert calls["n"] == 1 # the command's own attempt only; no auto-login retry @@ -425,7 +425,7 @@ def test_logout_never_auto_logs_in(monkeypatch): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("logout must never start a login")), ) monkeypatch.setattr( diff --git a/tests/test_login_guards.py b/tests/test_login_guards.py index 2fa62a25..c8d4c031 100644 --- a/tests/test_login_guards.py +++ b/tests/test_login_guards.py @@ -23,7 +23,7 @@ def test_login_browser_flow_fails_fast_when_keyring_unusable(monkeypatch): # final keyring write: probe the keyring first and point at what works. monkeypatch.setattr("aai_cli.commands.login.config.keyring_usable", lambda: False) monkeypatch.setattr( - "aai_cli.context.run_login_flow", + "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("browser flow must not start")), ) result = runner.invoke(app, ["login"]) @@ -70,7 +70,7 @@ def fake(*, json_mode): seen["json_mode"] = json_mode return _fake_login_result() - monkeypatch.setattr("aai_cli.context.run_login_flow", fake) + monkeypatch.setattr("aai_cli.auth.run_login_flow", fake) assert runner.invoke(app, ["login", "--json"]).exit_code == 0 assert seen["json_mode"] is True assert runner.invoke(app, ["login"]).exit_code == 0 diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index f83fe904..dca3d25b 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -180,19 +180,19 @@ def test_onboard_sorts_first_in_quick_start() -> None: assert result.output.index("onboard") < result.output.index("init") -def test_interactive_session_requires_both_ends_tty(monkeypatch: pytest.MonkeyPatch) -> None: - from aai_cli import main as main_mod +def test_interactive_stdio_requires_both_ends_tty(monkeypatch: pytest.MonkeyPatch) -> None: + from aai_cli import stdio # Both TTY -> interactive. monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("sys.stdout.isatty", lambda: True) - assert main_mod._interactive_session() is True + assert stdio.interactive_stdio() is True # Only one end a TTY -> NOT interactive. An `or` mutant would call this interactive. monkeypatch.setattr("sys.stdout.isatty", lambda: False) - assert main_mod._interactive_session() is False + assert stdio.interactive_stdio() is False monkeypatch.setattr("sys.stdin.isatty", lambda: False) monkeypatch.setattr("sys.stdout.isatty", lambda: True) - assert main_mod._interactive_session() is False + assert stdio.interactive_stdio() is False def test_bare_aai_with_key_shows_help_no_offer(monkeypatch: pytest.MonkeyPatch) -> None: @@ -221,10 +221,9 @@ def test_bare_aai_quiet_suppresses_banner(monkeypatch: pytest.MonkeyPatch) -> No def test_bare_aai_offers_wizard_when_no_key(monkeypatch: pytest.MonkeyPatch) -> None: - from aai_cli import main as main_mod from aai_cli.onboard.sections import WizardContext - monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.stdio.interactive_stdio", lambda: True) monkeypatch.setattr("aai_cli.main.typer.confirm", lambda *a, **k: True) captured: dict[str, object] = {} @@ -244,9 +243,7 @@ def _fake_run(prompter: object, ctx: WizardContext) -> int: def test_bare_aai_empty_confirm_defaults_to_yes(monkeypatch: pytest.MonkeyPatch) -> None: # The offer prompt defaults to Yes: an empty answer runs the wizard. # A `default=False` mutant would instead decline and print help. - from aai_cli import main as main_mod - - monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.stdio.interactive_stdio", lambda: True) ran = {"called": False} def _fake_run(prompter: object, ctx: object) -> int: @@ -262,11 +259,9 @@ def _fake_run(prompter: object, ctx: object) -> int: def test_bare_aai_interactive_with_key_shows_help_no_offer( monkeypatch: pytest.MonkeyPatch, ) -> None: - from aai_cli import main as main_mod - # Interactive session but a key is already present: _profile_has_key returns True, # so the wizard is never offered and help is printed instead. - monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.stdio.interactive_stdio", lambda: True) monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") called = {"confirm": False} monkeypatch.setattr( @@ -279,9 +274,7 @@ def test_bare_aai_interactive_with_key_shows_help_no_offer( def test_bare_aai_declined_offer_shows_help(monkeypatch: pytest.MonkeyPatch) -> None: - from aai_cli import main as main_mod - - monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.stdio.interactive_stdio", lambda: True) monkeypatch.setattr("aai_cli.main.typer.confirm", lambda *a, **k: False) called = {"v": False} diff --git a/tests/test_sessions_command.py b/tests/test_sessions_command.py index ab65df67..a75f9e75 100644 --- a/tests/test_sessions_command.py +++ b/tests/test_sessions_command.py @@ -205,7 +205,7 @@ def test_sessions_get_renders_detail(monkeypatch, mocker): def test_sessions_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) list_ = mocker.patch( "aai_cli.commands.sessions.ams.list_streaming", autospec=True, return_value={"data": []} ) diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 0f5f381f..676fd6ff 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -134,7 +134,7 @@ def fake(api_key, source, *, params, on_begin=None, on_turn=None, on_termination def test_stream_unauthenticated_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) def fake_stream_audio( api_key, source, *, params, on_begin=None, on_turn=None, on_termination=None diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index a847c0ad..cb5719ee 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -81,7 +81,7 @@ def test_transcribe_json_output(mocker): def test_transcribe_unauthenticated_runs_login_then_transcribes(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) tx = mocker.patch( "aai_cli.transcribe_exec.client.transcribe", autospec=True, diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index fbe6af3d..ad6b942e 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -139,7 +139,7 @@ def test_get_malformed_id_is_rejected_before_auth(monkeypatch, mocker): # No key configured: the cheap local id check must win over auth, so the user # is told to fix the id instead of being sent through login first. monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - login = mocker.patch("aai_cli.context.run_login_flow", side_effect=AssertionError("no login")) + login = mocker.patch("aai_cli.auth.run_login_flow", side_effect=AssertionError("no login")) get = mocker.patch("aai_cli.commands.transcripts.client.get_transcript", autospec=True) result = runner.invoke(app, ["transcripts", "get", "not-a-real-id!!"]) assert result.exit_code == 2 @@ -167,7 +167,7 @@ def test_list_renders_rows(mocker): def test_list_unauthenticated_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) - monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) + monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) rows = [{"id": "t1", "status": "completed"}] list_ = mocker.patch( "aai_cli.commands.transcripts.client.list_transcripts", autospec=True, return_value=rows diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 7d50a890..7f41a02b 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -239,7 +239,7 @@ def fake_popen(args, *, stdout, stderr, start_new_session, env): } return object() - monkeypatch.setattr(update_check.subprocess, "Popen", fake_popen) + monkeypatch.setattr("aai_cli.procs.subprocess.Popen", fake_popen) update_check.spawn_refresh() assert calls["args"][:3] == [sys.executable, "-m", "aai_cli"]