diff --git a/.gitignore b/.gitignore index b08ec1f4..c92cebfd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,9 @@ htmlcov/ # Local scratch scripts (often contain live keys) transcribe/ -# But do track the packaged template +# But do track the packaged template and the transcribe orchestration subpackage !aai_cli/init/templates/transcribe/ +!aai_cli/app/transcribe/ # Wrong tool for this project (hatchling + uv); never commit a poetry lock poetry.lock diff --git a/.importlinter b/.importlinter index 7cc2a3f0..0bb57bd3 100644 --- a/.importlinter +++ b/.importlinter @@ -3,94 +3,61 @@ root_package = aai_cli include_external_packages = True [importlinter:contract:1] -name = Core modules do not import command modules +name = Layered architecture +type = layers +; The flat root pile of library modules is gone: each layer is now a package, +; so intra-layer imports stay free and only the *direction* between layers is +; enforced. Higher may import lower, never the reverse. This single declarative +; contract replaces the old hand-maintained 47-module `forbidden` list (which +; the comments admitted had "silently drifted"). The CLI framework glue that +; assembles the command layer — main, command_registry, help_panels, options — +; stays at the package root, above `commands`, and is intentionally unlisted +; (it legitimately imports the command modules to discover/register them). +; Feature slices (agent, tts, streaming, code_gen, init, auth, onboard) are +; likewise unlisted vertical slices governed by contract 2. +layers = + commands + app + ui + core +containers = + aai_cli + +[importlinter:contract:2] +name = Feature slices do not import commands type = forbidden -; A command's private run-logic now lives inside its own package -; (aai_cli/commands//_exec.py, …) and is governed by contract 2's -; independence rule, so those modules are intentionally absent here. The -; *_exec modules that remain are the ones still at the package root because -; they're shared beyond their command (transcribe_exec/render/batch and -; init_exec are reused by the onboarding wizard; setup_exec/doctor_checks too). +; The vertical feature slices sit beside the layered stack (they internally mix +; protocol + rendering, so they aren't a single horizontal layer). They must +; never reach up into the command layer. Adding a feature slice is rare and +; deliberate, so this short list does not drift the way the old core list did. source_modules = aai_cli.agent - aai_cli.argscan aai_cli.auth - aai_cli.choices - aai_cli.client aai_cli.code_gen - aai_cli.coding_agent - aai_cli.config - aai_cli.config_builder - aai_cli.context - aai_cli.debuglog - aai_cli.doctor_checks - aai_cli.environments - aai_cli.errors - aai_cli.follow - aai_cli.help_panels - aai_cli.help_text - aai_cli.hotkey aai_cli.init - aai_cli.init_exec - aai_cli.jsonshape - aai_cli.llm - aai_cli.mediafile - aai_cli.microphone aai_cli.onboard - aai_cli.options - aai_cli.output - aai_cli.procs - aai_cli.remotefs - aai_cli.render - aai_cli.setup_exec - aai_cli.stdio - aai_cli.steps aai_cli.streaming - aai_cli.sync_stt - aai_cli.telemetry - aai_cli.theme - aai_cli.timeparse - aai_cli.transcribe_batch - aai_cli.transcribe_exec - aai_cli.transcribe_render - aai_cli.transcribe_sources - aai_cli.transcribe_validate aai_cli.tts - aai_cli.typer_patches - aai_cli.update_check - aai_cli.wer - aai_cli.ws - aai_cli.youtube forbidden_modules = aai_cli.commands -[importlinter:contract:2] +[importlinter:contract:3] name = Command modules are independent type = independence -; Wildcard so every module under aai_cli/commands/ is covered automatically — -; the previous enumerated list had silently drifted (onboard and speak were -; missing, so nothing forbade them from importing sibling commands). +; Wildcard so every module under aai_cli/commands/ is covered automatically. modules = aai_cli.commands.* -[importlinter:contract:3] -name = Library layers do not depend on Rich rendering +[importlinter:contract:4] +name = Core library and the testable command helpers stay Rich-free type = forbidden +; The layered contract keeps `core` from importing the `ui` layer, but Rich is +; an external package, so "no Rich below the UI layer" still needs an explicit +; forbidden edge. The two command helpers are pure data/selection logic kept +; Rich-free so their tests never need a console. source_modules = - aai_cli.argscan - aai_cli.client + aai_cli.core aai_cli.commands.clip._select aai_cli.commands.evaluate._data - aai_cli.config - aai_cli.config_builder - aai_cli.debuglog - aai_cli.environments - aai_cli.errors - aai_cli.hotkey - aai_cli.llm - aai_cli.remotefs - aai_cli.sync_stt - aai_cli.telemetry - aai_cli.wer forbidden_modules = rich diff --git a/aai_cli/AGENTS.md b/aai_cli/AGENTS.md index 9e3dccee..c2bc1b7e 100644 --- a/aai_cli/AGENTS.md +++ b/aai_cli/AGENTS.md @@ -9,11 +9,52 @@ hooks, conventions) live in the root `AGENTS.md`; test-suite guidance lives in A Typer CLI. `aai_cli/main.py` builds the `app` and registers every command module discovered by `aai_cli/command_registry.py`. Typer/Click/Rich overrides (help palette, column clipping, pipe-safe consoles, Click error formatting) -live in `aai_cli/typer_patches.py` — one file to fix when a dependency upgrade -breaks a patch; each patch documents the upstream behavior it overrides. +live in `aai_cli/ui/typer_patches.py` — one file to fix when a dependency +upgrade breaks a patch; each patch documents the upstream behavior it overrides. `run()` is the entry point and swallows `BrokenPipeError` (closed downstream pipe → exit 0). +### Package layout (layered) + +The package is organized as a layered stack, enforced by `.importlinter` +contract 1 (`type = layers`, `commands > app > ui > core`). Each layer is a +single package, so imports *within* a layer are free and only the *direction* +between layers is enforced — higher may import lower, never the reverse: + +- **`commands/`** — the Typer sub-apps (top of the stack; see the convention + below). +- **`app/`** — orchestration / shared run-logic that wires features together and + is reused beyond one command: `context`, the `transcribe/` subpackage + (`run`/`render`/`batch`/`sources`/`validate`), `init_exec`, `setup_exec`, + `doctor_checks`, `coding_agent`, `mediafile` (it renders via the UI layer, so + it sits here, not in `core`). +- **`ui/`** — Rich rendering: `output`, `render`, `theme`, `steps`, `follow`, + `help_text`, `typer_patches`, `update_check`. +- **`core/`** — the Rich-free library layer: `client`, `config`, + `config_builder`, `environments`, `env`, `errors`, `llm`, `telemetry`, + `debuglog`, `remotefs`, `sync_stt`, `hotkey`, `ws`, `youtube`, `wer`, + `argscan`, `jsonshape`, `timeparse`, `microphone`, `procs`, `stdio`, + `choices`. Contract 4 also forbids `rich` here, so "no Rich below the UI + layer" is structural. + +Three things sit *beside* the stack, intentionally unlisted in the layers +contract: + +- **CLI framework glue at the package root** — `main`, `command_registry`, + `help_panels`, `options`. They assemble/define the command layer (and + `command_registry` imports the command modules to discover them), so they live + *above* `commands` and stay at the root. +- **Feature slices** — `agent/`, `tts/`, `streaming/`, `code_gen/`, `init/`, + `auth/`, `onboard/`. These are cohesive vertical slices that internally mix + protocol + rendering, so they aren't a single horizontal layer; contract 2 + forbids them from importing `commands`. + +A new top-level module must land in one of these buckets; +`tests/test_importlinter_coverage.py` fails loudly if one escapes the partition. +The intra-layer split is invisible to importers in the *same* layer, but always +import across layers by the full path (`from aai_cli.core import config`, +`from aai_cli.ui import output`, `from aai_cli.app.context import AppState`). + ### Command layer & the registration convention Each entry under `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, @@ -34,11 +75,13 @@ private and avoids colliding with the package's own command functions (the `webhooks` package binds a `listen` command, so its module is `_listen.py`, not `listen.py`). This is the Prefect/spaCy convention: flat file by default, promote to a folder only when the command has earned multiple modules. Run-logic -that's **shared beyond one command stays at the package root**, not inside a -command package — `transcribe_exec`/`transcribe_render`/`transcribe_batch` and -`init_exec` are reused by the onboarding wizard (`onboard/sections.py`), so they -live at the root alongside `doctor_checks`/`setup_exec` rather than under -`commands/transcribe/` or `commands/init/`. +that's **shared beyond one command lives in the `app/` layer**, not inside a +command package — the `app/transcribe/` subpackage (`run`/`render`/`batch`/ +`sources`/`validate` — promoted from flat `transcribe_*` modules once the family +outgrew one file) and `app/init_exec` are reused by the onboarding wizard +(`onboard/sections.py`), so they live in `app/` alongside +`doctor_checks`/`setup_exec` rather than under `commands/transcribe/` or +`commands/init/`. **Adding a command is purely additive — no shared file edits.** Every command module declares a module-level @@ -68,19 +111,19 @@ Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures. -**Command modules are import-linter-independent** (`.importlinter` contract 2, +**Command modules are import-linter-independent** (`.importlinter` contract 3, wildcarded over `aai_cli.commands.*` so new modules are covered automatically). -Logic shared between commands lives in the core layer: `doctor_checks.py` -(diagnostics shared by `doctor` and onboarding) and `setup_exec.py` (installer -steps shared by `setup` and onboarding) are the precedent — never import one -command module from another. +Logic shared between commands lives in the `app/` layer: `app/doctor_checks.py` +(diagnostics shared by `doctor` and onboarding) and `app/setup_exec.py` +(installer steps shared by `setup` and onboarding) are the precedent — never +import one command module from another. **Options/run split for flag-heavy commands** (gh-CLI style): the Typer function only parses argv into a frozen `Options` dataclass and hands it to a module-level `run_(opts, state, *, json_mode)` through a thin lambda adapter in `run_command(ctx, ..., json=...)`. The run commands follow it — -`commands/stream/_exec.py` (the reference implementation), `transcribe_exec.py` -(at the root — shared with onboarding), `commands/agent/_exec.py`, +`commands/stream/_exec.py` (the reference implementation), `app/transcribe/run.py` +(in the `app/` layer — shared with onboarding), `commands/agent/_exec.py`, `commands/speak/_exec.py`, `commands/llm/_exec.py`, `commands/clip/_exec.py`, `commands/dictate/_exec.py`. Because the run path is a plain function of data, tests construct options directly (`dataclasses.replace` off a defaults instance, see @@ -92,22 +135,22 @@ heavily-reworked commands with long bodies; small commands keep the inline ### Cross-cutting state (resolution order matters) -- **`context.py`** — `AppState` (profile, env) is attached to the Typer context in the root `@app.callback()`. `run_command` is the standard command wrapper. -- **`config.py`** — profiles persisted in `config.toml` (via `platformdirs`); the **API key lives only in the OS keyring** (`KEYRING_SERVICE = "assemblyai-cli"`), never in a dotfile. Key resolution order: `--api-key` flag (validation paths only) → `ASSEMBLYAI_API_KEY` env → keyring. **Run commands deliberately expose no `--api-key` flag** so keys can't leak into `ps`/shell history. -- **`environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is **`production`**; use `--sandbox` (or `--env sandbox000` / `AAI_ENV`) to target the sandbox. The active environment is a process-global set once at startup; precedence: `--env` → `AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it. -- **`client.py`** — thin wrappers over the `assemblyai` SDK (`transcribe`, `list_transcripts`, `stream_audio`, etc.). It normalizes SDK exceptions: auth failures become a single clean `auth_failure()` `CLIError`; everything else becomes `APIError`. New SDK calls should follow this try/except shape. -- **`errors.py`** — the `CLIError` hierarchy (each with `error_type` + `exit_code`). `output.py` emits errors to **stderr**; stdout stays clean for pipelines. `--json` switches to machine-readable output; it is never auto-enabled — `output.resolve_json()` deliberately keeps human text the default even when piped or agent-run. -- **Raw `subprocess` and `os.environ`/`os.getenv` are fenced by ruff `banned-api` (TID251).** Only the modules allowlisted in `pyproject.toml`'s `per-file-ignores` may call them — process spawning is meant to go through `procs.py`, and environment reads through the config/env-resolution layer. A new module reaching for either trips the gate, so adding one is a deliberate, reviewable allowlist edit (the Deno toolchain's per-crate `clippy.toml` model). Tests and `scripts/` are exempt. -- **`debuglog.py`** — the root `-v/--verbose` flag (count: `-v` request-level at INFO, `-vv` wire-level at DEBUG). The CLI normally configures no logging, and the realtime paths *silence* library loggers (`ws.py`, `streaming/diagnostics.py`); verbose mode installs one redacting stderr handler and those silencers stand down. Secrets are registered at their resolution choke points (`config.resolve_api_key`, `AppState.resolve_session`) and masked in every rendered record — websockets logs the raw Authorization header at DEBUG, so masking lives in the formatter, not at call sites. Stdlib-only on purpose: `config` (a Rich-free layer) imports it. +- **`app/context.py`** — `AppState` (profile, env) is attached to the Typer context in the root `@app.callback()`. `run_command` is the standard command wrapper. +- **`core/config.py`** — profiles persisted in `config.toml` (via `platformdirs`); the **API key lives only in the OS keyring** (`KEYRING_SERVICE = "assemblyai-cli"`), never in a dotfile. Key resolution order: `--api-key` flag (validation paths only) → `ASSEMBLYAI_API_KEY` env → keyring. **Run commands deliberately expose no `--api-key` flag** so keys can't leak into `ps`/shell history. +- **`core/environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is **`production`**; use `--sandbox` (or `--env sandbox000` / `AAI_ENV`) to target the sandbox. The active environment is a process-global set once at startup; precedence: `--env` → `AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it. +- **`core/client.py`** — thin wrappers over the `assemblyai` SDK (`transcribe`, `list_transcripts`, `stream_audio`, etc.). It normalizes SDK exceptions: auth failures become a single clean `auth_failure()` `CLIError`; everything else becomes `APIError`. New SDK calls should follow this try/except shape. +- **`core/errors.py`** — the `CLIError` hierarchy (each with `error_type` + `exit_code`). `ui/output.py` emits errors to **stderr**; stdout stays clean for pipelines. `--json` switches to machine-readable output; it is never auto-enabled — `output.resolve_json()` deliberately keeps human text the default even when piped or agent-run. +- **Raw `subprocess` and `os.environ`/`os.getenv` are fenced by ruff `banned-api` (TID251).** Environment access has a single chokepoint: **`core/env.py`** is the only module allowlisted for raw `os.environ` — every other module reads/writes the environment through `env.get`/`env.child_env`/`env.force_color`/… (callers still own their variable *names*, e.g. `config.ENV_API_KEY`). Process spawning is the sibling boundary, but unlike env reads it's genuinely diverse (sync-capture, long-lived `Popen` with pipes, detached children), so each module that shells out to its specific tool stays individually allowlisted rather than funnelling through one module. A new module reaching past either boundary trips the gate, so adding one is a deliberate, reviewable edit (the Deno toolchain's per-crate `clippy.toml` model). Tests and `scripts/` are exempt. +- **`core/debuglog.py`** — the root `-v/--verbose` flag (count: `-v` request-level at INFO, `-vv` wire-level at DEBUG). The CLI normally configures no logging, and the realtime paths *silence* library loggers (`ws.py`, `streaming/diagnostics.py`); verbose mode installs one redacting stderr handler and those silencers stand down. Secrets are registered at their resolution choke points (`config.resolve_api_key`, `AppState.resolve_session`) and masked in every rendered record — websockets logs the raw Authorization header at DEBUG, so masking lives in the formatter, not at call sites. Stdlib-only on purpose: `config` (a Rich-free layer) imports it. ### Feature subsystems - **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. -- **`sync_stt.py`** + **`hotkey.py`** + `commands/dictate.py` — `assembly dictate`: push-to-talk dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). `hotkey.TerminalKeys` scopes stdin into cbreak (Ctrl-C still signals) and reads single keypresses; `dictate_exec._record` polls it with a zero timeout between ~100 ms mic chunks. All three boundaries (keys, mic, HTTP) are injectable, so the suite never needs a real terminal — `tests/test_hotkey.py` drives a pty pair for the termios behavior. +- **`core/sync_stt.py`** + **`core/hotkey.py`** + `commands/dictate/` — `assembly dictate`: push-to-talk dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). `hotkey.TerminalKeys` scopes stdin into cbreak (Ctrl-C still signals) and reads single keypresses; `dictate_exec._record` polls it with a zero timeout between ~100 ms mic chunks. All three boundaries (keys, mic, HTTP) are injectable, so the suite never needs a real terminal — `tests/test_hotkey.py` drives a pty pair for the termios behavior. - **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). - **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). - **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). - **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. - **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`. -- **`telemetry.py`** — anonymous, opt-out usage telemetry (Supabase-CLI model): `context.run_command` wraps each command body in `telemetry.track(ctx.command_path)`, which dispatches one allow-listed event (command path, outcome/exit code, duration, version/OS, and on failure the error message capped at 500 chars — never args or account data) to the Datadog logs intake via a **detached flusher subprocess** (the hidden `assembly telemetry flush`), so commands never wait on telemetry. `SHIPPED_CLIENT_TOKEN` is a committed write-only Datadog *client* token (`pub…`, embeddable by design — never an API key; `AAI_TELEMETRY_CLIENT_TOKEN` overrides). The test suite blanks it via an autouse conftest fixture so no test ever spawns a real flusher. Opt-out: `AAI_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1` / `assembly telemetry disable` (persisted as `telemetry_enabled` in config.toml, alongside the random `device_id`). Send-side failures are swallowed (`OSError`/`CLIError`) — telemetry must never break a command. -- **`commands/setup.py`** + **`setup_exec.py`** — `assembly setup install/status/remove` wires a coding agent up to AssemblyAI by installing three artifacts: the `assemblyai-docs` docs MCP (via `claude mcp add`), the AssemblyAI skill (via `npx skills add`), and the bundled `aai-cli` skill (copied out of the wheel, no network). Missing `claude`/`npx` is reported and skipped, not an error. The step implementations live in `aai_cli/setup_exec.py` and the presence probes (docs MCP registered, skills on disk) in `aai_cli/coding_agent.py`, so `assembly doctor` (via `doctor_checks.py`) and the onboarding wizard share them without command modules importing each other. +- **`core/telemetry.py`** — anonymous, opt-out usage telemetry (Supabase-CLI model): `context.run_command` wraps each command body in `telemetry.track(ctx.command_path)`, which dispatches one allow-listed event (command path, outcome/exit code, duration, version/OS, and on failure the error message capped at 500 chars — never args or account data) to the Datadog logs intake via a **detached flusher subprocess** (the hidden `assembly telemetry flush`), so commands never wait on telemetry. `SHIPPED_CLIENT_TOKEN` is a committed write-only Datadog *client* token (`pub…`, embeddable by design — never an API key; `AAI_TELEMETRY_CLIENT_TOKEN` overrides). The test suite blanks it via an autouse conftest fixture so no test ever spawns a real flusher. Opt-out: `AAI_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1` / `assembly telemetry disable` (persisted as `telemetry_enabled` in config.toml, alongside the random `device_id`). Send-side failures are swallowed (`OSError`/`CLIError`) — telemetry must never break a command. +- **`commands/setup.py`** + **`app/setup_exec.py`** — `assembly setup install/status/remove` wires a coding agent up to AssemblyAI by installing three artifacts: the `assemblyai-docs` docs MCP (via `claude mcp add`), the AssemblyAI skill (via `npx skills add`), and the bundled `aai-cli` skill (copied out of the wheel, no network). Missing `claude`/`npx` is reported and skipped, not an error. The step implementations live in `aai_cli/app/setup_exec.py` and the presence probes (docs MCP registered, skills on disk) in `aai_cli/app/coding_agent.py`, so `assembly doctor` (via `app/doctor_checks.py`) and the onboarding wizard share them without command modules importing each other. diff --git a/aai_cli/agent/audio.py b/aai_cli/agent/audio.py index cfc4a340..1a6580c2 100644 --- a/aai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -6,8 +6,8 @@ from collections.abc import Callable, Iterator from typing import Any -from aai_cli.errors import CLIError -from aai_cli.microphone import default_rate, import_sounddevice, resample_pcm16 +from aai_cli.core.errors import CLIError +from aai_cli.core.microphone import default_rate, import_sounddevice, resample_pcm16 SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate diff --git a/aai_cli/agent/render.py b/aai_cli/agent/render.py index 2c6fc669..70a86ecc 100644 --- a/aai_cli/agent/render.py +++ b/aai_cli/agent/render.py @@ -4,7 +4,7 @@ from rich.text import Text -from aai_cli.render import BaseRenderer +from aai_cli.ui.render import BaseRenderer def _labeled(label: str, body: str, *, style: str = "aai.label") -> Text: diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index 88ea78b4..6c6e629b 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -8,9 +8,9 @@ from dataclasses import dataclass from typing import Any -from aai_cli import environments -from aai_cli import ws as wsutil -from aai_cli.errors import APIError, CLIError, NotAuthenticated +from aai_cli.core import environments +from aai_cli.core import ws as wsutil +from aai_cli.core.errors import APIError, CLIError, NotAuthenticated from aai_cli.streaming import diagnostics diff --git a/aai_cli/app/__init__.py b/aai_cli/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aai_cli/coding_agent.py b/aai_cli/app/coding_agent.py similarity index 100% rename from aai_cli/coding_agent.py rename to aai_cli/app/coding_agent.py diff --git a/aai_cli/context.py b/aai_cli/app/context.py similarity index 96% rename from aai_cli/context.py rename to aai_cli/app/context.py index 0d848855..2e734fcc 100644 --- a/aai_cli/context.py +++ b/aai_cli/app/context.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import sys from collections.abc import Callable from dataclasses import dataclass @@ -9,9 +8,10 @@ import keyring.errors import typer -from aai_cli import config, debuglog, environments, output, telemetry, update_check -from aai_cli.environments import Environment -from aai_cli.errors import APIError, CLIError, NotAuthenticated +from aai_cli.core import config, debuglog, env, environments, telemetry +from aai_cli.core.environments import Environment +from aai_cli.core.errors import APIError, CLIError, NotAuthenticated +from aai_cli.ui import output, update_check @dataclass @@ -85,7 +85,7 @@ def env_override_warning(self) -> str | None: """ if self.env is not None: source, selected = "--env", self.env - elif (from_env := os.environ.get("AAI_ENV")) is not None: + elif (from_env := env.get("AAI_ENV")) is not None: source, selected = "AAI_ENV", from_env else: return None @@ -151,7 +151,7 @@ def _should_auto_login(err: NotAuthenticated) -> bool: # so retrying cannot fix that case. `rejected_key` is the structured marker set # by auth_failure(); auth-owning commands (login/logout) opt out at their # run_command call site with auto_login=False instead of being name-matched here. - return not (os.environ.get(config.ENV_API_KEY) and err.rejected_key) + return not (env.get(config.ENV_API_KEY) and err.rejected_key) def _auto_login_and_exit(state: AppState, *, json_mode: bool) -> NoReturn: diff --git a/aai_cli/doctor_checks.py b/aai_cli/app/doctor_checks.py similarity index 98% rename from aai_cli/doctor_checks.py rename to aai_cli/app/doctor_checks.py index 494901da..e80b9ee4 100644 --- a/aai_cli/doctor_checks.py +++ b/aai_cli/app/doctor_checks.py @@ -16,8 +16,10 @@ from rich.markup import escape -from aai_cli import client, coding_agent, config, environments, output, theme -from aai_cli.errors import CLIError, NotAuthenticated +from aai_cli.app import coding_agent +from aai_cli.core import client, config, environments +from aai_cli.core.errors import CLIError, NotAuthenticated +from aai_cli.ui import output, theme class Check(TypedDict): diff --git a/aai_cli/init_exec.py b/aai_cli/app/init_exec.py similarity index 98% rename from aai_cli/init_exec.py rename to aai_cli/app/init_exec.py index 85390a8f..3accf7ae 100644 --- a/aai_cli/init_exec.py +++ b/aai_cli/app/init_exec.py @@ -18,10 +18,12 @@ import typer from rich.markup import escape -from aai_cli import __version__, environments, output, stdio, steps -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli import __version__ +from aai_cli.app.context import AppState +from aai_cli.core import environments, stdio +from aai_cli.core.errors import CLIError, UsageError from aai_cli.init import keys, runner, scaffold, templates +from aai_cli.ui import output, steps DEFAULT_PORT = 3000 diff --git a/aai_cli/mediafile.py b/aai_cli/app/mediafile.py similarity index 98% rename from aai_cli/mediafile.py rename to aai_cli/app/mediafile.py index 45f628a3..b285cef8 100644 --- a/aai_cli/mediafile.py +++ b/aai_cli/app/mediafile.py @@ -18,8 +18,9 @@ import assemblyai as aai -from aai_cli import client, output, youtube -from aai_cli.errors import APIError, CLIError, UsageError +from aai_cli.core import client, youtube +from aai_cli.core.errors import APIError, CLIError, UsageError +from aai_cli.ui import output def validate_local_media(media: Path, command: str, *, kind: str = "audio/video") -> None: diff --git a/aai_cli/setup_exec.py b/aai_cli/app/setup_exec.py similarity index 99% rename from aai_cli/setup_exec.py rename to aai_cli/app/setup_exec.py index e09832ff..82b0103d 100644 --- a/aai_cli/setup_exec.py +++ b/aai_cli/app/setup_exec.py @@ -12,8 +12,8 @@ from pathlib import Path from typing import TYPE_CHECKING -from aai_cli import coding_agent -from aai_cli.steps import Step, render_steps +from aai_cli.app import coding_agent +from aai_cli.ui.steps import Step, render_steps if TYPE_CHECKING: # Annotation only (PEP 563 string), so no runtime import. Import from diff --git a/aai_cli/app/transcribe/__init__.py b/aai_cli/app/transcribe/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aai_cli/transcribe_batch.py b/aai_cli/app/transcribe/batch.py similarity index 95% rename from aai_cli/transcribe_batch.py rename to aai_cli/app/transcribe/batch.py index 1a00fa23..0e4124ee 100644 --- a/aai_cli/transcribe_batch.py +++ b/aai_cli/app/transcribe/batch.py @@ -32,9 +32,11 @@ from rich.live import Live from rich.markup import escape -from aai_cli import client, jsonshape, llm, output, remotefs, theme, transcribe_exec -from aai_cli.errors import CLIError, NotAuthenticated -from aai_cli.transcribe_sources import SIDECAR_SUFFIX, URL_PREFIXES +from aai_cli.app.transcribe import run as transcribe_exec +from aai_cli.app.transcribe.sources import SIDECAR_SUFFIX, URL_PREFIXES +from aai_cli.core import client, jsonshape, llm, remotefs +from aai_cli.core.errors import CLIError, NotAuthenticated +from aai_cli.ui import output, theme if TYPE_CHECKING: import assemblyai as aai @@ -77,7 +79,10 @@ def resumable_record(sidecar: Path, *, digest: str | None) -> dict[str, object] def _dump_sidecar(sidecar: Path, record: dict[str, object]) -> None: - sidecar.write_text(json.dumps(record, indent=2, default=str) + "\n") + # `sidecar` is derived from the user's own CLI source argument and written next to + # that source by design (it's the resume marker). A local CLI has no attacker- + # controlled path input, so the path-traversal taint warning is a false positive. + sidecar.write_text(json.dumps(record, indent=2, default=str) + "\n") # nosemgrep def _write_sidecar( diff --git a/aai_cli/transcribe_render.py b/aai_cli/app/transcribe/render.py similarity index 98% rename from aai_cli/transcribe_render.py rename to aai_cli/app/transcribe/render.py index e787b166..68e287f3 100644 --- a/aai_cli/transcribe_render.py +++ b/aai_cli/app/transcribe/render.py @@ -6,7 +6,8 @@ from rich.console import Console from rich.text import Text -from aai_cli import jsonshape, theme +from aai_cli.core import jsonshape +from aai_cli.ui import theme def _fmt_ms(ms: int) -> str: diff --git a/aai_cli/transcribe_exec.py b/aai_cli/app/transcribe/run.py similarity index 96% rename from aai_cli/transcribe_exec.py rename to aai_cli/app/transcribe/run.py index c60c321d..c303b928 100644 --- a/aai_cli/transcribe_exec.py +++ b/aai_cli/app/transcribe/run.py @@ -15,24 +15,15 @@ import assemblyai as aai from rich.markup import escape -from aai_cli import ( - choices, - client, - code_gen, - config_builder, - jsonshape, - llm, - output, - remotefs, - stdio, - transcribe_render, - transcribe_sources, - transcribe_validate, - youtube, -) +from aai_cli import code_gen +from aai_cli.app.context import AppState +from aai_cli.app.transcribe import render as transcribe_render +from aai_cli.app.transcribe import sources as transcribe_sources +from aai_cli.app.transcribe import validate as transcribe_validate from aai_cli.code_gen.transcribe import render as render_transcribe_code -from aai_cli.context import AppState -from aai_cli.errors import UsageError +from aai_cli.core import choices, client, config_builder, jsonshape, llm, remotefs, stdio, youtube +from aai_cli.core.errors import UsageError +from aai_cli.ui import output def render_transform_steps(d: dict[str, Any]) -> str: @@ -311,7 +302,7 @@ def _print_show_code(opts: TranscribeOptions, merged: dict[str, object]) -> None def run_transcribe(opts: TranscribeOptions, state: AppState, *, json_mode: bool) -> None: """Execute one `assembly transcribe` invocation from already-parsed flags.""" # Module-load order: transcribe_batch imports this module, so import it lazily. - from aai_cli import transcribe_batch + from aai_cli.app.transcribe import batch as transcribe_batch transcribe_validate.validate_language_flags( opts.language_code, language_detection=opts.language_detection diff --git a/aai_cli/transcribe_sources.py b/aai_cli/app/transcribe/sources.py similarity index 98% rename from aai_cli/transcribe_sources.py rename to aai_cli/app/transcribe/sources.py index d8d582f4..e2dc6e9b 100644 --- a/aai_cli/transcribe_sources.py +++ b/aai_cli/app/transcribe/sources.py @@ -13,8 +13,8 @@ from pathlib import Path -from aai_cli import remotefs, stdio -from aai_cli.errors import UsageError, mutually_exclusive +from aai_cli.core import remotefs, stdio +from aai_cli.core.errors import UsageError, mutually_exclusive SIDECAR_SUFFIX = ".aai.json" diff --git a/aai_cli/transcribe_validate.py b/aai_cli/app/transcribe/validate.py similarity index 95% rename from aai_cli/transcribe_validate.py rename to aai_cli/app/transcribe/validate.py index 7c558392..3c43bfcc 100644 --- a/aai_cli/transcribe_validate.py +++ b/aai_cli/app/transcribe/validate.py @@ -14,8 +14,10 @@ import assemblyai as aai -from aai_cli import choices, output, transcribe_sources -from aai_cli.errors import UsageError, mutually_exclusive +from aai_cli.app.transcribe import sources as transcribe_sources +from aai_cli.core import choices +from aai_cli.core.errors import UsageError, mutually_exclusive +from aai_cli.ui import output # The PII policy strings the SDK accepts, validated client-side so a typo'd # --redact-pii-policy fails before any upload — mirroring how an unknown --config diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 69681fab..2682b225 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -2,9 +2,9 @@ import httpx2 as httpx -from aai_cli import jsonshape from aai_cli.auth import endpoints -from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.core import jsonshape +from aai_cli.core.errors import APIError, NotAuthenticated _TIMEOUT = 30.0 _HTTP_ERROR_MIN_STATUS = 400 diff --git a/aai_cli/auth/endpoints.py b/aai_cli/auth/endpoints.py index 5e14434b..595aa58f 100644 --- a/aai_cli/auth/endpoints.py +++ b/aai_cli/auth/endpoints.py @@ -1,9 +1,7 @@ from __future__ import annotations -import os - -from aai_cli import environments -from aai_cli.errors import CLIError +from aai_cli.core import env, environments +from aai_cli.core.errors import CLIError # Constant across environments. STYTCH_OAUTH_PROVIDER = "google" @@ -34,7 +32,7 @@ def loopback_port() -> int: path, so that ValueError would otherwise crash *every* ``assembly`` command (even ``--help``), not just ``assembly login``. """ - raw = os.environ.get("AAI_AUTH_PORT") + raw = env.get("AAI_AUTH_PORT") if raw is None: return _DEFAULT_LOOPBACK_PORT try: diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index 0ecf890b..689e1c80 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -8,9 +8,9 @@ from pydantic import BaseModel, TypeAdapter, ValidationError from rich.markup import escape -from aai_cli import output from aai_cli.auth import ams, discovery, endpoints, loopback -from aai_cli.errors import STDIN_KEY_RECIPE, APIError, NotAuthenticated +from aai_cli.core.errors import STDIN_KEY_RECIPE, APIError, NotAuthenticated +from aai_cli.ui import output @dataclass diff --git a/aai_cli/auth/loopback.py b/aai_cli/auth/loopback.py index 749a87e7..e3c7ff6e 100644 --- a/aai_cli/auth/loopback.py +++ b/aai_cli/auth/loopback.py @@ -6,7 +6,7 @@ from urllib.parse import parse_qs, urlparse from aai_cli.auth import endpoints -from aai_cli.errors import APIError +from aai_cli.core.errors import APIError # The callback URL carries the single-use OAuth token in its # query string, so it would otherwise linger in the browser's history and address diff --git a/aai_cli/code_gen/stream.py b/aai_cli/code_gen/stream.py index 27e0a45e..e42593a6 100644 --- a/aai_cli/code_gen/stream.py +++ b/aai_cli/code_gen/stream.py @@ -2,8 +2,8 @@ from typing import cast -from aai_cli import environments from aai_cli.code_gen import serialize +from aai_cli.core import environments # Streaming-class imports always used by the generated scaffold. SpeechModel is added # only when a speech_model kwarg is emitted, so the generated script stays lint-clean. diff --git a/aai_cli/code_gen/transcribe.py b/aai_cli/code_gen/transcribe.py index 8e49206b..4a5bc553 100644 --- a/aai_cli/code_gen/transcribe.py +++ b/aai_cli/code_gen/transcribe.py @@ -2,8 +2,8 @@ from typing import cast -from aai_cli import environments, llm, youtube from aai_cli.code_gen import serialize, snippets +from aai_cli.core import environments, llm, youtube # ``-o/--output`` choice -> printed-result code, mirroring the run path's # ``client._FIELD_RENDERERS`` semantics: plain fields, the speaker-labeled diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 92c9a3d4..170d8235 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -8,11 +8,13 @@ from rich.markup import escape from rich.text import Text -from aai_cli import command_registry, help_panels, jsonshape, options, output, timeparse +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command from aai_cli.auth import ams -from aai_cli.context import AppState, run_command -from aai_cli.errors import UsageError -from aai_cli.help_text import examples_epilog +from aai_cli.core import jsonshape, timeparse +from aai_cli.core.errors import UsageError +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog def _parse_day(day: str) -> date: diff --git a/aai_cli/commands/agent/__init__.py b/aai_cli/commands/agent/__init__.py index 3112f57c..6a1a84f9 100644 --- a/aai_cli/commands/agent/__init__.py +++ b/aai_cli/commands/agent/__init__.py @@ -4,17 +4,14 @@ import typer -from aai_cli import choices, command_registry, help_panels, options, output +from aai_cli import command_registry, help_panels, options from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT -from aai_cli.agent.voices import ( - DEFAULT_VOICE, - VOICES, - complete_voice, - format_voice_list, -) +from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, complete_voice, format_voice_list +from aai_cli.app.context import AppState, run_command from aai_cli.commands.agent import _exec as agent_exec -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli.core import choices +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/agent/_exec.py b/aai_cli/commands/agent/_exec.py index 9cf50474..87556ef9 100644 --- a/aai_cli/commands/agent/_exec.py +++ b/aai_cli/commands/agent/_exec.py @@ -15,15 +15,17 @@ import typer -from aai_cli import choices, client, code_gen, output +from aai_cli import code_gen from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from aai_cli.agent.render import AgentRenderer from aai_cli.agent.session import AgentRunConfig, run_session from aai_cli.agent.voices import VOICE_NAMES -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.app.context import AppState +from aai_cli.core import choices, client +from aai_cli.core.errors import CLIError, UsageError from aai_cli.streaming.session import validate_output_flags from aai_cli.streaming.sources import FileSource +from aai_cli.ui import output @dataclass(frozen=True) diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index 454a3d57..37417237 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -5,10 +5,12 @@ import typer from rich.markup import escape -from aai_cli import command_registry, help_panels, jsonshape, options, output, timeparse +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command from aai_cli.auth import ams -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli.core import jsonshape, timeparse +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer(help="View your account's audit log") diff --git a/aai_cli/commands/caption/__init__.py b/aai_cli/commands/caption/__init__.py index 8c918ad2..a73dbf9c 100644 --- a/aai_cli/commands/caption/__init__.py +++ b/aai_cli/commands/caption/__init__.py @@ -5,9 +5,9 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.caption import _exec as caption_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/caption/_exec.py b/aai_cli/commands/caption/_exec.py index da3cb7bf..bf948cdf 100644 --- a/aai_cli/commands/caption/_exec.py +++ b/aai_cli/commands/caption/_exec.py @@ -22,9 +22,11 @@ import assemblyai as aai from rich.markup import escape -from aai_cli import client, mediafile, output -from aai_cli.context import AppState -from aai_cli.errors import CLIError +from aai_cli.app import mediafile +from aai_cli.app.context import AppState +from aai_cli.core import client +from aai_cli.core.errors import CLIError +from aai_cli.ui import output @dataclass(frozen=True) diff --git a/aai_cli/commands/clip/__init__.py b/aai_cli/commands/clip/__init__.py index 064b4d9d..d8f7303e 100644 --- a/aai_cli/commands/clip/__init__.py +++ b/aai_cli/commands/clip/__init__.py @@ -4,10 +4,11 @@ import typer -from aai_cli import command_registry, help_panels, llm, options +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.clip import _exec as clip_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.core import llm +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/clip/_exec.py b/aai_cli/commands/clip/_exec.py index f143a0f4..3ab37d2b 100644 --- a/aai_cli/commands/clip/_exec.py +++ b/aai_cli/commands/clip/_exec.py @@ -26,11 +26,13 @@ from rich.markup import escape -from aai_cli import jsonshape, llm, mediafile, output, stdio, youtube +from aai_cli.app import mediafile +from aai_cli.app.context import AppState from aai_cli.commands.clip import _select as clip_select from aai_cli.commands.clip._select import Segment -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import jsonshape, llm, stdio, youtube +from aai_cli.core.errors import CLIError, UsageError +from aai_cli.ui import output @dataclass(frozen=True) diff --git a/aai_cli/commands/clip/_select.py b/aai_cli/commands/clip/_select.py index 1b4d21bb..f1daeaeb 100644 --- a/aai_cli/commands/clip/_select.py +++ b/aai_cli/commands/clip/_select.py @@ -15,8 +15,8 @@ import re from dataclasses import dataclass -from aai_cli import jsonshape -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import jsonshape +from aai_cli.core.errors import CLIError, UsageError _RANGE_FORMAT = "START-END, each end as seconds or [HH:]MM:SS (e.g. 90-120 or 1:30-2:00)" _MAX_CLOCK_FIELDS = 3 # [HH:]MM:SS — anything longer than three colon fields is a typo diff --git a/aai_cli/commands/config_cmd.py b/aai_cli/commands/config.py similarity index 95% rename from aai_cli/commands/config_cmd.py rename to aai_cli/commands/config.py index 5e220f93..1b197866 100644 --- a/aai_cli/commands/config_cmd.py +++ b/aai_cli/commands/config.py @@ -12,11 +12,13 @@ import typer from rich.markup import escape -from aai_cli import command_registry, config, environments, help_panels, options, output -from aai_cli.choices import ConfigKey -from aai_cli.context import AppState, run_command -from aai_cli.errors import UsageError -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command +from aai_cli.core import config, environments +from aai_cli.core.choices import ConfigKey +from aai_cli.core.errors import UsageError +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog SPEC = command_registry.CommandModuleSpec( panel=help_panels.SETUP, diff --git a/aai_cli/commands/deploy/__init__.py b/aai_cli/commands/deploy/__init__.py index 02a1ea4e..cca220f5 100644 --- a/aai_cli/commands/deploy/__init__.py +++ b/aai_cli/commands/deploy/__init__.py @@ -4,9 +4,9 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.deploy import _exec as deploy_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.ui.help_text import examples_epilog # Flattened single-command sub-typer (same pattern as `assembly dev`). app = typer.Typer() diff --git a/aai_cli/commands/deploy/_exec.py b/aai_cli/commands/deploy/_exec.py index 5413d0c1..84b57c1c 100644 --- a/aai_cli/commands/deploy/_exec.py +++ b/aai_cli/commands/deploy/_exec.py @@ -17,10 +17,10 @@ import typer -from aai_cli import output -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.app.context import AppState +from aai_cli.core.errors import CLIError, UsageError from aai_cli.init import procfile +from aai_cli.ui import output @dataclass(frozen=True) diff --git a/aai_cli/commands/dev/__init__.py b/aai_cli/commands/dev/__init__.py index 30ddae6c..69402f2c 100644 --- a/aai_cli/commands/dev/__init__.py +++ b/aai_cli/commands/dev/__init__.py @@ -4,10 +4,10 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.dev import _exec as dev_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog from aai_cli.init import devserver +from aai_cli.ui.help_text import examples_epilog # Flattened single-command sub-typer (same pattern as `assembly init`): one # @app.command() registered via app.add_typer(dev.app) with no name. diff --git a/aai_cli/commands/dev/_exec.py b/aai_cli/commands/dev/_exec.py index 68cbe26e..9e2b7228 100644 --- a/aai_cli/commands/dev/_exec.py +++ b/aai_cli/commands/dev/_exec.py @@ -8,16 +8,16 @@ from __future__ import annotations -import os from dataclasses import dataclass from pathlib import Path import typer from rich.markup import escape -from aai_cli import output, steps -from aai_cli.context import AppState +from aai_cli.app.context import AppState +from aai_cli.core import env as os_env from aai_cli.init import devserver, procfile, runner +from aai_cli.ui import output, steps @dataclass(frozen=True) @@ -38,7 +38,7 @@ def run_dev(opts: DevOptions, state: AppState, *, json_mode: bool) -> None: chosen_port = runner.find_free_port(opts.port) devserver.notify_port_change(opts.port, chosen_port, json_mode=json_mode, quiet=state.quiet) - env = {**os.environ, "PORT": str(chosen_port)} + env = os_env.child_env(PORT=str(chosen_port)) # Resolves the start command AND validates we're inside a scaffolded project. web = procfile.web_argv(target, env=env) diff --git a/aai_cli/commands/dictate/__init__.py b/aai_cli/commands/dictate/__init__.py index ed49b6dc..a298423d 100644 --- a/aai_cli/commands/dictate/__init__.py +++ b/aai_cli/commands/dictate/__init__.py @@ -3,10 +3,10 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.dictate import _exec as dictate_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog -from aai_cli.sync_stt import MAX_AUDIO_SECONDS +from aai_cli.core.sync_stt import MAX_AUDIO_SECONDS +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/dictate/_exec.py b/aai_cli/commands/dictate/_exec.py index 0bd6d7f9..c00d3515 100644 --- a/aai_cli/commands/dictate/_exec.py +++ b/aai_cli/commands/dictate/_exec.py @@ -12,10 +12,11 @@ from dataclasses import dataclass -from aai_cli import output, sync_stt -from aai_cli.context import AppState -from aai_cli.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys -from aai_cli.microphone import MicrophoneSource +from aai_cli.app.context import AppState +from aai_cli.core import sync_stt +from aai_cli.core.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys +from aai_cli.core.microphone import MicrophoneSource +from aai_cli.ui import output # Capture is resampled to one rate the Sync API accepts; 16 kHz mono PCM16 keeps # a 120 s utterance well under the 40 MB upload cap. diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index b2a1aab3..a461e427 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -2,9 +2,12 @@ import typer -from aai_cli import command_registry, doctor_checks, environments, help_panels, options, output -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app import doctor_checks +from aai_cli.app.context import AppState, run_command +from aai_cli.core import environments +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/dub/__init__.py b/aai_cli/commands/dub/__init__.py index 713d18da..d9b0c6be 100644 --- a/aai_cli/commands/dub/__init__.py +++ b/aai_cli/commands/dub/__init__.py @@ -4,10 +4,11 @@ import typer -from aai_cli import command_registry, help_panels, llm, options +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.dub import _exec as dub_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.core import llm +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/dub/_exec.py b/aai_cli/commands/dub/_exec.py index a19654e0..85905197 100644 --- a/aai_cli/commands/dub/_exec.py +++ b/aai_cli/commands/dub/_exec.py @@ -25,11 +25,13 @@ from rich.markup import escape -from aai_cli import mediafile, output, youtube +from aai_cli.app import mediafile +from aai_cli.app.context import AppState from aai_cli.commands.dub import _pipeline as pipeline -from aai_cli.context import AppState -from aai_cli.errors import UsageError +from aai_cli.core import youtube +from aai_cli.core.errors import UsageError from aai_cli.tts import audio, dialogue, session +from aai_cli.ui import output # ISO-639-1 codes accepted by --lang, mapped to the language *name* both the # translation prompt and the streaming-TTS `language` param expect. A value not diff --git a/aai_cli/commands/dub/_pipeline.py b/aai_cli/commands/dub/_pipeline.py index 2fa10ba8..9f24335e 100644 --- a/aai_cli/commands/dub/_pipeline.py +++ b/aai_cli/commands/dub/_pipeline.py @@ -14,11 +14,13 @@ from pathlib import Path from typing import TYPE_CHECKING -from aai_cli import jsonshape, mediafile, output -from aai_cli import llm as gateway -from aai_cli.errors import APIError, CLIError +from aai_cli.app import mediafile +from aai_cli.core import jsonshape +from aai_cli.core import llm as gateway +from aai_cli.core.errors import APIError, CLIError from aai_cli.tts import audio, dialogue, session, voices from aai_cli.tts.session import SpeakConfig +from aai_cli.ui import output if TYPE_CHECKING: from aai_cli.commands.dub._exec import DubOptions diff --git a/aai_cli/commands/evaluate/__init__.py b/aai_cli/commands/evaluate/__init__.py index 00ce36d1..a16838c1 100644 --- a/aai_cli/commands/evaluate/__init__.py +++ b/aai_cli/commands/evaluate/__init__.py @@ -10,10 +10,10 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.evaluate import _exec as evaluate_exec from aai_cli.commands.evaluate._exec import EvalSpeechModel -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/evaluate/_data.py b/aai_cli/commands/evaluate/_data.py index 62f2afd7..dc974d30 100644 --- a/aai_cli/commands/evaluate/_data.py +++ b/aai_cli/commands/evaluate/_data.py @@ -21,9 +21,9 @@ from dataclasses import dataclass from pathlib import Path -from aai_cli import jsonshape, wer from aai_cli.commands.evaluate import _hf_api as eval_hf_api -from aai_cli.errors import APIError, CLIError, UsageError +from aai_cli.core import jsonshape, wer +from aai_cli.core.errors import APIError, CLIError, UsageError _MANIFEST_SUFFIXES = (".csv", ".jsonl") # Hub ids are `name` or `namespace/name`; rejecting anything else keeps a typo'd diff --git a/aai_cli/commands/evaluate/_exec.py b/aai_cli/commands/evaluate/_exec.py index 08c23438..047f8041 100644 --- a/aai_cli/commands/evaluate/_exec.py +++ b/aai_cli/commands/evaluate/_exec.py @@ -19,10 +19,11 @@ import assemblyai as aai from rich.console import RenderableType -from aai_cli import client, jsonshape, output, wer +from aai_cli.app.context import AppState from aai_cli.commands.evaluate import _data as eval_data -from aai_cli.context import AppState -from aai_cli.errors import CLIError, NotAuthenticated +from aai_cli.core import client, jsonshape, wer +from aai_cli.core.errors import CLIError, NotAuthenticated +from aai_cli.ui import output class EvalSpeechModel(StrEnum): diff --git a/aai_cli/commands/evaluate/_hf_api.py b/aai_cli/commands/evaluate/_hf_api.py index 367e5d2c..c2285886 100644 --- a/aai_cli/commands/evaluate/_hf_api.py +++ b/aai_cli/commands/evaluate/_hf_api.py @@ -8,13 +8,12 @@ from __future__ import annotations -import os from http import HTTPStatus import httpx2 as httpx -from aai_cli import jsonshape -from aai_cli.errors import APIError, UsageError +from aai_cli.core import env, jsonshape +from aai_cli.core.errors import APIError, UsageError _DATASETS_SERVER = "https://datasets-server.huggingface.co" _TIMEOUT = 30.0 # pragma: no mutate (request timeout; nothing observable to assert) @@ -82,7 +81,7 @@ def _checked_payload(resp: httpx.Response, *, dataset: str) -> dict[str, object] def fetch_json(endpoint: str, params: dict[str, str | int], *, dataset: str) -> dict[str, object]: - token = os.environ.get("HF_TOKEN") + token = env.get("HF_TOKEN") headers = {"authorization": f"Bearer {token}"} if token else {} try: with httpx.Client(base_url=_DATASETS_SERVER, timeout=_TIMEOUT, headers=headers) as client: diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index f7fc972c..7992b545 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -3,10 +3,11 @@ import typer -from aai_cli import command_registry, help_panels, init_exec, options -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app import init_exec +from aai_cli.app.context import AppState, run_command from aai_cli.init import templates +from aai_cli.ui.help_text import examples_epilog # Single-command sub-typer flattened to `assembly init` (the exact pattern `assembly transcribe` # uses): one @app.command() named `init`, registered via app.add_typer(init.app) with diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index b1642ec6..cd4d953c 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -3,11 +3,13 @@ import typer from rich.markup import escape -from aai_cli import command_registry, help_panels, jsonshape, options, output +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command from aai_cli.auth import ams -from aai_cli.context import AppState, run_command -from aai_cli.errors import APIError, UsageError -from aai_cli.help_text import examples_epilog +from aai_cli.core import jsonshape +from aai_cli.core.errors import APIError, UsageError +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer(help="List, create, and rename your AssemblyAI API keys", no_args_is_help=True) diff --git a/aai_cli/commands/llm/__init__.py b/aai_cli/commands/llm/__init__.py index 54b36169..84d32e34 100644 --- a/aai_cli/commands/llm/__init__.py +++ b/aai_cli/commands/llm/__init__.py @@ -2,12 +2,14 @@ import typer -from aai_cli import choices, command_registry, help_panels, options, output -from aai_cli import llm as gateway +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.llm import _exec as llm_exec -from aai_cli.context import run_command -from aai_cli.errors import UsageError -from aai_cli.help_text import examples_epilog +from aai_cli.core import choices +from aai_cli.core import llm as gateway +from aai_cli.core.errors import UsageError +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/llm/_exec.py b/aai_cli/commands/llm/_exec.py index 798521da..29934dd0 100644 --- a/aai_cli/commands/llm/_exec.py +++ b/aai_cli/commands/llm/_exec.py @@ -13,11 +13,12 @@ from rich.markup import escape -from aai_cli import choices, client, output, stdio -from aai_cli import llm as gateway -from aai_cli.context import AppState -from aai_cli.errors import UsageError -from aai_cli.follow import FollowRenderer +from aai_cli.app.context import AppState +from aai_cli.core import choices, client, stdio +from aai_cli.core import llm as gateway +from aai_cli.core.errors import UsageError +from aai_cli.ui import output +from aai_cli.ui.follow import FollowRenderer _FOLLOW_STDIN_MESSAGE = ( "--follow needs transcript text piped on stdin, e.g. " diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index dd09af53..21fca3b6 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -6,16 +6,12 @@ from rich.markup import escape from rich.table import Table -from aai_cli import client, command_registry, config, environments, help_panels, options, output -from aai_cli.context import AppState, persist_browser_login, run_command -from aai_cli.errors import ( - STDIN_KEY_RECIPE, - APIError, - CLIError, - UsageError, - mutually_exclusive, -) -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, persist_browser_login, run_command +from aai_cli.core import client, config, environments +from aai_cli.core.errors import STDIN_KEY_RECIPE, APIError, CLIError, UsageError, mutually_exclusive +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py index b9823da6..d98a97bf 100644 --- a/aai_cli/commands/onboard.py +++ b/aai_cli/commands/onboard.py @@ -2,13 +2,15 @@ import typer -from aai_cli import command_registry, 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 import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command +from aai_cli.core import stdio +from aai_cli.core.errors import CLIError from aai_cli.onboard import wizard from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter, Prompter from aai_cli.onboard.sections import WizardContext +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index 31457115..f5fa1c5f 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -6,10 +6,12 @@ from rich.markup import escape from rich.table import Table -from aai_cli import command_registry, help_panels, jsonshape, options, output, theme, timeparse +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command from aai_cli.auth import ams -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli.core import jsonshape, timeparse +from aai_cli.ui import output, theme +from aai_cli.ui.help_text import examples_epilog app = typer.Typer(help="Browse your past streaming (real-time) sessions", no_args_is_help=True) diff --git a/aai_cli/commands/setup.py b/aai_cli/commands/setup.py index bf08bbe0..942f4f64 100644 --- a/aai_cli/commands/setup.py +++ b/aai_cli/commands/setup.py @@ -2,9 +2,12 @@ import typer -from aai_cli import choices, command_registry, help_panels, options, output, setup_exec -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app import setup_exec +from aai_cli.app.context import AppState, run_command +from aai_cli.core import choices +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer( help="Set up your coding agent for AssemblyAI (docs MCP + skills)", diff --git a/aai_cli/commands/share/__init__.py b/aai_cli/commands/share/__init__.py index 03cb955c..0c7015cf 100644 --- a/aai_cli/commands/share/__init__.py +++ b/aai_cli/commands/share/__init__.py @@ -4,9 +4,9 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.share import _exec as share_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.ui.help_text import examples_epilog # Flattened single-command sub-typer (same pattern as `assembly dev`). app = typer.Typer() diff --git a/aai_cli/commands/share/_exec.py b/aai_cli/commands/share/_exec.py index 3e51e651..f996ffc8 100644 --- a/aai_cli/commands/share/_exec.py +++ b/aai_cli/commands/share/_exec.py @@ -8,17 +8,17 @@ from __future__ import annotations -import os from dataclasses import dataclass from pathlib import Path import typer from rich.markup import escape -from aai_cli import output, steps -from aai_cli.context import AppState -from aai_cli.errors import CLIError +from aai_cli.app.context import AppState +from aai_cli.core import env as os_env +from aai_cli.core.errors import CLIError from aai_cli.init import devserver, procfile, runner, tunnel +from aai_cli.ui import output, steps @dataclass(frozen=True) @@ -45,7 +45,7 @@ def run_share(opts: ShareOptions, state: AppState, *, json_mode: bool) -> None: chosen_port = runner.find_free_port(opts.port) devserver.notify_port_change(opts.port, chosen_port, json_mode=json_mode, quiet=state.quiet) - env = {**os.environ, "PORT": str(chosen_port)} + env = os_env.child_env(PORT=str(chosen_port)) web = procfile.web_argv(target, env=env) # validates we're in a scaffolded project tunnel.require_cloudflared("share a public link") diff --git a/aai_cli/commands/speak/__init__.py b/aai_cli/commands/speak/__init__.py index 7da35b89..85b99030 100644 --- a/aai_cli/commands/speak/__init__.py +++ b/aai_cli/commands/speak/__init__.py @@ -5,10 +5,10 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.speak import _exec as speak_exec from aai_cli.commands.speak._exec import DEFAULT_LANGUAGE -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/speak/_exec.py b/aai_cli/commands/speak/_exec.py index 0463fd16..82febd89 100644 --- a/aai_cli/commands/speak/_exec.py +++ b/aai_cli/commands/speak/_exec.py @@ -11,10 +11,11 @@ from dataclasses import dataclass from pathlib import Path -from aai_cli import output, stdio -from aai_cli.context import AppState -from aai_cli.errors import UsageError +from aai_cli.app.context import AppState +from aai_cli.core import stdio +from aai_cli.core.errors import UsageError from aai_cli.tts import audio, dialogue, session, voices +from aai_cli.ui import output # The streaming-TTS reference client defaults to English, so the CLI does the # same. The default voice follows the language (voices.default_voice): each diff --git a/aai_cli/commands/stream/__init__.py b/aai_cli/commands/stream/__init__.py index 78ef5772..9d558fa1 100644 --- a/aai_cli/commands/stream/__init__.py +++ b/aai_cli/commands/stream/__init__.py @@ -5,10 +5,11 @@ import typer from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel -from aai_cli import choices, command_registry, help_panels, llm, options +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command from aai_cli.commands.stream import _exec as stream_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli.core import choices, llm +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/stream/_exec.py b/aai_cli/commands/stream/_exec.py index 387592f9..f2642e56 100644 --- a/aai_cli/commands/stream/_exec.py +++ b/aai_cli/commands/stream/_exec.py @@ -15,11 +15,11 @@ from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel -from aai_cli import choices, client, code_gen, config_builder, output, youtube -from aai_cli.context import AppState -from aai_cli.errors import UsageError -from aai_cli.follow import FollowRenderer -from aai_cli.microphone import MicrophoneSource +from aai_cli import code_gen +from aai_cli.app.context import AppState +from aai_cli.core import choices, client, config_builder, youtube +from aai_cli.core.errors import UsageError +from aai_cli.core.microphone import MicrophoneSource from aai_cli.streaming.macos import MacSystemAudioSource from aai_cli.streaming.render import StreamRenderer from aai_cli.streaming.session import ( @@ -29,6 +29,8 @@ validate_sources, ) from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource +from aai_cli.ui import output +from aai_cli.ui.follow import FollowRenderer @dataclass(frozen=True) diff --git a/aai_cli/commands/telemetry.py b/aai_cli/commands/telemetry.py index 120bb24e..0e06e077 100644 --- a/aai_cli/commands/telemetry.py +++ b/aai_cli/commands/telemetry.py @@ -10,9 +10,11 @@ import typer -from aai_cli import command_registry, config, help_panels, options, output, telemetry -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command +from aai_cli.core import config, telemetry +from aai_cli.ui import output +from aai_cli.ui.help_text import examples_epilog app = typer.Typer( help="Anonymous usage telemetry: status, enable, disable", diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 2e7d483d..b9fd3565 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -5,9 +5,11 @@ import assemblyai as aai import typer -from aai_cli import choices, command_registry, help_panels, llm, options, transcribe_exec -from aai_cli.context import run_command -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import run_command +from aai_cli.app.transcribe import run as transcribe_exec +from aai_cli.core import choices, llm +from aai_cli.ui.help_text import examples_epilog app = typer.Typer() diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index 856b13fb..e144a421 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -3,19 +3,12 @@ import typer from rich.markup import escape -from aai_cli import ( - choices, - client, - command_registry, - help_panels, - options, - output, - theme, - timeparse, -) -from aai_cli.context import AppState, run_command -from aai_cli.errors import APIError -from aai_cli.help_text import examples_epilog +from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command +from aai_cli.core import choices, client, timeparse +from aai_cli.core.errors import APIError +from aai_cli.ui import output, theme +from aai_cli.ui.help_text import examples_epilog app = typer.Typer(help="Browse and fetch past transcripts", no_args_is_help=True) diff --git a/aai_cli/commands/update.py b/aai_cli/commands/update.py index 7b6a83e2..39decbb2 100644 --- a/aai_cli/commands/update.py +++ b/aai_cli/commands/update.py @@ -14,18 +14,12 @@ import typer -from aai_cli import ( - __version__, - command_registry, - config, - help_panels, - options, - output, - update_check, -) -from aai_cli.context import AppState, run_command -from aai_cli.errors import APIError, CLIError -from aai_cli.help_text import examples_epilog +from aai_cli import __version__, command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command +from aai_cli.core import config +from aai_cli.core.errors import APIError, CLIError +from aai_cli.ui import output, update_check +from aai_cli.ui.help_text import examples_epilog SPEC = command_registry.CommandModuleSpec( panel=help_panels.SETUP, diff --git a/aai_cli/commands/webhooks/__init__.py b/aai_cli/commands/webhooks/__init__.py index 1a19a3e8..25366df4 100644 --- a/aai_cli/commands/webhooks/__init__.py +++ b/aai_cli/commands/webhooks/__init__.py @@ -4,9 +4,9 @@ import typer from aai_cli import command_registry, help_panels, options +from aai_cli.app.context import AppState, run_command from aai_cli.commands.webhooks import _listen as webhook_listen -from aai_cli.context import AppState, run_command -from aai_cli.help_text import examples_epilog +from aai_cli.ui.help_text import examples_epilog app = typer.Typer(help="Receive webhook deliveries on a public dev URL", no_args_is_help=True) diff --git a/aai_cli/commands/webhooks/_listen.py b/aai_cli/commands/webhooks/_listen.py index 25e8b7c2..e62e7a4b 100644 --- a/aai_cli/commands/webhooks/_listen.py +++ b/aai_cli/commands/webhooks/_listen.py @@ -21,9 +21,10 @@ from rich.markup import escape -from aai_cli import jsonshape, output -from aai_cli.errors import CLIError +from aai_cli.core import jsonshape +from aai_cli.core.errors import CLIError from aai_cli.init import runner, tunnel +from aai_cli.ui import output if TYPE_CHECKING: import subprocess diff --git a/aai_cli/core/__init__.py b/aai_cli/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aai_cli/argscan.py b/aai_cli/core/argscan.py similarity index 100% rename from aai_cli/argscan.py rename to aai_cli/core/argscan.py diff --git a/aai_cli/choices.py b/aai_cli/core/choices.py similarity index 100% rename from aai_cli/choices.py rename to aai_cli/core/choices.py diff --git a/aai_cli/client.py b/aai_cli/core/client.py similarity index 98% rename from aai_cli/client.py rename to aai_cli/core/client.py index 15ee5e26..f7e333a2 100644 --- a/aai_cli/client.py +++ b/aai_cli/core/client.py @@ -16,8 +16,8 @@ StreamingParameters, ) -from aai_cli import environments, jsonshape, remotefs, stdio -from aai_cli.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure +from aai_cli.core import environments, jsonshape, remotefs, stdio +from aai_cli.core.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure from aai_cli.streaming.diagnostics import classify_error, silence_streaming_logging SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" diff --git a/aai_cli/config.py b/aai_cli/core/config.py similarity index 99% rename from aai_cli/config.py rename to aai_cli/core/config.py index 322e06ca..bb7923e1 100644 --- a/aai_cli/config.py +++ b/aai_cli/core/config.py @@ -14,8 +14,8 @@ import tomli_w from pydantic import BaseModel, ConfigDict, Field, ValidationError -from aai_cli import debuglog -from aai_cli.errors import CLIError, NotAuthenticated +from aai_cli.core import debuglog, env +from aai_cli.core.errors import CLIError, NotAuthenticated KEYRING_SERVICE = "assemblyai-cli" ENV_API_KEY = "ASSEMBLYAI_API_KEY" @@ -460,7 +460,7 @@ def _resolve_api_key(*, profile: str | None, api_key_flag: str | None) -> str: suggestion="Pass a non-empty key, e.g. --api-key sk_...", ) return flag_key - env_key = (os.environ.get(ENV_API_KEY) or "").strip() + env_key = (env.get(ENV_API_KEY) or "").strip() if env_key: return env_key profile = profile or get_active_profile() diff --git a/aai_cli/config_builder.py b/aai_cli/core/config_builder.py similarity index 99% rename from aai_cli/config_builder.py rename to aai_cli/core/config_builder.py index 3270a996..5e72a641 100644 --- a/aai_cli/config_builder.py +++ b/aai_cli/core/config_builder.py @@ -10,8 +10,8 @@ from assemblyai.streaming.v3 import SpeechModel, StreamingParameters from pydantic import JsonValue, TypeAdapter, ValidationError -from aai_cli import jsonshape -from aai_cli.errors import UsageError +from aai_cli.core import jsonshape +from aai_cli.core.errors import UsageError # The curated set of user-settable config fields per command. This is the authoritative # allow-list (deliberately a subset of the SDK models — e.g. output-only and internal diff --git a/aai_cli/debuglog.py b/aai_cli/core/debuglog.py similarity index 100% rename from aai_cli/debuglog.py rename to aai_cli/core/debuglog.py diff --git a/aai_cli/core/env.py b/aai_cli/core/env.py new file mode 100644 index 00000000..bdf207e9 --- /dev/null +++ b/aai_cli/core/env.py @@ -0,0 +1,46 @@ +"""The single chokepoint for raw environment access. + +`os.environ` / `os.getenv` are banned project-wide via ruff's `banned-api` +(TID251); this module is the one place allowlisted to touch them, so "who reads +the environment" is answered structurally by the import graph rather than by a +hand-maintained per-file allowlist. Callers keep ownership of their variable +*names* (e.g. `config.ENV_API_KEY`, `telemetry.ENV_DISABLED`) and pass them in; +this module only performs the raw read/write. + +Process spawning is the sibling boundary, owned by the modules that shell out to +their specific external tool (see the TID251 allowlist in pyproject.toml). +""" + +from __future__ import annotations + +import os + + +def get(name: str, default: str | None = None) -> str | None: + """Return the value of environment variable ``name`` (or ``default``).""" + return os.environ.get(name, default) + + +def child_env(**overrides: str) -> dict[str, str]: + """A copy of the current environment with ``overrides`` applied. + + For handing a tweaked environment to a child process (e.g. injecting ``PORT``) + without mutating this process's own ``os.environ``. + """ + return {**os.environ, **overrides} + + +def force_color() -> None: + """Force color on for this process and its children. + + Sets ``FORCE_COLOR`` and clears ``NO_COLOR`` so consoles built later — and + child processes — agree with the explicit ``--color always``. + """ + os.environ["FORCE_COLOR"] = "1" + os.environ.pop("NO_COLOR", None) + + +def disable_color() -> None: + """Force color off for this process and its children (the ``--color never`` half).""" + os.environ["NO_COLOR"] = "1" + os.environ.pop("FORCE_COLOR", None) diff --git a/aai_cli/environments.py b/aai_cli/core/environments.py similarity index 97% rename from aai_cli/environments.py rename to aai_cli/core/environments.py index ad9dd509..a9f750c8 100644 --- a/aai_cli/environments.py +++ b/aai_cli/core/environments.py @@ -1,9 +1,9 @@ from __future__ import annotations -import os from dataclasses import dataclass -from aai_cli.errors import CLIError +from aai_cli.core import env +from aai_cli.core.errors import CLIError @dataclass(frozen=True) @@ -97,7 +97,7 @@ def get(name: str) -> Environment: def resolve(flag: str | None, profile_env: str | None) -> Environment: """Pick the environment by precedence: --env flag > AAI_ENV > profile > default.""" - name = flag or os.environ.get("AAI_ENV") or profile_env or DEFAULT_ENV + name = flag or env.get("AAI_ENV") or profile_env or DEFAULT_ENV return get(name) diff --git a/aai_cli/errors.py b/aai_cli/core/errors.py similarity index 99% rename from aai_cli/errors.py rename to aai_cli/core/errors.py index 2ab5f76b..1e393728 100644 --- a/aai_cli/errors.py +++ b/aai_cli/core/errors.py @@ -19,7 +19,7 @@ from __future__ import annotations -from aai_cli import jsonshape +from aai_cli.core import jsonshape class CLIError(Exception): diff --git a/aai_cli/hotkey.py b/aai_cli/core/hotkey.py similarity index 98% rename from aai_cli/hotkey.py rename to aai_cli/core/hotkey.py index a525d960..fa0056ac 100644 --- a/aai_cli/hotkey.py +++ b/aai_cli/core/hotkey.py @@ -14,7 +14,7 @@ import select import sys -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError # Control characters hotkey-driven commands treat as "end the session". CTRL_C = "\x03" diff --git a/aai_cli/jsonshape.py b/aai_cli/core/jsonshape.py similarity index 100% rename from aai_cli/jsonshape.py rename to aai_cli/core/jsonshape.py diff --git a/aai_cli/llm.py b/aai_cli/core/llm.py similarity index 99% rename from aai_cli/llm.py rename to aai_cli/core/llm.py index 401e7d60..19b5747b 100644 --- a/aai_cli/llm.py +++ b/aai_cli/core/llm.py @@ -4,8 +4,8 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any -from aai_cli import environments -from aai_cli.errors import APIError, UsageError +from aai_cli.core import environments +from aai_cli.core.errors import APIError, UsageError if TYPE_CHECKING: from openai import OpenAI diff --git a/aai_cli/microphone.py b/aai_cli/core/microphone.py similarity index 99% rename from aai_cli/microphone.py rename to aai_cli/core/microphone.py index 7deb8c91..755858f1 100644 --- a/aai_cli/microphone.py +++ b/aai_cli/core/microphone.py @@ -6,7 +6,7 @@ from types import ModuleType from typing import Any, Protocol, cast -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError with warnings.catch_warnings(): # audioop is deprecated stdlib on 3.11/3.12 (warning suppressed here) and is diff --git a/aai_cli/procs.py b/aai_cli/core/procs.py similarity index 100% rename from aai_cli/procs.py rename to aai_cli/core/procs.py diff --git a/aai_cli/remotefs.py b/aai_cli/core/remotefs.py similarity index 99% rename from aai_cli/remotefs.py rename to aai_cli/core/remotefs.py index d234741a..75041f86 100644 --- a/aai_cli/remotefs.py +++ b/aai_cli/core/remotefs.py @@ -15,7 +15,7 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Protocol -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError if TYPE_CHECKING: from collections.abc import Generator diff --git a/aai_cli/stdio.py b/aai_cli/core/stdio.py similarity index 100% rename from aai_cli/stdio.py rename to aai_cli/core/stdio.py diff --git a/aai_cli/sync_stt.py b/aai_cli/core/sync_stt.py similarity index 97% rename from aai_cli/sync_stt.py rename to aai_cli/core/sync_stt.py index 213c6e54..475706be 100644 --- a/aai_cli/sync_stt.py +++ b/aai_cli/core/sync_stt.py @@ -14,8 +14,8 @@ import httpx2 as httpx -from aai_cli import environments, jsonshape -from aai_cli.errors import APIError, auth_failure +from aai_cli.core import environments, jsonshape +from aai_cli.core.errors import APIError, auth_failure # The X-AAI-Model header is required on every Sync API request. SYNC_MODEL = "u3-sync-pro" diff --git a/aai_cli/telemetry.py b/aai_cli/core/telemetry.py similarity index 95% rename from aai_cli/telemetry.py rename to aai_cli/core/telemetry.py index f5b36961..f1447aaa 100644 --- a/aai_cli/telemetry.py +++ b/aai_cli/core/telemetry.py @@ -17,7 +17,6 @@ from __future__ import annotations import json -import os import platform import sys import time @@ -26,8 +25,9 @@ import typer -from aai_cli import __version__, argscan, config, procs -from aai_cli.errors import CLIError +from aai_cli import __version__ +from aai_cli.core import argscan, config, env, procs +from aai_cli.core.errors import CLIError ENV_DISABLED = "AAI_TELEMETRY_DISABLED" ENV_DO_NOT_TRACK = "DO_NOT_TRACK" @@ -54,11 +54,11 @@ def client_token() -> str: """The write-only intake token: env override first, then the shipped one.""" - return os.environ.get(ENV_CLIENT_TOKEN) or SHIPPED_CLIENT_TOKEN + return env.get(ENV_CLIENT_TOKEN) or SHIPPED_CLIENT_TOKEN def intake_url() -> str: - return os.environ.get(ENV_INTAKE_URL) or DEFAULT_INTAKE_URL + return env.get(ENV_INTAKE_URL) or DEFAULT_INTAKE_URL def consent_granted() -> bool: @@ -68,7 +68,7 @@ def consent_granted() -> bool: tools commonly export ``true``, and treating those as "still tracking" would betray the user's stated intent. """ - if os.environ.get(ENV_DISABLED) or os.environ.get(ENV_DO_NOT_TRACK): + if env.get(ENV_DISABLED) or env.get(ENV_DO_NOT_TRACK): return False return config.get_telemetry_enabled() is not False @@ -78,9 +78,9 @@ def consent_source() -> str: an env kill-switch (``env:AAI_TELEMETRY_DISABLED`` / ``env:DO_NOT_TRACK``), the choice persisted by ``assembly telemetry enable/disable`` (``config``), or the opt-out ``default``.""" - if os.environ.get(ENV_DISABLED): + if env.get(ENV_DISABLED): return f"env:{ENV_DISABLED}" - if os.environ.get(ENV_DO_NOT_TRACK): + if env.get(ENV_DO_NOT_TRACK): return f"env:{ENV_DO_NOT_TRACK}" if config.get_telemetry_enabled() is not None: return "config" @@ -164,7 +164,7 @@ def build_event( "cli_version": __version__, "os": platform.system().lower(), "python_version": platform.python_version(), - "ci": bool(os.environ.get("CI")), + "ci": bool(env.get("CI")), "device_id": config.get_device_id(), } if not succeeded: diff --git a/aai_cli/timeparse.py b/aai_cli/core/timeparse.py similarity index 100% rename from aai_cli/timeparse.py rename to aai_cli/core/timeparse.py diff --git a/aai_cli/wer.py b/aai_cli/core/wer.py similarity index 100% rename from aai_cli/wer.py rename to aai_cli/core/wer.py diff --git a/aai_cli/ws.py b/aai_cli/core/ws.py similarity index 96% rename from aai_cli/ws.py rename to aai_cli/core/ws.py index 17622cb6..32d11a0c 100644 --- a/aai_cli/ws.py +++ b/aai_cli/core/ws.py @@ -9,8 +9,8 @@ import logging -from aai_cli import debuglog -from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure +from aai_cli.core import debuglog +from aai_cli.core.errors import APIError, CLIError, auth_failure, is_auth_failure # A pre-upgrade HTTP 403 on the WebSocket handshake is NOT a rejected key (it also # covers WAF/region/plan blocks) — mirrors how `stream` classifies handshakes. diff --git a/aai_cli/youtube.py b/aai_cli/core/youtube.py similarity index 98% rename from aai_cli/youtube.py rename to aai_cli/core/youtube.py index 2b84725c..08ef06aa 100644 --- a/aai_cli/youtube.py +++ b/aai_cli/core/youtube.py @@ -12,7 +12,7 @@ import re from pathlib import Path -from aai_cli.errors import CLIError, UsageError +from aai_cli.core.errors import CLIError, UsageError # youtube.com/watch, youtu.be/, music.youtube.com, shorts, with or without scheme. _YOUTUBE_RE = re.compile( @@ -48,7 +48,7 @@ def _ytdlp_error_message(exc: BaseException) -> str: # yt-dlp's default logger prints its own "ERROR: …" line straight to stderr before the # CLI can raise its one clean error, duplicating the message. Route yt-dlp's output to # a swallow-everything logger (NullHandler, no propagation) instead. -_YTDLP_LOGGER = logging.getLogger("aai_cli.youtube.yt_dlp") +_YTDLP_LOGGER = logging.getLogger("aai_cli.core.youtube.yt_dlp") _YTDLP_LOGGER.addHandler(logging.NullHandler()) _YTDLP_LOGGER.propagate = False diff --git a/aai_cli/init/devserver.py b/aai_cli/init/devserver.py index ab0d2e8d..9ff1976f 100644 --- a/aai_cli/init/devserver.py +++ b/aai_cli/init/devserver.py @@ -3,8 +3,8 @@ from pathlib import Path -from aai_cli import output, steps from aai_cli.init import runner +from aai_cli.ui import output, steps def install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step: diff --git a/aai_cli/init/keys.py b/aai_cli/init/keys.py index 4fd58753..f03d9b95 100644 --- a/aai_cli/init/keys.py +++ b/aai_cli/init/keys.py @@ -1,8 +1,6 @@ from __future__ import annotations -import os - -from aai_cli import config +from aai_cli.core import config, env def resolve_optional_api_key(*, profile: str | None) -> tuple[str | None, str | None]: @@ -18,6 +16,6 @@ def resolve_optional_api_key(*, profile: str | None) -> tuple[str | None, str | return None, None # Mirror resolve_api_key's whitespace handling: a blank env var is "unset", # so a key that actually came from the keyring must not report "environment". - env_value = os.environ.get(config.ENV_API_KEY, "").strip() + env_value = (env.get(config.ENV_API_KEY) or "").strip() source = "environment" if env_value else "keyring" return key, source diff --git a/aai_cli/init/procfile.py b/aai_cli/init/procfile.py index 122d98b2..33353a47 100644 --- a/aai_cli/init/procfile.py +++ b/aai_cli/init/procfile.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from pathlib import Path -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError # Matches ${VAR}, ${VAR:-default}, or $VAR — the shell-style refs that appear in a # Procfile's web: line (we expand them ourselves rather than invoking a shell). diff --git a/aai_cli/init/runner.py b/aai_cli/init/runner.py index de03ec7c..fd63c15c 100644 --- a/aai_cli/init/runner.py +++ b/aai_cli/init/runner.py @@ -10,8 +10,8 @@ import webbrowser from pathlib import Path -from aai_cli import output -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError +from aai_cli.ui import output def has_uv() -> bool: diff --git a/aai_cli/init/scaffold.py b/aai_cli/init/scaffold.py index 8304203a..ccd1d38f 100644 --- a/aai_cli/init/scaffold.py +++ b/aai_cli/init/scaffold.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError from aai_cli.init import templates if TYPE_CHECKING: diff --git a/aai_cli/init/tunnel.py b/aai_cli/init/tunnel.py index 2a9e6faa..97d627e7 100644 --- a/aai_cli/init/tunnel.py +++ b/aai_cli/init/tunnel.py @@ -11,8 +11,8 @@ from collections.abc import Callable from pathlib import Path -from aai_cli import config -from aai_cli.errors import CLIError +from aai_cli.core import config +from aai_cli.core.errors import CLIError from aai_cli.init import runner # cloudflared binary name; resolved via shutil.which by callers. diff --git a/aai_cli/main.py b/aai_cli/main.py index a35bfc54..b5d7a76a 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -13,23 +13,15 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from aai_cli import ( - __version__, - argscan, - choices, - command_registry, - debuglog, - environments, - output, - stdio, - typer_patches, -) +from aai_cli import __version__, command_registry +from aai_cli.app.context import AppState from aai_cli.commands import onboard -from aai_cli.context import AppState -from aai_cli.errors import CLIError, NotAuthenticated -from aai_cli.help_text import examples_epilog +from aai_cli.core import argscan, choices, debuglog, environments, stdio +from aai_cli.core.errors import CLIError, NotAuthenticated from aai_cli.onboard import wizard from aai_cli.onboard.sections import WizardContext +from aai_cli.ui import output, typer_patches +from aai_cli.ui.help_text import examples_epilog # Every module under aai_cli/commands/ declares its own panel, rank, and command # names (`SPEC`, see aai_cli/command_registry.py); discovery imports and orders them @@ -236,7 +228,7 @@ def main( ) def update_check_command() -> None: """Internal: refresh the cached latest version (spawned detached). Hidden.""" - from aai_cli import update_check + from aai_cli.ui import update_check update_check.fetch_and_cache() diff --git a/aai_cli/onboard/prompter.py b/aai_cli/onboard/prompter.py index 47b0cba1..16ca622e 100644 --- a/aai_cli/onboard/prompter.py +++ b/aai_cli/onboard/prompter.py @@ -5,8 +5,8 @@ import typer -from aai_cli import output -from aai_cli.errors import UsageError +from aai_cli.core.errors import UsageError +from aai_cli.ui import output class WizardCancelled(Exception): diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index 60f4d493..651891e5 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -7,20 +7,15 @@ import assemblyai as aai import typer -from aai_cli import ( - config, - doctor_checks, - environments, - init_exec, - output, - setup_exec, - transcribe_exec, - transcribe_render, -) -from aai_cli.context import AppState, persist_browser_login -from aai_cli.errors import CLIError +from aai_cli.app import doctor_checks, init_exec, setup_exec +from aai_cli.app.context import AppState, persist_browser_login +from aai_cli.app.transcribe import render as transcribe_render +from aai_cli.app.transcribe import run as transcribe_exec +from aai_cli.core import config, environments +from aai_cli.core.errors import CLIError from aai_cli.init import runner from aai_cli.onboard.prompter import Prompter +from aai_cli.ui import output class SectionResult(Enum): diff --git a/aai_cli/onboard/wizard.py b/aai_cli/onboard/wizard.py index 12740f84..ed3d81ed 100644 --- a/aai_cli/onboard/wizard.py +++ b/aai_cli/onboard/wizard.py @@ -2,11 +2,11 @@ from collections.abc import Callable -from aai_cli import output -from aai_cli.errors import NotAuthenticated +from aai_cli.core.errors import NotAuthenticated from aai_cli.onboard import sections from aai_cli.onboard.prompter import Prompter, WizardCancelled from aai_cli.onboard.sections import SectionResult, WizardContext +from aai_cli.ui import output _SectionFn = Callable[[Prompter, WizardContext], SectionResult] diff --git a/aai_cli/streaming/diagnostics.py b/aai_cli/streaming/diagnostics.py index 3d68a988..892464ce 100644 --- a/aai_cli/streaming/diagnostics.py +++ b/aai_cli/streaming/diagnostics.py @@ -12,9 +12,9 @@ import logging from collections.abc import Callable -from aai_cli import debuglog -from aai_cli import ws as wsutil -from aai_cli.errors import APIError, CLIError, NotAuthenticated +from aai_cli.core import debuglog +from aai_cli.core import ws as wsutil +from aai_cli.core.errors import APIError, CLIError, NotAuthenticated # The assemblyai SDK's streaming client logs its own connection failures at ERROR # ("Connection failed: WebSocket handshake rejected (HTTP 403) (code=403)") through diff --git a/aai_cli/streaming/macos.py b/aai_cli/streaming/macos.py index 6c9404f2..3dab1d56 100644 --- a/aai_cli/streaming/macos.py +++ b/aai_cli/streaming/macos.py @@ -14,7 +14,7 @@ from platformdirs import user_cache_path -from aai_cli.errors import APIError, CLIError +from aai_cli.core.errors import APIError, CLIError from aai_cli.streaming.sources import CHUNK_BYTES, TARGET_RATE _HELPER_RESOURCE = "macos_system_audio.swift" diff --git a/aai_cli/streaming/render.py b/aai_cli/streaming/render.py index 3e7de9d6..9907374a 100644 --- a/aai_cli/streaming/render.py +++ b/aai_cli/streaming/render.py @@ -6,8 +6,9 @@ from rich.console import Console from rich.text import Text -from aai_cli import jsonshape, theme -from aai_cli.render import BaseRenderer +from aai_cli.core import jsonshape +from aai_cli.ui import theme +from aai_cli.ui.render import BaseRenderer # Source label -> (display text, Rich style). System audio borrows the agent color; # the microphone ("you") its own. Unknown sources fall back to the raw label. diff --git a/aai_cli/streaming/session.py b/aai_cli/streaming/session.py index 0e55a213..f15db27a 100644 --- a/aai_cli/streaming/session.py +++ b/aai_cli/streaming/session.py @@ -9,10 +9,11 @@ import typer -from aai_cli import choices, client, config_builder, llm, output -from aai_cli.errors import APIError, CLIError, UsageError, mutually_exclusive -from aai_cli.follow import FollowRenderer +from aai_cli.core import choices, client, config_builder, llm +from aai_cli.core.errors import APIError, CLIError, UsageError, mutually_exclusive from aai_cli.streaming.render import StreamRenderer, speaker_prefix +from aai_cli.ui import output +from aai_cli.ui.follow import FollowRenderer # Sources that can be transcribed in parallel sessions: (label, audio chunks, sample rate). _ParallelStreams = list[tuple[str, Iterable[bytes], int]] diff --git a/aai_cli/streaming/sources.py b/aai_cli/streaming/sources.py index fcdc0cdc..f2ad43ed 100644 --- a/aai_cli/streaming/sources.py +++ b/aai_cli/streaming/sources.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -from aai_cli.errors import APIError, CLIError +from aai_cli.core.errors import APIError, CLIError TARGET_RATE = 16000 PCM16_SAMPLE_WIDTH_BYTES = 2 diff --git a/aai_cli/tts/audio.py b/aai_cli/tts/audio.py index eb0a4d75..f1b8bfef 100644 --- a/aai_cli/tts/audio.py +++ b/aai_cli/tts/audio.py @@ -6,8 +6,8 @@ from pathlib import Path from typing import Protocol -from aai_cli.errors import CLIError -from aai_cli.microphone import import_sounddevice +from aai_cli.core.errors import CLIError +from aai_cli.core.microphone import import_sounddevice class _OutputStream(Protocol): diff --git a/aai_cli/tts/dialogue.py b/aai_cli/tts/dialogue.py index cbf7c5d3..3007d9c6 100644 --- a/aai_cli/tts/dialogue.py +++ b/aai_cli/tts/dialogue.py @@ -4,7 +4,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from aai_cli.errors import UsageError +from aai_cli.core.errors import UsageError # A rendered transcript line: "Speaker A: text". The id is a non-space run; the # colon may be followed by a single space (label-only lines can lack trailing text). diff --git a/aai_cli/tts/session.py b/aai_cli/tts/session.py index f54cd7c2..d6774312 100644 --- a/aai_cli/tts/session.py +++ b/aai_cli/tts/session.py @@ -10,9 +10,9 @@ from typing import Protocol from urllib.parse import urlencode -from aai_cli import environments -from aai_cli import ws as wsutil -from aai_cli.errors import APIError, CLIError +from aai_cli.core import environments +from aai_cli.core import ws as wsutil +from aai_cli.core.errors import APIError, CLIError from aai_cli.streaming import diagnostics from aai_cli.tts import audio diff --git a/aai_cli/ui/__init__.py b/aai_cli/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aai_cli/follow.py b/aai_cli/ui/follow.py similarity index 98% rename from aai_cli/follow.py rename to aai_cli/ui/follow.py index a77d2bca..f2ec775a 100644 --- a/aai_cli/follow.py +++ b/aai_cli/ui/follow.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.panel import Panel -from aai_cli import output +from aai_cli.ui import output class FollowRenderer: diff --git a/aai_cli/help_text.py b/aai_cli/ui/help_text.py similarity index 100% rename from aai_cli/help_text.py rename to aai_cli/ui/help_text.py diff --git a/aai_cli/output.py b/aai_cli/ui/output.py similarity index 96% rename from aai_cli/output.py rename to aai_cli/ui/output.py index 8f3956f8..2c3d68cd 100644 --- a/aai_cli/output.py +++ b/aai_cli/ui/output.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import os import sys from collections.abc import Callable, Generator from typing import TYPE_CHECKING @@ -12,10 +11,12 @@ from rich.table import Table from rich.text import Text -from aai_cli import __version__, choices, jsonshape, theme +from aai_cli import __version__ +from aai_cli.core import choices, env, jsonshape +from aai_cli.ui import theme if TYPE_CHECKING: - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError console = theme.make_console() # Errors go to stderr so they never pollute piped stdout (e.g. `assembly transcribe x -o text > out`). @@ -34,7 +35,7 @@ def is_agentic() -> bool: env var is set. Used to suppress *interactivity* (the spinner) — never to change the output *shape*; `resolve_json` keeps text the default regardless (see its docstring). """ - return not _stdout_is_tty() or any(os.environ.get(var) for var in _AGENT_ENV_VARS) + return not _stdout_is_tty() or any(env.get(var) for var in _AGENT_ENV_VARS) def set_color_mode(mode: choices.ColorMode) -> None: @@ -48,15 +49,13 @@ def set_color_mode(mode: choices.ColorMode) -> None: if mode is choices.ColorMode.auto: return if mode is choices.ColorMode.always: - os.environ["FORCE_COLOR"] = "1" - os.environ.pop("NO_COLOR", None) + env.force_color() rebuilt = { "console": theme.make_console(force_terminal=True), "error_console": theme.make_console(stderr=True, force_terminal=True), } else: - os.environ["NO_COLOR"] = "1" - os.environ.pop("FORCE_COLOR", None) + env.disable_color() rebuilt = { "console": theme.make_console(no_color=True), "error_console": theme.make_console(stderr=True, no_color=True), diff --git a/aai_cli/render.py b/aai_cli/ui/render.py similarity index 98% rename from aai_cli/render.py rename to aai_cli/ui/render.py index 5f7a18cd..d404e943 100644 --- a/aai_cli/render.py +++ b/aai_cli/ui/render.py @@ -7,7 +7,8 @@ from rich.live import Live from rich.text import Text -from aai_cli import jsonshape, theme +from aai_cli.core import jsonshape +from aai_cli.ui import theme class BaseRenderer: diff --git a/aai_cli/steps.py b/aai_cli/ui/steps.py similarity index 96% rename from aai_cli/steps.py rename to aai_cli/ui/steps.py index 22207e8b..62d1bcde 100644 --- a/aai_cli/steps.py +++ b/aai_cli/ui/steps.py @@ -4,7 +4,7 @@ from rich.markup import escape -from aai_cli import theme +from aai_cli.ui import theme class Step(TypedDict): diff --git a/aai_cli/theme.py b/aai_cli/ui/theme.py similarity index 100% rename from aai_cli/theme.py rename to aai_cli/ui/theme.py diff --git a/aai_cli/typer_patches.py b/aai_cli/ui/typer_patches.py similarity index 98% rename from aai_cli/typer_patches.py rename to aai_cli/ui/typer_patches.py index 160868e0..c57e9035 100644 --- a/aai_cli/typer_patches.py +++ b/aai_cli/ui/typer_patches.py @@ -25,8 +25,9 @@ from typer._click.exceptions import ClickException, NoSuchOption from typer._click.exceptions import UsageError as ClickUsageError -from aai_cli import argscan, output, theme -from aai_cli.errors import UsageError +from aai_cli.core import argscan +from aai_cli.core.errors import UsageError +from aai_cli.ui import output, theme if TYPE_CHECKING: # Typer (>=0.13) vendors its own click; these patches receive its context diff --git a/aai_cli/update_check.py b/aai_cli/ui/update_check.py similarity index 96% rename from aai_cli/update_check.py rename to aai_cli/ui/update_check.py index a6f2345d..35e27ba9 100644 --- a/aai_cli/update_check.py +++ b/aai_cli/ui/update_check.py @@ -9,7 +9,6 @@ from __future__ import annotations -import os import sys import time @@ -18,8 +17,10 @@ from rich.panel import Panel from rich.text import Text -from aai_cli import __version__, config, output, procs -from aai_cli.errors import CLIError +from aai_cli import __version__ +from aai_cli.core import config, env, procs +from aai_cli.core.errors import CLIError +from aai_cli.ui import output ENV_DISABLED = "AAI_NO_UPDATE_CHECK" _RELEASES_URL = "https://api.github.com/repos/AssemblyAI/cli/releases/latest" @@ -95,7 +96,7 @@ def _should_notify(*, json_mode: bool) -> bool: """Notify only on human, interactive, opted-in, non-CI runs.""" if json_mode: return False - if os.environ.get(ENV_DISABLED) or os.environ.get("CI"): + if env.get(ENV_DISABLED) or env.get("CI"): return False return bool(output.error_console.is_terminal) diff --git a/pyproject.toml b/pyproject.toml index 964a98f2..73047a80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -289,8 +289,8 @@ ban-relative-imports = "all" # exemplars (string literals) don't trip it. [tool.ruff.lint.flake8-tidy-imports.banned-api] "subprocess".msg = "Spawn detached children via aai_cli.procs; if a module genuinely needs raw subprocess, add it to the TID251 allowlist in pyproject.toml." -"os.environ".msg = "Resolve configuration through aai_cli.config / aai_cli.context (which centralize precedence and secret handling); env-owning modules are allowlisted for TID251 in pyproject.toml." -"os.getenv".msg = "Use os.environ.get (the single project idiom) via an env-owning module; see the TID251 allowlist in pyproject.toml." +"os.environ".msg = "Read/write the environment through aai_cli.core.env (env.get/child_env/force_color/...), the single allowlisted chokepoint; callers still own their variable names." +"os.getenv".msg = "Use aai_cli.core.env.get, the single project idiom and the only module allowlisted for raw os.environ (TID251)." "os.putenv".msg = "os.putenv/os.unsetenv bypass os.environ and desync the mapping; mutate os.environ instead." "os.unsetenv".msg = "os.putenv/os.unsetenv bypass os.environ and desync the mapping; mutate os.environ instead." @@ -326,29 +326,37 @@ max-statements = 40 # command signatures do. "aai_cli/options.py" = ["FBT003"] # Raw stdout/stderr writes are centralized here; command modules call output helpers. -# TID251: output owns the FORCE_COLOR/NO_COLOR env toggles and TTY/agent detection. -"aai_cli/output.py" = ["T201", "TID251"] +"aai_cli/ui/output.py" = ["T201"] # The active environment is process-global startup state by design. -# TID251: environments.py owns AAI_ENV resolution (an env-owning module). -"aai_cli/environments.py" = ["PLW0603", "TID251"] +"aai_cli/core/environments.py" = ["PLW0603"] # Verbosity is process-global startup state by design (mirrors environments.py). -"aai_cli/debuglog.py" = ["PLW0603"] +"aai_cli/core/debuglog.py" = ["PLW0603"] # BaseHTTPRequestHandler.log_message requires a parameter named `format`. "aai_cli/auth/loopback.py" = ["A002"] # Template constants include URL path names such as TOKEN_PATH, not credentials. # TID251: the scaffolds are end-user example apps that read their own config straight # from os.environ — that's correct, idiomatic code to ship, not a CLI-internal env read. "aai_cli/init/templates/**" = ["S105", "TID251"] +# ENV_CLIENT_TOKEN holds an env-var *name*; the shipped token constant is empty in +# source (release builds inject the write-only client token). +"aai_cli/core/telemetry.py" = ["S105"] # TID251 banned-api allowlist (see [tool.ruff.lint.flake8-tidy-imports.banned-api]). -# These are the only modules permitted raw `subprocess` (process spawning) or raw -# `os.environ`/`os.getenv` (environment access). Splitting the ignore per file keeps the -# blast radius explicit: a new module needing either must be added here in review. -# Process-spawning modules (shell out to claude/npx/ffmpeg/yt-dlp/tunnels/etc.): -"aai_cli/procs.py" = ["TID251"] -"aai_cli/coding_agent.py" = ["TID251"] -"aai_cli/mediafile.py" = ["TID251"] -"aai_cli/setup_exec.py" = ["TID251"] +# Two OS boundaries are fenced; each is owned by a chokepoint so the allowlist stays +# small and a new module reaching past the boundary trips the gate in review. +# Environment access: aai_cli.core.env is the SINGLE module permitted raw os.environ; +# every other module reads/writes the environment through it (callers still own their +# variable *names*). One entry, enforced structurally — not a per-file list to drift. +"aai_cli/core/env.py" = ["TID251"] +# Process spawning: unlike env reads, these are genuinely diverse (sync-capture, +# long-lived Popen with pipes, detached children) and each owns shelling out to its +# specific external tool — funnelling them through one module would just re-export all +# of `subprocess`, so they stay individually allowlisted (claude/npx/ffmpeg/yt-dlp/ +# tunnels/vercel/the macOS Swift helper, etc.): +"aai_cli/core/procs.py" = ["TID251"] +"aai_cli/app/coding_agent.py" = ["TID251"] +"aai_cli/app/mediafile.py" = ["TID251"] +"aai_cli/app/setup_exec.py" = ["TID251"] "aai_cli/commands/deploy/_exec.py" = ["TID251"] "aai_cli/commands/update.py" = ["TID251"] "aai_cli/commands/webhooks/_listen.py" = ["TID251"] @@ -356,20 +364,6 @@ max-statements = 40 "aai_cli/init/tunnel.py" = ["TID251"] "aai_cli/streaming/macos.py" = ["TID251"] "aai_cli/streaming/sources.py" = ["TID251"] -# Environment-owning modules (config/auth/env resolution; output & environments are -# allowlisted above alongside their existing ignores): -"aai_cli/config.py" = ["TID251"] -"aai_cli/context.py" = ["TID251"] -"aai_cli/update_check.py" = ["TID251"] -"aai_cli/auth/endpoints.py" = ["TID251"] -"aai_cli/init/keys.py" = ["TID251"] -"aai_cli/commands/dev/_exec.py" = ["TID251"] -"aai_cli/commands/share/_exec.py" = ["TID251"] -"aai_cli/commands/evaluate/_hf_api.py" = ["TID251"] -# ENV_CLIENT_TOKEN holds an env-var *name*; the shipped token constant is empty in -# source (release builds inject the write-only client token). TID251: telemetry reads -# its opt-out / intake-URL / CI-detection env vars (an env-owning module). -"aai_cli/telemetry.py" = ["S105", "TID251"] [tool.vulture] paths = ["aai_cli", "tests"] diff --git a/scripts/record_fixtures.py b/scripts/record_fixtures.py index d42ea8a4..97e2cb62 100644 --- a/scripts/record_fixtures.py +++ b/scripts/record_fixtures.py @@ -28,9 +28,9 @@ import assemblyai as aai -from aai_cli import client, config, environments, llm from aai_cli.auth import ams -from aai_cli.errors import CLIError +from aai_cli.core import client, config, environments, llm +from aai_cli.core.errors import CLIError FIXTURE_DIR = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "api" PROFILE = "default" diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 8c2c0661..4b93caab 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -102,7 +102,7 @@ these live in the default suite. Three moving parts: Refresh after an API shape change: `ASSEMBLYAI_API_KEY=… uv run python scripts/record_fixtures.py`. The key comes from the env; the AMS session JWT + `account_id` from the keyring/`config.toml` of whoever ran `assembly login` (profile `default`) — neither is ever written to a fixture. -- **`tests/replay_fixtures.py`** — rebuilds the boundary objects from JSON. A transcript is a +- **`tests/_replay_fixtures.py`** — rebuilds the boundary objects from JSON. A transcript is a real `aai.Transcript` via `Transcript.from_response`; an LLM response is rebuilt with `ChatCompletion.model_construct` (**not** `model_validate`) because the gateway returns Anthropic-flavored fields — `finish_reason="end_turn"`, token counts under diff --git a/tests/_clip_helpers.py b/tests/_clip_helpers.py index bda873eb..ce50c2e9 100644 --- a/tests/_clip_helpers.py +++ b/tests/_clip_helpers.py @@ -14,8 +14,9 @@ import pytest -from aai_cli import llm, mediafile +from aai_cli.app import mediafile from aai_cli.commands.clip._exec import ClipOptions +from aai_cli.core import llm _ANSI_SGR = re.compile(r"\x1b\[[0-9;]*m") diff --git a/tests/_dub_helpers.py b/tests/_dub_helpers.py index 16852e84..cfe87491 100644 --- a/tests/_dub_helpers.py +++ b/tests/_dub_helpers.py @@ -15,8 +15,9 @@ import pytest -from aai_cli import client, config, llm, mediafile +from aai_cli.app import mediafile from aai_cli.commands.dub._exec import DubOptions +from aai_cli.core import client, config, llm from aai_cli.tts import session from aai_cli.tts.session import SpeakResult diff --git a/tests/replay_fixtures.py b/tests/_replay_fixtures.py similarity index 100% rename from tests/replay_fixtures.py rename to tests/_replay_fixtures.py diff --git a/tests/setup_helpers.py b/tests/_setup_helpers.py similarity index 98% rename from tests/setup_helpers.py rename to tests/_setup_helpers.py index dd5ce6ef..19e6502b 100644 --- a/tests/setup_helpers.py +++ b/tests/_setup_helpers.py @@ -57,7 +57,7 @@ def __call__(self, cmd, *args, **kwargs): def _all_tools_present(monkeypatch): monkeypatch.setattr( - "aai_cli.setup_exec.shutil.which", + "aai_cli.app.setup_exec.shutil.which", lambda tool: f"/usr/bin/{tool}", ) diff --git a/tests/conftest.py b/tests/conftest.py index f115411b..8afd5e3a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -120,7 +120,7 @@ def preserve_logging_state(): # opting test asserts on. import logging - from aai_cli import ws as wsutil + from aai_cli.core import ws as wsutil root = logging.getLogger() previous_handlers = list(root.handlers) @@ -140,7 +140,7 @@ def preserve_logging_state(): def reset_active_environment(): # The active environment is a process-global (set at CLI startup); pin it to # the default before each test so unit tests aren't affected by ordering. - from aai_cli import environments + from aai_cli.core import environments environments.set_active(environments.get(environments.DEFAULT_ENV)) @@ -159,7 +159,7 @@ def neutralize_shipped_token(monkeypatch): # *subprocess* telemetry spawns, so blank the token suite-wide: tests exercise # telemetry by opting in via AAI_TELEMETRY_CLIENT_TOKEN and patching dispatch. # Returns the real shipped value so its own tests can still assert its shape. - from aai_cli import telemetry + from aai_cli.core import telemetry original = telemetry.SHIPPED_CLIENT_TOKEN monkeypatch.setattr(telemetry, "SHIPPED_CLIENT_TOKEN", "") @@ -187,5 +187,5 @@ def memory_fs(): def tmp_config(monkeypatch, tmp_path): cfg_dir = tmp_path / "config" cfg_dir.mkdir() - monkeypatch.setattr("aai_cli.config.config_dir", lambda: cfg_dir) + monkeypatch.setattr("aai_cli.core.config.config_dir", lambda: cfg_dir) return cfg_dir diff --git a/tests/test_account_command.py b/tests/test_account_command.py index f7d8cbca..90f290cd 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -3,9 +3,9 @@ import pytest from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult from aai_cli.commands import account +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -34,7 +34,7 @@ def test_balance_formats_dollars(mocker): def test_balance_without_session_runs_login(monkeypatch, mocker): - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) get_balance = mocker.patch( "aai_cli.commands.account.ams.get_balance", @@ -334,7 +334,7 @@ def test_usage_invalid_date_fails_before_session_resolution(monkeypatch, mocker) 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.app.context._interactive_session", lambda: True) 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"]) @@ -391,7 +391,7 @@ def test_format_usage_number_fractional_trims_trailing_zeros(): def test_usage_rejects_end_before_start(monkeypatch, mocker): # A reversed range is a fast exit-2 usage error before session resolution or # any AMS call — even when not logged in. - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) monkeypatch.setattr( "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("login must not start")), @@ -417,7 +417,7 @@ def test_usage_allows_equal_start_and_end(mocker): def test_usage_rejects_unknown_window(monkeypatch, mocker): # --window was free text silently misinterpreted server-side; now it's validated # client-side, before session resolution or any AMS call. - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) monkeypatch.setattr( "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("login must not start")), diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index bf56f4ed..e5cf5f9a 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -4,7 +4,7 @@ import pytest -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError class FakeStream: diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index d6c84f08..8c7a2ce8 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -2,9 +2,9 @@ from typer.testing import CliRunner -from aai_cli import config from aai_cli.agent.voices import VOICES, format_voice_list from aai_cli.auth.flow import LoginResult +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -57,7 +57,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.app.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) monkeypatch.setattr("aai_cli.commands.agent._exec.FileSource", lambda src: f"filesrc:{src}") @@ -368,7 +368,7 @@ def test_resolve_system_prompt_unreadable_file_raises_clierror(tmp_path): import pytest from aai_cli.commands.agent import _exec as agent_exec - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError missing = Path(tmp_path) / "does-not-exist.txt" with pytest.raises(CLIError) as exc: diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 45b58412..9b65d16a 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -3,8 +3,8 @@ import pytest -from aai_cli import theme from aai_cli.agent.render import AgentRenderer +from aai_cli.ui import theme def _json_lines(buf: io.StringIO): diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index fd8f6a84..802345b6 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -10,7 +10,7 @@ import pytest from aai_cli.agent.session import VoiceAgentSession, _send_audio_loop -from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.core.errors import APIError, NotAuthenticated class FakeRenderer: diff --git a/tests/test_agent_session_run.py b/tests/test_agent_session_run.py index 780f8ce2..ccbb2bba 100644 --- a/tests/test_agent_session_run.py +++ b/tests/test_agent_session_run.py @@ -10,8 +10,8 @@ import pytest from aai_cli.agent.session import AgentRunConfig, run_session -from aai_cli.errors import APIError, CLIError, NotAuthenticated -from aai_cli.ws import WEBSOCKETS_LOGGERS +from aai_cli.core.errors import APIError, CLIError, NotAuthenticated +from aai_cli.core.ws import WEBSOCKETS_LOGGERS class FakeRenderer: @@ -118,7 +118,7 @@ def close(self): def test_run_session_surfaces_mic_open_failure_from_capture_thread(): import threading as _threading - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError class _BoomMic: def __iter__(self): @@ -376,7 +376,7 @@ def test_run_session_ws_url_follows_active_environment() -> None: # 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 + from aai_cli.core import environments seen: dict[str, str] = {} diff --git a/tests/test_ams_account.py b/tests/test_ams_account.py index 70c7428a..eff2abdf 100644 --- a/tests/test_ams_account.py +++ b/tests/test_ams_account.py @@ -2,7 +2,7 @@ import pytest from aai_cli.auth import ams -from aai_cli.errors import NotAuthenticated +from aai_cli.core.errors import NotAuthenticated def _patch_transport(monkeypatch, handler): diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py index 5bb94dd2..74a57246 100644 --- a/tests/test_audit_command.py +++ b/tests/test_audit_command.py @@ -3,9 +3,9 @@ from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult from aai_cli.commands import audit +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -167,7 +167,7 @@ def test_audit_summarizes_all_login_rows(mocker): def test_audit_without_session_runs_login(monkeypatch, mocker): - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) 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_auth_ams.py b/tests/test_auth_ams.py index 61c5bea7..d5a32758 100644 --- a/tests/test_auth_ams.py +++ b/tests/test_auth_ams.py @@ -2,7 +2,7 @@ import pytest from aai_cli.auth import ams -from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.core.errors import APIError, NotAuthenticated def _patch_transport(monkeypatch, handler): diff --git a/tests/test_auth_endpoints.py b/tests/test_auth_endpoints.py index 03a45b44..8f66a79c 100644 --- a/tests/test_auth_endpoints.py +++ b/tests/test_auth_endpoints.py @@ -1,7 +1,7 @@ import pytest from aai_cli.auth import endpoints -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError def test_redirect_uri_is_fixed_loopback(): diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index 259a09fc..e2b2319a 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -1,7 +1,7 @@ import pytest from aai_cli.auth import flow, loopback -from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.core.errors import APIError, NotAuthenticated class _FakeCapture: diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index 8a5dee19..a0cd0e0b 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -6,7 +6,7 @@ import pytest from aai_cli.auth import endpoints, loopback -from aai_cli.errors import APIError +from aai_cli.core.errors import APIError # These tests bind a real loopback HTTP server and connect to it, so they opt back # into sockets past the suite-wide --disable-socket (see pyproject pytest config). diff --git a/tests/test_caption_exec.py b/tests/test_caption_exec.py index 3785852e..12eff9d5 100644 --- a/tests/test_caption_exec.py +++ b/tests/test_caption_exec.py @@ -16,11 +16,12 @@ import pytest -from aai_cli import client, config, mediafile, youtube +from aai_cli.app import mediafile +from aai_cli.app.context import AppState from aai_cli.commands.caption import _exec as caption_exec from aai_cli.commands.caption._exec import CaptionOptions -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import client, config, youtube +from aai_cli.core.errors import CLIError, UsageError from tests._clip_helpers import plain # The CLI's flag defaults, as data. Tests override per-case with dataclasses.replace. diff --git a/tests/test_client.py b/tests/test_client.py index 06bd2e6c..05019534 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,8 +6,8 @@ import assemblyai as aai import pytest -from aai_cli import client -from aai_cli.errors import APIError +from aai_cli.core import client +from aai_cli.core.errors import APIError def test_validate_key_true_on_success(mocker): @@ -166,7 +166,7 @@ def test_list_transcripts_auth_error_becomes_apierror(mocker): def test_list_transcripts_rejected_key_becomes_not_authenticated(mocker): - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated T = mocker.patch.object(client.aai, "Transcriber", autospec=True) T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( @@ -290,7 +290,7 @@ def test_select_transcript_field_vtt_network_error_becomes_apierror(mocker): def test_select_transcript_field_srt_auth_error_becomes_not_authenticated(mocker): - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated t = mocker.MagicMock() t.export_subtitles_srt.side_effect = RuntimeError("HTTP 401 Unauthorized") @@ -299,7 +299,7 @@ def test_select_transcript_field_srt_auth_error_becomes_not_authenticated(mocker def test_select_transcript_field_vtt_auth_error_becomes_not_authenticated(mocker): - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated t = mocker.MagicMock() t.export_subtitles_vtt.side_effect = RuntimeError("HTTP 401 Unauthorized") @@ -318,7 +318,7 @@ def test_validate_chars_per_caption_allows_unset_value(): @pytest.mark.parametrize("field", [None, "text", "json"]) def test_validate_chars_per_caption_rejects_non_subtitle_fields(field): - from aai_cli.errors import UsageError + from aai_cli.core.errors import UsageError with pytest.raises(UsageError) as exc: client.validate_chars_per_caption(40, field) @@ -341,7 +341,7 @@ def test_get_transcript_generic_error_becomes_apierror(mocker): def test_get_transcript_auth_error_becomes_not_authenticated(mocker): - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated mocker.patch.object( client.aai.Transcript, "get_by_id", side_effect=RuntimeError("HTTP 401 Unauthorized") @@ -359,7 +359,7 @@ def test_transcribe_network_error_becomes_apierror(mocker): def test_transcribe_auth_error_becomes_not_authenticated(mocker): - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated fake_transcriber = mocker.MagicMock() fake_transcriber.transcribe.side_effect = RuntimeError("Invalid API key") @@ -371,7 +371,7 @@ def test_transcribe_auth_error_becomes_not_authenticated(mocker): def test_transcribe_passes_prebuilt_config(monkeypatch, mocker): import assemblyai as aai - from aai_cli import client + from aai_cli.core import client captured = {} diff --git a/tests/test_client_streaming.py b/tests/test_client_streaming.py index 66de830a..38fa46e5 100644 --- a/tests/test_client_streaming.py +++ b/tests/test_client_streaming.py @@ -8,8 +8,8 @@ import pytest -from aai_cli import client -from aai_cli.errors import APIError +from aai_cli.core import client +from aai_cli.core.errors import APIError def _stream_params(sample_rate: int = 16000): @@ -135,7 +135,7 @@ def connect(self, params): def test_stream_audio_connect_auth_error_becomes_not_authenticated(monkeypatch): - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated class ConnectUnauthorized(_FakeStreamingClient): def connect(self, params): @@ -147,7 +147,7 @@ def connect(self, params): def test_stream_audio_auth_error_event_becomes_not_authenticated(monkeypatch): - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated class AuthErrClient(_FakeStreamingClient): def stream(self, source): @@ -188,7 +188,7 @@ def stream(self, source): def test_stream_audio_handshake_401_event_is_not_authenticated_with_suggestion(monkeypatch): from assemblyai.streaming.v3 import StreamingError - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated class Handshake401Client(_FakeStreamingClient): def stream(self, source): @@ -244,7 +244,7 @@ def test_stream_audio_swallows_broken_pipe_in_callback(monkeypatch): # reader thread; the guard must swallow it instead of dumping a thread traceback. monkeypatch.setattr(client, "StreamingClient", _FakeStreamingClient) # never touch the real stdout fd during the test - monkeypatch.setattr("aai_cli.stdio.silence_stdout", lambda: None) + monkeypatch.setattr("aai_cli.core.stdio.silence_stdout", lambda: None) def on_turn(_event): raise BrokenPipeError @@ -253,7 +253,7 @@ def on_turn(_event): def test_stream_audio_passes_through_clierror(monkeypatch): - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError class StreamRaisesCLIError(_FakeStreamingClient): def stream(self, source): @@ -268,7 +268,7 @@ def stream(self, source): def test_stream_audio_accepts_params(monkeypatch): from assemblyai.streaming.v3 import SpeechModel, StreamingParameters - from aai_cli import client + from aai_cli.core import client captured = {} @@ -288,7 +288,7 @@ def stream(self, source): def disconnect(self, terminate=True): pass - monkeypatch.setattr("aai_cli.client.StreamingClient", FakeSC) + monkeypatch.setattr("aai_cli.core.client.StreamingClient", FakeSC) params = StreamingParameters( sample_rate=16000, speech_model=SpeechModel.universal_streaming_multilingual ) diff --git a/tests/test_clip_command.py b/tests/test_clip_command.py index a6162ee3..4ed1872d 100644 --- a/tests/test_clip_command.py +++ b/tests/test_clip_command.py @@ -9,9 +9,10 @@ from typer.testing import CliRunner -from aai_cli import llm, mediafile +from aai_cli.app import mediafile from aai_cli.commands.clip import _exec as clip_exec from aai_cli.commands.clip._exec import ClipOptions +from aai_cli.core import llm from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_clip_exec.py b/tests/test_clip_exec.py index 98e97ec1..aa887161 100644 --- a/tests/test_clip_exec.py +++ b/tests/test_clip_exec.py @@ -16,11 +16,12 @@ import pytest -from aai_cli import client, config, mediafile +from aai_cli.app import mediafile +from aai_cli.app.context import AppState from aai_cli.commands.clip import _exec as clip_exec from aai_cli.commands.clip._select import Segment -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import client, config +from aai_cli.core.errors import CLIError, UsageError from tests._clip_helpers import ( DEFAULTS, UTTERANCES, diff --git a/tests/test_clip_select.py b/tests/test_clip_select.py index b5022ef2..3811e215 100644 --- a/tests/test_clip_select.py +++ b/tests/test_clip_select.py @@ -10,7 +10,7 @@ from aai_cli.commands.clip import _select as clip_select from aai_cli.commands.clip._select import Segment -from aai_cli.errors import CLIError, UsageError +from aai_cli.core.errors import CLIError, UsageError from tests._clip_helpers import UTTERANCES # --- range parsing ----------------------------------------------------------- diff --git a/tests/test_clip_sources.py b/tests/test_clip_sources.py index 6374ef57..b040d978 100644 --- a/tests/test_clip_sources.py +++ b/tests/test_clip_sources.py @@ -11,11 +11,11 @@ import pytest -from aai_cli import client, config +from aai_cli.app.context import AppState from aai_cli.commands.clip import _exec as clip_exec from aai_cli.commands.clip import _select as clip_select -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import client, config +from aai_cli.core.errors import CLIError, UsageError from tests._clip_helpers import DEFAULTS, UTTERANCES, fake_transcript, record_ffmpeg diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 3ffbc3ca..a1d85337 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -64,7 +64,7 @@ def test_every_render_feature_has_a_snippet(): # but no `_render_speaker_labels` function, so it is an allowed orphan. import inspect - from aai_cli import transcribe_render + from aai_cli.app.transcribe import render as transcribe_render rendered = { name[len("_render_") :] @@ -254,9 +254,9 @@ def test_output_field_maps_cover_every_transcript_output_choice(): # 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 + from aai_cli.core import client + from aai_cli.core.choices import TranscriptOutput values = {member.value for member in TranscriptOutput} assert set(_OUTPUT_SNIPPETS) == values @@ -359,7 +359,7 @@ def test_transcribe_show_code_without_gateway_has_no_openai_import(): def test_generated_code_targets_active_environment(): # --show-code embeds hosts from the active environment, so a sandbox user's # generated script talks to the sandbox that minted their key, not production. - from aai_cli import environments + from aai_cli.core import environments sandbox = environments.get("sandbox000") environments.set_active(sandbox) diff --git a/tests/test_code_gen_fuzz.py b/tests/test_code_gen_fuzz.py index 5d3c7784..e40ef42d 100644 --- a/tests/test_code_gen_fuzz.py +++ b/tests/test_code_gen_fuzz.py @@ -11,9 +11,10 @@ from hypothesis import given, settings from hypothesis import strategies as st -from aai_cli import code_gen, config_builder +from aai_cli import code_gen from aai_cli.code_gen import serialize, snippets from aai_cli.code_gen.transcribe import render as render_transcribe_code +from aai_cli.core import config_builder settings.register_profile("codegen", max_examples=150) settings.load_profile("codegen") diff --git a/tests/test_coding_agent.py b/tests/test_coding_agent.py index 786efb53..f856b097 100644 --- a/tests/test_coding_agent.py +++ b/tests/test_coding_agent.py @@ -1,10 +1,10 @@ -"""Unit tests for aai_cli.coding_agent — the setup/doctor shared presence probes.""" +"""Unit tests for aai_cli.app.coding_agent — the setup/doctor shared presence probes.""" import subprocess import pytest -from aai_cli import coding_agent +from aai_cli.app import coding_agent @pytest.fixture(autouse=True) diff --git a/tests/test_color_mode.py b/tests/test_color_mode.py index e70662a3..095f1075 100644 --- a/tests/test_color_mode.py +++ b/tests/test_color_mode.py @@ -5,9 +5,9 @@ import pytest from typer.testing import CliRunner -from aai_cli import output -from aai_cli.choices import ColorMode +from aai_cli.core.choices import ColorMode from aai_cli.main import app +from aai_cli.ui import output runner = CliRunner() diff --git a/tests/test_command_options_seam.py b/tests/test_command_options_seam.py index c494a9b1..d2088394 100644 --- a/tests/test_command_options_seam.py +++ b/tests/test_command_options_seam.py @@ -14,14 +14,15 @@ import pytest import typer -from aai_cli import choices, config, llm, transcribe_exec from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT from aai_cli.agent.voices import DEFAULT_VOICE +from aai_cli.app.context import AppState +from aai_cli.app.transcribe import run as transcribe_exec from aai_cli.commands.agent import _exec as agent_exec from aai_cli.commands.llm import _exec as llm_exec from aai_cli.commands.speak import _exec as speak_exec -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import choices, config, llm +from aai_cli.core.errors import CLIError, UsageError from aai_cli.options import DEFAULT_BATCH_CONCURRENCY # The CLI's flag defaults, as data. Tests override per-case with dataclasses.replace. diff --git a/tests/test_completion.py b/tests/test_completion.py index 2b77199a..24635e71 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -3,7 +3,7 @@ import typer from aai_cli.agent.voices import VOICE_NAMES, complete_voice -from aai_cli.llm import KNOWN_MODELS, complete_model +from aai_cli.core.llm import KNOWN_MODELS, complete_model from aai_cli.main import app diff --git a/tests/test_config.py b/tests/test_config.py index b93a179d..388ba766 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,8 +2,8 @@ import pytest -from aai_cli import config -from aai_cli.errors import CLIError, NotAuthenticated +from aai_cli.core import config +from aai_cli.core.errors import CLIError, NotAuthenticated def test_set_and_get_api_key_roundtrip(): @@ -178,7 +178,7 @@ def test_persist_login_restores_prior_credentials_when_session_write_fails(monke def test_invalid_profile_name_rejected(): import pytest - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError with pytest.raises(CLIError): config.set_api_key("bad name!", "sk_x") @@ -187,7 +187,7 @@ def test_invalid_profile_name_rejected(): def test_empty_api_key_flag_rejected(): import pytest - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError with pytest.raises(CLIError) as exc: config.resolve_api_key(api_key_flag="") @@ -205,7 +205,7 @@ def test_invalid_profile_name_has_suggestion(): def test_malformed_config_raises_clean_error(tmp_config): - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError (tmp_config / "config.toml").write_text("this is not = = valid toml ===\n") with pytest.raises(CLIError) as exc: @@ -270,7 +270,7 @@ def test_dump_creates_missing_parent_directories(monkeypatch, tmp_path): # The config dir's parents may not exist yet (first run on a fresh machine); # _dump must create the whole chain (mkdir parents=True), not just the leaf. nested = tmp_path / "deeply" / "nested" / "config" - monkeypatch.setattr("aai_cli.config.config_dir", lambda: nested) + monkeypatch.setattr("aai_cli.core.config.config_dir", lambda: nested) config.set_api_key("default", "sk_abc") assert nested.is_dir() assert (nested / "config.toml").exists() diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index 3d571c75..3a9dd395 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -2,8 +2,8 @@ import pytest -from aai_cli import config_builder as cb -from aai_cli.errors import UsageError +from aai_cli.core import config_builder as cb +from aai_cli.core.errors import UsageError def _param_names(model_cls) -> set[str]: @@ -186,7 +186,7 @@ def test_every_transcribe_field_is_a_valid_param(field): def test_merge_transcribe_config_returns_kwargs_dict(): - from aai_cli import config_builder + from aai_cli.core import config_builder merged = config_builder.merge_transcribe_config( flags={"speaker_labels": True, "language_code": None}, @@ -199,7 +199,7 @@ def test_merge_transcribe_config_returns_kwargs_dict(): def test_construct_transcribe_config_from_merged(): import assemblyai as aai - from aai_cli import config_builder + from aai_cli.core import config_builder tc = config_builder.construct_transcription_config({"speaker_labels": True}) assert isinstance(tc, aai.TranscriptionConfig) @@ -210,7 +210,7 @@ def test_construct_transcribe_config_from_merged(): def test_merge_streaming_params_coerces_speech_model_enum(): from assemblyai.streaming.v3 import SpeechModel - from aai_cli import config_builder + from aai_cli.core import config_builder merged = config_builder.merge_streaming_params( flags={"speech_model": "universal-streaming-multilingual", "sample_rate": 16000}, diff --git a/tests/test_config_command.py b/tests/test_config_command.py index 17ad4908..da4cf60e 100644 --- a/tests/test_config_command.py +++ b/tests/test_config_command.py @@ -5,8 +5,8 @@ import pytest from typer.testing import CliRunner -from aai_cli import config -from aai_cli.errors import CLIError +from aai_cli.core import config +from aai_cli.core.errors import CLIError from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_config_session.py b/tests/test_config_session.py index 4787af6a..004ab4c9 100644 --- a/tests/test_config_session.py +++ b/tests/test_config_session.py @@ -1,4 +1,4 @@ -from aai_cli import config +from aai_cli.core import config def test_set_and_get_session_roundtrips(): diff --git a/tests/test_context.py b/tests/test_context.py index 2d6af38a..48d9bf39 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -5,10 +5,10 @@ import typer from typer.testing import CliRunner -from aai_cli import config, environments +from aai_cli.app.context import AppState, _interactive_session, run_command from aai_cli.auth.flow import LoginResult -from aai_cli.context import AppState, _interactive_session, run_command -from aai_cli.errors import APIError, NotAuthenticated, auth_failure +from aai_cli.core import config, environments +from aai_cli.core.errors import APIError, NotAuthenticated, auth_failure runner = CliRunner() @@ -29,7 +29,7 @@ def go(ctx: typer.Context): def _force_interactive(monkeypatch): """Pretend a human is at the terminal (CliRunner/pytest streams are never TTYs).""" - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) class _TtyProbe: @@ -54,7 +54,7 @@ def test_interactive_session_requires_both_ttys_and_no_agent( ): monkeypatch.setattr(sys, "stdin", _TtyProbe(stdin_tty)) monkeypatch.setattr(sys, "stderr", _TtyProbe(stderr_tty)) - monkeypatch.setattr("aai_cli.output.is_agentic", lambda: agentic) + monkeypatch.setattr("aai_cli.ui.output.is_agentic", lambda: agentic) assert _interactive_session() is expected @@ -165,7 +165,7 @@ def test_run_command_auto_login_persistence_failure_is_clean(monkeypatch): ), ) monkeypatch.setattr( - "aai_cli.context.config.set_api_key", + "aai_cli.app.context.config.set_api_key", lambda *_args: (_ for _ in ()).throw(OSError("keyring is unavailable")), ) @@ -193,7 +193,7 @@ def test_run_command_auto_login_persistence_type_error_is_clean(monkeypatch): ), ) monkeypatch.setattr( - "aai_cli.context.config.set_api_key", + "aai_cli.app.context.config.set_api_key", lambda *_args: (_ for _ in ()).throw(TypeError("not TOML-serializable")), ) @@ -316,8 +316,8 @@ def test_env_override_warning_none_when_aai_env_matches_profile(monkeypatch): def test_resolve_session_returns_account_and_jwt(): - from aai_cli import config - from aai_cli.context import AppState + from aai_cli.app.context import AppState + from aai_cli.core import config config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) account_id, jwt = AppState().resolve_session() @@ -328,8 +328,8 @@ def test_resolve_session_returns_account_and_jwt(): def test_resolve_session_raises_when_no_session(): import pytest - from aai_cli.context import AppState - from aai_cli.errors import NotAuthenticated + from aai_cli.app.context import AppState + from aai_cli.core.errors import NotAuthenticated with pytest.raises(NotAuthenticated): AppState().resolve_session() @@ -341,8 +341,8 @@ 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 - from aai_cli.errors import NotAuthenticated + from aai_cli.app.context import AppState + from aai_cli.core.errors import NotAuthenticated monkeypatch.setattr(config, "get_session", lambda _profile: {"jwt": "j", "token": "t"}) monkeypatch.setattr(config, "get_account_id", lambda _profile: None) @@ -354,8 +354,8 @@ 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 - from aai_cli.errors import NotAuthenticated + from aai_cli.app.context import AppState + from aai_cli.core.errors import NotAuthenticated monkeypatch.setattr(config, "get_session", lambda _profile: None) monkeypatch.setattr(config, "get_account_id", lambda _profile: 42) @@ -455,7 +455,7 @@ def test_resolve_session_suggestion_never_offers_api_key_env_var(): def test_resolve_profile_rejects_invalid_explicit_profile_fast(): # Validated at resolution time (the root callback), so a typo'd --profile is a # fast exit-2 before any network round-trip, not a keyring-write-time failure. - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError with pytest.raises(CLIError) as exc: AppState(profile="bad name!").resolve_profile() @@ -468,7 +468,7 @@ def test_resolve_profile_returns_valid_explicit_profile(): def test_persist_browser_login_passes_json_mode_to_flow(monkeypatch): - from aai_cli.context import persist_browser_login + from aai_cli.app.context import persist_browser_login seen = {} diff --git a/tests/test_debuglog.py b/tests/test_debuglog.py index d6a97bad..3ad34534 100644 --- a/tests/test_debuglog.py +++ b/tests/test_debuglog.py @@ -10,8 +10,8 @@ import pytest from typer.testing import CliRunner -from aai_cli import config, debuglog -from aai_cli.context import AppState +from aai_cli.app.context import AppState +from aai_cli.core import config, debuglog from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 8c09a112..8ab6f09e 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -52,7 +52,7 @@ def _stub( monkeypatch.setattr( "shutil.which", lambda name: f"/usr/bin/{name}" if name in available else None ) - monkeypatch.setattr("aai_cli.output.is_agentic", lambda: agentic) + monkeypatch.setattr("aai_cli.ui.output.is_agentic", lambda: agentic) calls: dict[str, object] = {} def fake_confirm(prompt: str) -> bool: diff --git a/tests/test_devserver.py b/tests/test_devserver.py index 92b6c757..0bb4a02e 100644 --- a/tests/test_devserver.py +++ b/tests/test_devserver.py @@ -158,7 +158,7 @@ def test_local_host_constant_is_loopback(): def test_notify_port_change_emits_warning_with_both_ports(monkeypatch): calls = [] monkeypatch.setattr( - "aai_cli.output.emit_warning", + "aai_cli.ui.output.emit_warning", lambda msg, *, json_mode: calls.append((msg, json_mode)), ) devserver.notify_port_change(5000, 5001, json_mode=True, quiet=False) @@ -169,7 +169,7 @@ def test_notify_port_change_emits_warning_with_both_ports(monkeypatch): def test_notify_port_change_silent_cases(monkeypatch): calls = [] monkeypatch.setattr( - "aai_cli.output.emit_warning", + "aai_cli.ui.output.emit_warning", lambda msg, *, json_mode: calls.append(msg), ) # Same port bound: nothing to announce. @@ -184,7 +184,7 @@ def test_notify_port_change_silent_cases(monkeypatch): def test_notify_port_change_human_mode_passthrough(monkeypatch): calls = [] monkeypatch.setattr( - "aai_cli.output.emit_warning", + "aai_cli.ui.output.emit_warning", lambda msg, *, json_mode: calls.append(json_mode), ) devserver.notify_port_change(5000, 5001, json_mode=False, quiet=False) diff --git a/tests/test_dictate_exec.py b/tests/test_dictate_exec.py index b6b89c5f..b8174840 100644 --- a/tests/test_dictate_exec.py +++ b/tests/test_dictate_exec.py @@ -14,10 +14,10 @@ import pytest -from aai_cli import config, sync_stt +from aai_cli.app.context import AppState from aai_cli.commands.dictate import _exec as dictate_exec -from aai_cli.context import AppState -from aai_cli.errors import CLIError +from aai_cli.core import config, sync_stt +from aai_cli.core.errors import CLIError DICTATE_DEFAULTS = dictate_exec.DictateOptions( language=None, diff --git a/tests/test_doctor.py b/tests/test_doctor.py index d985f4a2..d3949293 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -6,9 +6,9 @@ import pytest from typer.testing import CliRunner -from aai_cli import config -from aai_cli import doctor_checks as doctor -from aai_cli.errors import APIError +from aai_cli.app import doctor_checks as doctor +from aai_cli.core import config +from aai_cli.core.errors import APIError from aai_cli.main import app runner = CliRunner() @@ -18,12 +18,12 @@ def healthy(monkeypatch): """A fully-ready environment: valid key, all tools present, a microphone.""" config.set_api_key("default", "sk_1234567890") - monkeypatch.setattr("aai_cli.doctor_checks.client.validate_key", lambda _key: True) - monkeypatch.setattr("aai_cli.doctor_checks.shutil.which", lambda tool: f"/usr/bin/{tool}") - monkeypatch.setattr("aai_cli.doctor_checks._probe_input_devices", lambda: 2) + monkeypatch.setattr("aai_cli.app.doctor_checks.client.validate_key", lambda _key: True) + monkeypatch.setattr("aai_cli.app.doctor_checks.shutil.which", lambda tool: f"/usr/bin/{tool}") + monkeypatch.setattr("aai_cli.app.doctor_checks._probe_input_devices", lambda: 2) # The MCP probe shells out to `claude mcp get`; keep the suite hermetic and # report the full setup (docs MCP + both skills) as installed. - monkeypatch.setattr("aai_cli.doctor_checks.coding_agent.missing_components", list) + monkeypatch.setattr("aai_cli.app.doctor_checks.coding_agent.missing_components", list) def _checks(result): @@ -51,7 +51,7 @@ def test_doctor_no_keyring_recommends_env_var(healthy, monkeypatch): # On a box with no usable keyring, `assembly login` can't persist a key either, so the # fix must point at ASSEMBLYAI_API_KEY rather than a dead-end browser login. config.clear_api_key("default") - monkeypatch.setattr("aai_cli.doctor_checks.config.keyring_usable", lambda: False) + monkeypatch.setattr("aai_cli.app.doctor_checks.config.keyring_usable", lambda: False) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 1 api = _checks(result)["api-key"] @@ -67,7 +67,7 @@ def test_doctor_success_suggests_trying_transcribe(healthy): def test_doctor_rejected_key_fails(healthy, monkeypatch): - monkeypatch.setattr("aai_cli.doctor_checks.client.validate_key", lambda _key: False) + monkeypatch.setattr("aai_cli.app.doctor_checks.client.validate_key", lambda _key: False) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 1 api = _checks(result)["api-key"] @@ -83,7 +83,7 @@ def test_doctor_network_error_is_a_failure(healthy, monkeypatch): def boom(_key): raise APIError("Network error contacting AssemblyAI: timeout") - monkeypatch.setattr("aai_cli.doctor_checks.client.validate_key", boom) + monkeypatch.setattr("aai_cli.app.doctor_checks.client.validate_key", boom) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 1 api = _checks(result)["api-key"] @@ -93,7 +93,7 @@ def boom(_key): def test_doctor_ffmpeg_missing_warns_but_passes(healthy, monkeypatch): monkeypatch.setattr( - "aai_cli.doctor_checks.shutil.which", + "aai_cli.app.doctor_checks.shutil.which", lambda tool: None if tool == "ffmpeg" else f"/usr/bin/{tool}", ) result = runner.invoke(app, ["doctor", "--json"]) @@ -109,7 +109,7 @@ def test_doctor_audio_unavailable_warns_but_passes(healthy, monkeypatch): def no_audio(): raise ImportError("no sounddevice") - monkeypatch.setattr("aai_cli.doctor_checks._probe_input_devices", no_audio) + monkeypatch.setattr("aai_cli.app.doctor_checks._probe_input_devices", no_audio) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 0 audio = _checks(result)["audio"] @@ -118,7 +118,7 @@ def no_audio(): def test_doctor_no_microphone_warns(healthy, monkeypatch): - monkeypatch.setattr("aai_cli.doctor_checks._probe_input_devices", lambda: 0) + monkeypatch.setattr("aai_cli.app.doctor_checks._probe_input_devices", lambda: 0) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 0 assert _checks(result)["audio"]["status"] == "warn" @@ -135,7 +135,7 @@ def test_doctor_coding_agent_fully_set_up_does_not_suggest_install(healthy): def test_doctor_coding_agent_not_set_up_names_whats_missing(healthy, monkeypatch): monkeypatch.setattr( - "aai_cli.doctor_checks.coding_agent.missing_components", + "aai_cli.app.doctor_checks.coding_agent.missing_components", lambda: ["docs MCP", "aai-cli skill"], ) result = runner.invoke(app, ["doctor", "--json"]) @@ -149,7 +149,7 @@ def test_doctor_coding_agent_not_set_up_names_whats_missing(healthy, monkeypatch def test_doctor_coding_agent_missing_warns(healthy, monkeypatch): monkeypatch.setattr( - "aai_cli.doctor_checks.shutil.which", + "aai_cli.app.doctor_checks.shutil.which", lambda tool: None if tool in ("claude", "npx") else f"/usr/bin/{tool}", ) result = runner.invoke(app, ["doctor", "--json"]) @@ -188,7 +188,7 @@ def test_doctor_network_fix_names_active_env_host(healthy, monkeypatch): def boom(_key): raise APIError("Network error contacting AssemblyAI: timeout") - monkeypatch.setattr("aai_cli.doctor_checks.client.validate_key", boom) + monkeypatch.setattr("aai_cli.app.doctor_checks.client.validate_key", boom) result = runner.invoke(app, ["--env", "sandbox000", "doctor", "--json"]) fix = _checks(result)["api-key"]["fix"] assert "that api.sandbox000.assemblyai-labs.com is reachable" in fix diff --git a/tests/test_dub_command.py b/tests/test_dub_command.py index ecd579b4..ad8f3c87 100644 --- a/tests/test_dub_command.py +++ b/tests/test_dub_command.py @@ -10,8 +10,8 @@ import pytest from typer.testing import CliRunner -from aai_cli import llm from aai_cli.commands.dub import _exec as dub_exec +from aai_cli.core import llm from aai_cli.main import app from tests._clip_helpers import plain diff --git a/tests/test_dub_exec.py b/tests/test_dub_exec.py index d0977caa..2350c66e 100644 --- a/tests/test_dub_exec.py +++ b/tests/test_dub_exec.py @@ -14,11 +14,11 @@ import pytest -from aai_cli import mediafile +from aai_cli.app import mediafile +from aai_cli.app.context import AppState from aai_cli.commands.dub import _exec as dub_exec from aai_cli.commands.dub import _pipeline as dub_pipeline -from aai_cli.context import AppState -from aai_cli.errors import CLIError, UsageError +from aai_cli.core.errors import CLIError, UsageError from tests._dub_helpers import ( DEFAULTS, enable_sandbox, diff --git a/tests/test_dub_pipeline.py b/tests/test_dub_pipeline.py index 300f1b78..4e9ac57b 100644 --- a/tests/test_dub_pipeline.py +++ b/tests/test_dub_pipeline.py @@ -16,10 +16,11 @@ import pytest -from aai_cli import client, llm, mediafile +from aai_cli.app import mediafile +from aai_cli.app.context import AppState from aai_cli.commands.dub import _exec as dub_exec -from aai_cli.context import AppState -from aai_cli.errors import APIError, CLIError +from aai_cli.core import client, llm +from aai_cli.core.errors import APIError, CLIError from aai_cli.tts import session from aai_cli.tts.session import SpeakResult from tests._clip_helpers import plain diff --git a/tests/test_dub_sources.py b/tests/test_dub_sources.py index 49f07c44..e030a585 100644 --- a/tests/test_dub_sources.py +++ b/tests/test_dub_sources.py @@ -12,10 +12,10 @@ import pytest -from aai_cli import youtube +from aai_cli.app.context import AppState from aai_cli.commands.dub import _exec as dub_exec -from aai_cli.context import AppState -from aai_cli.errors import UsageError +from aai_cli.core import youtube +from aai_cli.core.errors import UsageError from tests._dub_helpers import ( DEFAULTS, enable_sandbox, diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 00000000..a10802ef --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,48 @@ +"""`aai_cli.core.env` — the single chokepoint for raw environment access.""" + +from __future__ import annotations + +import os + +from aai_cli.core import env + + +def test_get_returns_value_or_default(monkeypatch): + monkeypatch.setenv("AAI_TEST_VAR", "present") + monkeypatch.delenv("AAI_TEST_MISSING", raising=False) + assert env.get("AAI_TEST_VAR") == "present" + assert env.get("AAI_TEST_MISSING") is None + assert env.get("AAI_TEST_MISSING", "fallback") == "fallback" + + +def test_child_env_overlays_without_mutating(monkeypatch): + monkeypatch.setattr(os, "environ", {"KEEP": "1", "PORT": "old"}) + child = env.child_env(PORT="9999") + # Overrides win over the inherited value, and inherited keys survive. + assert child == {"KEEP": "1", "PORT": "9999"} + # The parent environment is untouched (it's a copy, not a mutation). + assert os.environ == {"KEEP": "1", "PORT": "old"} + + +def test_force_color_sets_force_clears_no_color(monkeypatch): + monkeypatch.setattr(os, "environ", {"NO_COLOR": "1"}) + env.force_color() + assert os.environ == {"FORCE_COLOR": "1"} + + +def test_force_color_is_safe_when_no_color_absent(monkeypatch): + monkeypatch.setattr(os, "environ", {}) + env.force_color() # pop must tolerate a missing NO_COLOR + assert os.environ == {"FORCE_COLOR": "1"} + + +def test_disable_color_sets_no_color_clears_force(monkeypatch): + monkeypatch.setattr(os, "environ", {"FORCE_COLOR": "1"}) + env.disable_color() + assert os.environ == {"NO_COLOR": "1"} + + +def test_disable_color_is_safe_when_force_absent(monkeypatch): + monkeypatch.setattr(os, "environ", {}) + env.disable_color() # pop must tolerate a missing FORCE_COLOR + assert os.environ == {"NO_COLOR": "1"} diff --git a/tests/test_environments.py b/tests/test_environments.py index e21b3da2..1115b85e 100644 --- a/tests/test_environments.py +++ b/tests/test_environments.py @@ -1,7 +1,7 @@ import pytest -from aai_cli import config, environments -from aai_cli.errors import CLIError +from aai_cli.core import config, environments +from aai_cli.core.errors import CLIError def test_get_returns_named_environment(): diff --git a/tests/test_errors.py b/tests/test_errors.py index f00a1be2..8f605277 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,6 +1,6 @@ import pytest -from aai_cli.errors import ( +from aai_cli.core.errors import ( APIError, CLIError, NotAuthenticated, @@ -89,7 +89,7 @@ def test_api_error_carries_suggestion(): def test_auth_failure_splits_message_and_suggestion(): - from aai_cli.errors import auth_failure + from aai_cli.core.errors import auth_failure err = auth_failure() assert err.error_type == "not_authenticated" diff --git a/tests/test_eval_command.py b/tests/test_eval_command.py index 1ee69e0f..e4b9044f 100644 --- a/tests/test_eval_command.py +++ b/tests/test_eval_command.py @@ -13,8 +13,8 @@ import pytest from typer.testing import CliRunner -from aai_cli import config from aai_cli.commands.evaluate import _data as eval_data +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_eval_data_hf.py b/tests/test_eval_data_hf.py index c8f4ba4c..47357d6b 100644 --- a/tests/test_eval_data_hf.py +++ b/tests/test_eval_data_hf.py @@ -12,7 +12,7 @@ from aai_cli.commands.evaluate import _data as eval_data from aai_cli.commands.evaluate import _hf_api as eval_hf_api -from aai_cli.errors import APIError, UsageError +from aai_cli.core.errors import APIError, UsageError # ------------------------------------------------------- Hugging Face datasets diff --git a/tests/test_eval_data_manifest.py b/tests/test_eval_data_manifest.py index 7d29525b..8c708c0a 100644 --- a/tests/test_eval_data_manifest.py +++ b/tests/test_eval_data_manifest.py @@ -10,7 +10,7 @@ import pytest from aai_cli.commands.evaluate import _data as eval_data -from aai_cli.errors import CLIError, UsageError +from aai_cli.core.errors import CLIError, UsageError # ---------------------------------------------------------------- local manifests diff --git a/tests/test_eval_failures.py b/tests/test_eval_failures.py index 61f54881..806f4448 100644 --- a/tests/test_eval_failures.py +++ b/tests/test_eval_failures.py @@ -14,7 +14,7 @@ from typer.testing import CliRunner from aai_cli.commands.evaluate import _exec as evaluate -from aai_cli.errors import APIError, auth_failure +from aai_cli.core.errors import APIError, auth_failure from aai_cli.main import app from tests.test_eval_command import ( _auth, diff --git a/tests/test_follow.py b/tests/test_follow.py index 99747014..6a2f7dd0 100644 --- a/tests/test_follow.py +++ b/tests/test_follow.py @@ -1,4 +1,4 @@ -from aai_cli import follow, output +from aai_cli.ui import follow, output def test_json_mode_emits_ndjson_per_refresh(monkeypatch): diff --git a/tests/test_help_rendering.py b/tests/test_help_rendering.py index ae2d6fa1..cfc100ad 100644 --- a/tests/test_help_rendering.py +++ b/tests/test_help_rendering.py @@ -150,7 +150,7 @@ def test_version_command_suggests_version_flag(): def test_misplaced_flag_hint_without_context_is_none(): from typer._click.exceptions import NoSuchOption - from aai_cli.typer_patches import _misplaced_flag_hint + from aai_cli.ui.typer_patches import _misplaced_flag_hint assert _misplaced_flag_hint(NoSuchOption("--json")) is None @@ -160,7 +160,7 @@ def test_click_error_without_context_falls_back_to_argv(monkeypatch, capsys): # formatter then sniffs the real process argv for the JSON opt-in. from typer._click.exceptions import ClickException - from aai_cli.typer_patches import _format_click_error_fixed + from aai_cli.ui.typer_patches import _format_click_error_fixed monkeypatch.setattr(sys, "argv", ["assembly", "--json"]) _format_click_error_fixed(ClickException("boom")) @@ -175,7 +175,7 @@ def test_click_error_without_context_falls_back_to_argv(monkeypatch, capsys): def test_noclip_table_pins_leading_columns_and_passes_row_args_through(): - from aai_cli.typer_patches import _NoClipTable + from aai_cli.ui.typer_patches import _NoClipTable table = _NoClipTable() table.add_row("--flag", "META", "help text") diff --git a/tests/test_help_text.py b/tests/test_help_text.py index 4e8f5249..875e704a 100644 --- a/tests/test_help_text.py +++ b/tests/test_help_text.py @@ -1,4 +1,4 @@ -from aai_cli.help_text import examples_epilog +from aai_cli.ui.help_text import examples_epilog def test_examples_epilog_has_header_and_entries(): diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 515929cf..51df9601 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -10,8 +10,8 @@ import pytest -from aai_cli.errors import CLIError -from aai_cli.hotkey import TerminalKeys, _stdin_fd +from aai_cli.core.errors import CLIError +from aai_cli.core.hotkey import TerminalKeys, _stdin_fd @pytest.fixture diff --git a/tests/test_importlinter_coverage.py b/tests/test_importlinter_coverage.py index a3f9d3dc..17d6e82a 100644 --- a/tests/test_importlinter_coverage.py +++ b/tests/test_importlinter_coverage.py @@ -1,14 +1,19 @@ -"""Guard: the import-linter contracts must cover every module in the package. +"""Guard: every top-level module is covered by an architecture contract. -Contract 1 ("core modules do not import command modules") enumerates its source -modules by name, so a newly added module would be silently *uncovered* — worse -than a merge conflict, because nothing fails until the architecture has already -drifted (this is exactly how `onboard` once grew imports of command modules -unnoticed). This test compares the enumerated list against the filesystem so a -new top-level module fails loudly until it is added to `.importlinter` (or to -the deliberate exemption list below). +The architecture is enforced by `.importlinter` as a layered stack +(``commands > app > ui > core``, contract 1) plus the vertical feature slices +that sit beside it (contract 2 forbids them from importing the command layer). +A newly added top-level module that belongs to neither would be silently +*uncovered* — nothing would fail until the architecture had already drifted +(this is exactly how `onboard` once grew imports of command modules unnoticed). -Contract 2 needs no guard: it wildcards over ``aai_cli.commands.*``. +This test partitions the filesystem against the contracts: every top-level +entry under ``aai_cli/`` must be either a declared layer (contract 1), a +declared feature slice (contract 2), or one of the framework-glue modules that +legitimately assemble the command layer from above. A stray module fails +loudly until it is placed. + +Contract 3 needs no guard: it wildcards over ``aai_cli.commands.*``. """ from __future__ import annotations @@ -18,10 +23,17 @@ import aai_cli -# Modules that legitimately import aai_cli.commands and so are deliberately -# outside contract 1: main registers the discovered command apps, -# command_registry performs that discovery, and commands is the layer itself. -EXEMPT = {"aai_cli.main", "aai_cli.command_registry", "aai_cli.commands"} +# The CLI framework glue lives at the package root, *above* the command layer: +# main builds the app, command_registry discovers/registers the command apps, +# help_panels/options are the shared command-definition support they pull in. +# They legitimately import aai_cli.commands, so they sit outside the layered +# stack and the feature-slice list. +EXEMPT = { + "aai_cli.main", + "aai_cli.command_registry", + "aai_cli.help_panels", + "aai_cli.options", +} _REPO_ROOT = Path(aai_cli.__file__).resolve().parent.parent @@ -39,19 +51,35 @@ def _top_level_modules() -> set[str]: return modules -def _contract_one_sources() -> set[str]: +def _parser() -> configparser.ConfigParser: parser = configparser.ConfigParser() parser.read(_REPO_ROOT / ".importlinter") - return set(parser["importlinter:contract:1"]["source_modules"].split()) + return parser + + +def _layer_modules(parser: configparser.ConfigParser) -> set[str]: + container = parser["importlinter:contract:1"]["containers"].split()[0] + layers = parser["importlinter:contract:1"]["layers"].split() + return {f"{container}.{layer}" for layer in layers} + +def _feature_slices(parser: configparser.ConfigParser) -> set[str]: + return set(parser["importlinter:contract:2"]["source_modules"].split()) -def test_every_core_module_is_covered_by_contract_one(): - listed = _contract_one_sources() + +def test_every_top_level_module_is_placed_by_a_contract(): + parser = _parser() + layers = _layer_modules(parser) + features = _feature_slices(parser) + covered = layers | features | EXEMPT actual = _top_level_modules() - missing = sorted(actual - listed - EXEMPT) + + missing = sorted(actual - covered) assert missing == [], ( - f"new top-level module(s) {missing} are not covered by .importlinter contract 1; " - "add them to source_modules (or to EXEMPT here if they may import commands)" + f"top-level module(s) {missing} are not placed by any .importlinter contract; " + "move them into a layer package (commands/app/ui/core), add them to contract 2's " + "feature slices, or to EXEMPT here if they are framework glue that may import commands" ) - stale = sorted(listed - actual) - assert stale == [], f".importlinter contract 1 lists module(s) that no longer exist: {stale}" + + stale = sorted((layers | features) - actual) + assert stale == [], f".importlinter names module(s) that no longer exist: {stale}" diff --git a/tests/test_init_command.py b/tests/test_init_command.py index 98f511fc..0f7a2914 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -7,9 +7,9 @@ import typer from typer.testing import CliRunner -from aai_cli import init_exec +from aai_cli.app import init_exec from aai_cli.commands import init as init_cmd -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_init_force_and_report.py b/tests/test_init_force_and_report.py index 77a69a63..b5bf25f5 100644 --- a/tests/test_init_force_and_report.py +++ b/tests/test_init_force_and_report.py @@ -137,7 +137,7 @@ def test_init_reports_key_written_from_environment(tmp_path, monkeypatch): def test_init_reports_key_written_from_keyring(tmp_path, monkeypatch): import json - from aai_cli import config + from aai_cli.core import config monkeypatch.chdir(tmp_path) config.set_api_key("default", "sk-stored") @@ -154,7 +154,7 @@ def test_init_blank_env_var_reports_keyring_source(tmp_path, monkeypatch): # that actually resolved from the keyring must not be attributed to the env. import json - from aai_cli import config + from aai_cli.core import config monkeypatch.chdir(tmp_path) monkeypatch.setenv("ASSEMBLYAI_API_KEY", " ") diff --git a/tests/test_init_keys.py b/tests/test_init_keys.py index a1a3b060..88099e77 100644 --- a/tests/test_init_keys.py +++ b/tests/test_init_keys.py @@ -1,4 +1,4 @@ -from aai_cli import config +from aai_cli.core import config from aai_cli.init import keys diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index 7b9cc8e9..49a31287 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -5,7 +5,7 @@ import pytest -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError from aai_cli.init import runner diff --git a/tests/test_init_scaffold.py b/tests/test_init_scaffold.py index 18e33730..0b86393b 100644 --- a/tests/test_init_scaffold.py +++ b/tests/test_init_scaffold.py @@ -2,7 +2,7 @@ import pytest -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError from aai_cli.init import scaffold diff --git a/tests/test_jsonshape.py b/tests/test_jsonshape.py index 6e69b163..36634c3f 100644 --- a/tests/test_jsonshape.py +++ b/tests/test_jsonshape.py @@ -1,6 +1,6 @@ import datetime -from aai_cli import jsonshape +from aai_cli.core import jsonshape def test_as_mapping_accepts_json_objects_only(): diff --git a/tests/test_keys.py b/tests/test_keys.py index ae19ad9e..5df1cfe4 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -2,9 +2,9 @@ from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult from aai_cli.commands import keys +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -84,7 +84,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.app.context._interactive_session", lambda: True) 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=[] @@ -209,7 +209,7 @@ def test_keys_create_with_explicit_project_skips_lookup(mocker): def test_keys_create_rejects_empty_name(monkeypatch, mocker): # Local validation fires before session resolution or any AMS call — even when # 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.app.context._interactive_session", lambda: True) monkeypatch.setattr( "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("login must not start")), diff --git a/tests/test_llm.py b/tests/test_llm.py index 32cbe04e..e86eab3c 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -6,8 +6,8 @@ import pytest from openai.types.chat import ChatCompletion -from aai_cli import environments, llm -from aai_cli.errors import APIError +from aai_cli.core import environments, llm +from aai_cli.core.errors import APIError _GATEWAY_BASE = environments.get(environments.DEFAULT_ENV).llm_gateway_base _REQUEST = httpx.Request("POST", f"{_GATEWAY_BASE}/chat/completions") @@ -42,7 +42,7 @@ def _fake_client(monkeypatch, *, result=None, error=None): def test_client_targets_active_gateway_base(): - from aai_cli import environments + from aai_cli.core import environments client = llm._client("sk_live") assert str(client.base_url).rstrip("/") == environments.active().llm_gateway_base.rstrip("/") diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 0b1805c0..8456c73a 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -4,9 +4,9 @@ from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult -from aai_cli.llm import KNOWN_MODELS +from aai_cli.core import config +from aai_cli.core.llm import KNOWN_MODELS from aai_cli.main import app runner = CliRunner() @@ -86,7 +86,7 @@ def test_llm_json_emits_json_even_for_interactive_human(monkeypatch): _auth() # Even at an interactive terminal, --json emits machine output (it's the single, # explicit opt-in; we never auto-switch on pipe/agent anymore). - monkeypatch.setattr("aai_cli.output._stdout_is_tty", lambda: True) + monkeypatch.setattr("aai_cli.ui.output._stdout_is_tty", lambda: True) monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("4")) result = runner.invoke(app, ["llm", "hi", "--json"]) assert result.exit_code == 0 @@ -239,7 +239,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.app.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): @@ -354,7 +354,7 @@ def test_llm_output_json_field_forces_json_without_flag(monkeypatch): # interactive terminal (where json_mode is otherwise off). Pins the # `output_field == "json"` half of the json_mode disjunction. _auth() - monkeypatch.setattr("aai_cli.output._stdout_is_tty", lambda: True) + monkeypatch.setattr("aai_cli.ui.output._stdout_is_tty", lambda: True) monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("hi42")) result = runner.invoke(app, ["llm", "hi", "-o", "json"]) assert result.exit_code == 0 diff --git a/tests/test_llm_config_overrides.py b/tests/test_llm_config_overrides.py index e303b786..26d664eb 100644 --- a/tests/test_llm_config_overrides.py +++ b/tests/test_llm_config_overrides.py @@ -3,8 +3,8 @@ import pytest from typer.testing import CliRunner -from aai_cli import config, llm -from aai_cli.errors import UsageError +from aai_cli.core import config, llm +from aai_cli.core.errors import UsageError from aai_cli.main import app from tests.test_llm import _fake_client, _response diff --git a/tests/test_login.py b/tests/test_login.py index e1b61f9a..4cdf6d84 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -2,8 +2,8 @@ from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -70,7 +70,7 @@ def test_whoami_human_render_shows_detail_rows(mocker): def test_whoami_unauthenticated_runs_login(monkeypatch, mocker): - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) 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 @@ -154,7 +154,7 @@ def test_logout_clears_session(): def test_login_oauth_flow_failure_exits_nonzero(monkeypatch): - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError def boom(**_kwargs): raise APIError("Login failed: the server returned an unexpected response.") @@ -167,7 +167,7 @@ def boom(**_kwargs): def test_login_timeout_is_auth_typed_with_exit_4(monkeypatch): # The loopback timeout surfaces as not_authenticated/exit 4, not api_error/1. - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated def timed_out(**_kwargs): raise NotAuthenticated("Login timed out waiting for the browser.") @@ -283,7 +283,7 @@ def test_unknown_env_exits_2(mocker): # callback can't see a per-command --json, and we never auto-switch to JSON on a # pipe/agent), so it's the "Error:" + "Suggestion:" pair on stderr, not a JSON blob — # regardless of whether stdout is a TTY. - is_agentic = mocker.patch("aai_cli.output.is_agentic", autospec=True) + is_agentic = mocker.patch("aai_cli.ui.output.is_agentic", autospec=True) for agentic in (True, False): is_agentic.return_value = agentic result = runner.invoke(app, ["--env", "bogus", "whoami"]) @@ -320,7 +320,7 @@ def test_env_override_prints_warning_to_stderr(mocker): def test_rejected_api_key_has_suggestion(monkeypatch): - from aai_cli import client + from aai_cli.core import client monkeypatch.setattr(client, "validate_key", lambda key: False) result = runner.invoke(app, ["login", "--api-key", "sk_bad", "--json"]) @@ -405,7 +405,7 @@ def test_login_failure_never_auto_logs_in_again(monkeypatch): # The login command owns sign-in (auto_login=False): a NotAuthenticated from its # own browser flow must surface as exit 4, not trigger run_command's auto-login # retry — even in an interactive session. - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated calls = {"n": 0} @@ -413,7 +413,7 @@ def timed_out(**_kwargs): calls["n"] += 1 raise NotAuthenticated("Login timed out waiting for the browser.") - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", timed_out) result = runner.invoke(app, ["login"]) assert result.exit_code == 4 @@ -423,9 +423,9 @@ def timed_out(**_kwargs): def test_logout_never_auto_logs_in(monkeypatch): # Signing out must never start a sign-in flow (auto_login=False), even if the # body surfaces a NotAuthenticated and the session is interactive. - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) monkeypatch.setattr( "aai_cli.auth.run_login_flow", lambda **_: (_ for _ in ()).throw(AssertionError("logout must never start a login")), diff --git a/tests/test_login_guards.py b/tests/test_login_guards.py index 1f1045b1..3ac687e4 100644 --- a/tests/test_login_guards.py +++ b/tests/test_login_guards.py @@ -7,8 +7,8 @@ from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -115,7 +115,7 @@ def test_whoami_network_failure_still_renders_table(mocker): # A network failure must not suppress the local identity table; the status is # "unreachable (network error)" — distinct from "key rejected" — and the exit # code is 1 (api_error), keeping 4 reserved for a key the server refused. - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError config.set_api_key("default", "sk_1234567890") config.set_session("default", session_jwt="j", session_token="t", account_id=77) @@ -133,7 +133,7 @@ def test_whoami_network_failure_still_renders_table(mocker): def test_whoami_network_failure_json_reachable_is_null(mocker): - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError config.set_api_key("default", "sk_1234567890") mocker.patch( diff --git a/tests/test_login_with_key.py b/tests/test_login_with_key.py index f4d08b2f..8421d5d1 100644 --- a/tests/test_login_with_key.py +++ b/tests/test_login_with_key.py @@ -6,9 +6,9 @@ import pytest from typer.testing import CliRunner -from aai_cli import config from aai_cli.commands import login as login_cmd -from aai_cli.errors import STDIN_KEY_RECIPE, UsageError +from aai_cli.core import config +from aai_cli.core.errors import STDIN_KEY_RECIPE, UsageError from aai_cli.main import app from tests._snapshot_surface import normalize diff --git a/tests/test_macos_audio_source.py b/tests/test_macos_audio_source.py index fd19a478..e6f6a06a 100644 --- a/tests/test_macos_audio_source.py +++ b/tests/test_macos_audio_source.py @@ -4,7 +4,7 @@ import pytest -from aai_cli.errors import APIError, CLIError +from aai_cli.core.errors import APIError, CLIError from aai_cli.streaming import macos from aai_cli.streaming.sources import CHUNK_BYTES diff --git a/tests/test_main_module.py b/tests/test_main_module.py index 2eaa9501..0199a713 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -4,7 +4,7 @@ import pytest import aai_cli.main as main_mod -from aai_cli import argscan +from aai_cli.core import argscan def test_command_line_requests_json_recognizes_every_form(): @@ -34,7 +34,7 @@ def boom(*a, **k): monkeypatch.setattr(main_mod, "app", boom) # Don't dup2 the real stdout fd during the test; just verify the exit contract. - monkeypatch.setattr("aai_cli.stdio.silence_stdout", lambda: None) + monkeypatch.setattr("aai_cli.core.stdio.silence_stdout", lambda: None) with pytest.raises(SystemExit) as exc: main_mod.run() assert exc.value.code == 0 @@ -83,7 +83,7 @@ def epipe_path(*a, **k): raise SystemExit(1) monkeypatch.setattr(main_mod, "app", epipe_path) - monkeypatch.setattr("aai_cli.stdio.silence_stdout", lambda: None) + monkeypatch.setattr("aai_cli.core.stdio.silence_stdout", lambda: None) with pytest.raises(SystemExit) as exc: main_mod.run() assert exc.value.code == 0 diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 6e554573..d215e187 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -4,9 +4,9 @@ import pytest -from aai_cli import microphone -from aai_cli.errors import CLIError -from aai_cli.microphone import ( +from aai_cli.core import microphone +from aai_cli.core.errors import CLIError +from aai_cli.core.microphone import ( _FALLBACK_RATE, MicrophoneSource, _default_mic_stream, @@ -38,7 +38,7 @@ def close(self): def test_audio_missing_error_has_reinstall_suggestion(): - from aai_cli.microphone import audio_missing_error + from aai_cli.core.microphone import audio_missing_error err = audio_missing_error() assert "sounddevice" in err.message diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index 227e893d..c9b70a48 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -66,7 +66,7 @@ class _FakeTranscript: monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") monkeypatch.setattr( - "aai_cli.transcribe_exec.run_transcription", lambda *a, **k: _FakeTranscript() + "aai_cli.app.transcribe.run.run_transcription", lambda *a, **k: _FakeTranscript() ) result = CliRunner().invoke(app, ["onboard", "--json"]) assert result.exit_code == 0, result.output @@ -80,7 +80,7 @@ def test_onboard_does_not_auto_login_on_auth_error(monkeypatch: pytest.MonkeyPat # auto_login=False: an unauthenticated wizard surfaces the auth error (exit 4) # rather than kicking off a browser login. A True mutant would instead try to # log in and never exit 4 here. - from aai_cli.errors import NotAuthenticated + from aai_cli.core.errors import NotAuthenticated def _raise(p: object, c: object) -> int: raise NotAuthenticated("nope") @@ -133,7 +133,7 @@ def test_onboard_non_interactive_flag_forces_noninteractive( monkeypatch: pytest.MonkeyPatch, ) -> None: # `--non-interactive` forces non-interactive mode even when no agent is detected. - monkeypatch.setattr("aai_cli.output.is_agentic", lambda: False) + monkeypatch.setattr("aai_cli.ui.output.is_agentic", lambda: False) captured = _spy_forced(monkeypatch) result = CliRunner().invoke(app, ["onboard", "--non-interactive"]) assert result.exit_code == 0, result.output @@ -145,7 +145,7 @@ def test_onboard_defaults_to_noninteractive_when_agent_detected( ) -> None: # No flag, but an agent is detected: the wizard still defaults to non-interactive. # A mutant dropping the `is_agentic()` term would leave `forced` False here. - monkeypatch.setattr("aai_cli.output.is_agentic", lambda: True) + monkeypatch.setattr("aai_cli.ui.output.is_agentic", lambda: True) captured = _spy_forced(monkeypatch) result = CliRunner().invoke(app, ["onboard"]) assert result.exit_code == 0, result.output @@ -158,7 +158,7 @@ def test_onboard_stays_interactive_without_flag_or_agent( # No flag, no agent: `forced` is False, so build_prompter is free to drive real # prompts. An `and` mutant on the `or` would also land here, but the two cases # above (each True via a different operand) pin the operator. - monkeypatch.setattr("aai_cli.output.is_agentic", lambda: False) + monkeypatch.setattr("aai_cli.ui.output.is_agentic", lambda: False) captured = _spy_forced(monkeypatch) result = CliRunner().invoke(app, ["onboard"]) assert result.exit_code == 0, result.output @@ -168,7 +168,7 @@ def test_onboard_stays_interactive_without_flag_or_agent( def test_onboard_json_forces_noninteractive(monkeypatch: pytest.MonkeyPatch) -> None: # --json forces non-interactive even with no agent detected: a machine-output run # can't block on prompts (and the interactive prompter writes prose to stdout). - monkeypatch.setattr("aai_cli.output.is_agentic", lambda: False) + monkeypatch.setattr("aai_cli.ui.output.is_agentic", lambda: False) captured = _spy_forced(monkeypatch) result = CliRunner().invoke(app, ["onboard", "--json"]) assert result.exit_code == 0, result.output @@ -181,7 +181,7 @@ def test_onboard_sorts_first_in_quick_start() -> None: def test_interactive_stdio_requires_both_ends_tty(monkeypatch: pytest.MonkeyPatch) -> None: - from aai_cli import stdio + from aai_cli.core import stdio # Both TTY -> interactive. monkeypatch.setattr("sys.stdin.isatty", lambda: True) @@ -226,7 +226,7 @@ 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.onboard.sections import WizardContext - monkeypatch.setattr("aai_cli.stdio.interactive_stdio", lambda: True) + monkeypatch.setattr("aai_cli.core.stdio.interactive_stdio", lambda: True) monkeypatch.setattr("aai_cli.main.typer.confirm", lambda *a, **k: True) captured: dict[str, object] = {} @@ -246,7 +246,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. - monkeypatch.setattr("aai_cli.stdio.interactive_stdio", lambda: True) + monkeypatch.setattr("aai_cli.core.stdio.interactive_stdio", lambda: True) ran = {"called": False} def _fake_run(prompter: object, ctx: object) -> int: @@ -264,7 +264,7 @@ def test_bare_aai_interactive_with_key_shows_help_no_offer( ) -> None: # 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("aai_cli.stdio.interactive_stdio", lambda: True) + monkeypatch.setattr("aai_cli.core.stdio.interactive_stdio", lambda: True) monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") called = {"confirm": False} monkeypatch.setattr( @@ -277,7 +277,7 @@ def test_bare_aai_interactive_with_key_shows_help_no_offer( def test_bare_aai_declined_offer_shows_help(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("aai_cli.stdio.interactive_stdio", lambda: True) + monkeypatch.setattr("aai_cli.core.stdio.interactive_stdio", lambda: True) monkeypatch.setattr("aai_cli.main.typer.confirm", lambda *a, **k: False) called = {"v": False} diff --git a/tests/test_onboard_environment.py b/tests/test_onboard_environment.py index a486f2f4..823bf363 100644 --- a/tests/test_onboard_environment.py +++ b/tests/test_onboard_environment.py @@ -7,7 +7,7 @@ import pytest -from aai_cli.context import AppState +from aai_cli.app.context import AppState from aai_cli.onboard import sections from aai_cli.onboard.prompter import NonInteractivePrompter from aai_cli.onboard.sections import SectionResult, WizardContext @@ -39,9 +39,9 @@ def _mk(name: str, status: str): } return lambda: check - monkeypatch.setattr("aai_cli.doctor_checks.check_python", _mk("python", python)) - monkeypatch.setattr("aai_cli.doctor_checks.check_ffmpeg", _mk("ffmpeg", ffmpeg)) - monkeypatch.setattr("aai_cli.doctor_checks.check_audio", _mk("audio", audio)) + monkeypatch.setattr("aai_cli.app.doctor_checks.check_python", _mk("python", python)) + monkeypatch.setattr("aai_cli.app.doctor_checks.check_ffmpeg", _mk("ffmpeg", ffmpeg)) + monkeypatch.setattr("aai_cli.app.doctor_checks.check_audio", _mk("audio", audio)) def test_environment_all_ok_says_everything_looks_good( diff --git a/tests/test_onboard_prompter.py b/tests/test_onboard_prompter.py index de5c91da..436ec5c8 100644 --- a/tests/test_onboard_prompter.py +++ b/tests/test_onboard_prompter.py @@ -2,7 +2,7 @@ import pytest -from aai_cli.errors import UsageError +from aai_cli.core.errors import UsageError from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter, WizardCancelled diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index 3fbd302f..13a4187c 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -7,14 +7,17 @@ import pytest import typer -from aai_cli import init_exec, output, transcribe_exec, transcribe_render -from aai_cli import setup_exec as setup_cmd -from aai_cli.context import AppState -from aai_cli.errors import CLIError +from aai_cli.app import init_exec +from aai_cli.app import setup_exec as setup_cmd +from aai_cli.app.context import AppState +from aai_cli.app.transcribe import render as transcribe_render +from aai_cli.app.transcribe import run as transcribe_exec +from aai_cli.core.errors import CLIError from aai_cli.onboard import sections from aai_cli.onboard.prompter import NonInteractivePrompter from aai_cli.onboard.sections import SectionResult, WizardContext -from aai_cli.steps import Step +from aai_cli.ui import output +from aai_cli.ui.steps import Step class _FakeTranscript: @@ -131,7 +134,7 @@ def _fake( def test_first_request_handles_failure(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") diff --git a/tests/test_onboard_wizard.py b/tests/test_onboard_wizard.py index ee360012..15bd7281 100644 --- a/tests/test_onboard_wizard.py +++ b/tests/test_onboard_wizard.py @@ -4,11 +4,11 @@ import pytest -from aai_cli import output -from aai_cli.context import AppState +from aai_cli.app.context import AppState from aai_cli.onboard import sections, wizard from aai_cli.onboard.prompter import NonInteractivePrompter, WizardCancelled from aai_cli.onboard.sections import SectionResult, WizardContext +from aai_cli.ui import output ALL_SECTIONS = ( "welcome", diff --git a/tests/test_output.py b/tests/test_output.py index 91e3fa4f..641045e8 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,8 +1,8 @@ import json from typing import cast -from aai_cli import output -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError +from aai_cli.ui import output def test_resolve_json_true_only_when_explicit(): @@ -113,7 +113,7 @@ def test_emit_error_no_suggestion_line_when_absent(capsys): def test_affordance_helpers_carry_their_symbol(): - from aai_cli import theme + from aai_cli.ui import theme assert theme.SYMBOL_SUCCESS in output.success("done") assert theme.SYMBOL_WARN in output.warn("careful") @@ -123,7 +123,7 @@ def test_affordance_helpers_carry_their_symbol(): def test_affordance_helpers_use_resolvable_styles(capsys): - from aai_cli import theme + from aai_cli.ui import theme # Rendering through the themed console proves the markup parses and the # aai.* style names resolve (a bad name would raise MissingStyle). @@ -150,7 +150,7 @@ def test_print_code_plain_when_piped(monkeypatch, capsys): def test_print_code_highlights_for_interactive_human(monkeypatch, capsys): - from aai_cli import theme + from aai_cli.ui import theme monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) monkeypatch.setattr( @@ -286,7 +286,7 @@ def test_status_is_noop_when_quiet(monkeypatch): def test_emit_warning_human_writes_yellow_line_to_stderr(capsys): - from aai_cli import theme + from aai_cli.ui import theme output.emit_warning("env mismatch", json_mode=False) captured = capsys.readouterr() diff --git a/tests/test_procfile.py b/tests/test_procfile.py index 30bd1b8b..cde26f71 100644 --- a/tests/test_procfile.py +++ b/tests/test_procfile.py @@ -2,7 +2,7 @@ import pytest -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError from aai_cli.init import procfile WEB = "web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}\n" diff --git a/tests/test_properties.py b/tests/test_properties.py index 2c9173d8..2bbbad38 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -8,8 +8,8 @@ from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st -from aai_cli import config_builder as cb from aai_cli.agent.render import AgentRenderer +from aai_cli.core import config_builder as cb from aai_cli.streaming import sources from aai_cli.streaming.render import StreamRenderer diff --git a/tests/test_remotefs.py b/tests/test_remotefs.py index fc8b0021..6eb015f1 100644 --- a/tests/test_remotefs.py +++ b/tests/test_remotefs.py @@ -1,4 +1,4 @@ -"""aai_cli.remotefs: fsspec-backed bucket/remote audio sources. +"""aai_cli.core.remotefs: fsspec-backed bucket/remote audio sources. Driven against fsspec's in-process memory filesystem (the shared ``memory_fs`` fixture) so the tests exercise real fsspec glob/find/download code paths while @@ -12,8 +12,8 @@ from fsspec.implementations.memory import MemoryFileSystem from fsspec.registry import known_implementations -from aai_cli import remotefs -from aai_cli.errors import CLIError +from aai_cli.core import remotefs +from aai_cli.core.errors import CLIError @pytest.mark.parametrize( diff --git a/tests/test_replay_e2e.py b/tests/test_replay_e2e.py index 06374530..8cb9b6f1 100644 --- a/tests/test_replay_e2e.py +++ b/tests/test_replay_e2e.py @@ -1,7 +1,7 @@ """End-to-end replay tests: drive real CLI commands against recorded API responses. Each test patches the command's network boundary (``client.* / llm.* / ams.*``) to -return an object rebuilt from a real, scrubbed fixture (see ``tests/replay_fixtures.py`` +return an object rebuilt from a real, scrubbed fixture (see ``tests/_replay_fixtures.py`` and ``scripts/record_fixtures.py``), then invokes the command through Typer and asserts on the rendered output. The transport stays offline — pytest-socket is untouched — but the command's own parsing, formatting, and rendering all run against a real payload. @@ -11,9 +11,9 @@ from typer.testing import CliRunner -from aai_cli import config +from aai_cli.core import config from aai_cli.main import app -from tests import replay_fixtures as rf +from tests import _replay_fixtures as rf runner = CliRunner() @@ -29,7 +29,7 @@ def _with_session(): def test_transcribe_sample_renders_real_transcript(mocker): _with_api_key() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=rf.transcript("transcribe_sample"), ) diff --git a/tests/test_sessions_command.py b/tests/test_sessions_command.py index 842369cb..ca235fae 100644 --- a/tests/test_sessions_command.py +++ b/tests/test_sessions_command.py @@ -3,9 +3,9 @@ import pytest from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult from aai_cli.commands import sessions +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -196,7 +196,7 @@ def test_sessions_get_renders_detail(mocker): def test_sessions_without_session_runs_login(monkeypatch, mocker): - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) 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_setup.py b/tests/test_setup.py index e48647c0..5f3ecd95 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -5,7 +5,13 @@ from typer.testing import CliRunner from aai_cli.main import app -from tests.setup_helpers import FakeRun, _all_tools_present, _cli_skill_path, _skill_path, _statuses +from tests._setup_helpers import ( + FakeRun, + _all_tools_present, + _cli_skill_path, + _skill_path, + _statuses, +) runner = CliRunner() @@ -18,7 +24,7 @@ def _isolate_home(tmp_path, monkeypatch): def test_proc_detail_prefers_stderr_then_falls_back_to_stdout(): - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup # stderr wins when present (pins `proc.stderr or proc.stdout`); stdout is the # fallback when stderr is empty. @@ -39,7 +45,7 @@ def test_remove_skill_failure_reports_failed(monkeypatch): # MCP absent (so only the skill step can fail) and `npx skills remove` runs but # leaves the skill in place -> remove must report it as failed, not removed. monkeypatch.setattr( - "aai_cli.setup_exec.subprocess.run", + "aai_cli.app.setup_exec.subprocess.run", FakeRun({("claude", "mcp", "get"): 1}, removes_skill=False), ) @@ -58,14 +64,14 @@ def test_remove_skill_skipped_when_npx_missing(monkeypatch): # The assemblyai skill is present but npx is gone -> we can't drive `skills # remove`, so report skipped (not failed). monkeypatch.setattr( - "aai_cli.setup_exec.shutil.which", + "aai_cli.app.setup_exec.shutil.which", lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", ) skill = _skill_path() skill.mkdir(parents=True) (skill / "SKILL.md").write_text("# AssemblyAI") monkeypatch.setattr( - "aai_cli.setup_exec.subprocess.run", + "aai_cli.app.setup_exec.subprocess.run", FakeRun({("claude", "mcp", "get"): 1}), ) @@ -84,7 +90,7 @@ def test_remove_unwinds_all(monkeypatch, tmp_path): d.mkdir(parents=True) (d / "SKILL.md").write_text("# x") fake = FakeRun({("claude", "mcp", "get"): 0}) # present -> removable - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "remove", "--json"]) assert result.exit_code == 0 @@ -101,7 +107,7 @@ def test_remove_unwinds_all(monkeypatch, tmp_path): def test_remove_when_absent_is_not_an_error(monkeypatch): _all_tools_present(monkeypatch) # no skill dirs fake = FakeRun({("claude", "mcp", "get"): 1}) # absent - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "remove", "--json"]) assert result.exit_code == 0 @@ -116,7 +122,7 @@ def test_remove_when_absent_is_not_an_error(monkeypatch): def test_remove_scope_passthrough(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 0}) # present - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "remove", "--scope", "project"]) assert result.exit_code == 0 @@ -125,18 +131,18 @@ def test_remove_scope_passthrough(monkeypatch): def test_remove_invalid_scope_exits_2(monkeypatch): _all_tools_present(monkeypatch) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", FakeRun()) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", FakeRun()) result = runner.invoke(app, ["setup", "remove", "--scope", "bogus"]) assert result.exit_code == 2 def test_remove_skips_mcp_when_claude_missing(monkeypatch): monkeypatch.setattr( - "aai_cli.setup_exec.shutil.which", + "aai_cli.app.setup_exec.shutil.which", lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", ) fake = FakeRun() - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "remove", "--json"]) assert result.exit_code == 0 @@ -148,7 +154,7 @@ def test_remove_mcp_failure_reports_failed(monkeypatch): _all_tools_present(monkeypatch) # present, but `mcp remove` fails -> the mcp step is failed and exit is non-zero. fake = FakeRun({("claude", "mcp", "get"): 0, ("claude", "mcp", "remove"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "remove", "--json"]) assert result.exit_code == 1 @@ -160,7 +166,7 @@ def test_remove_mcp_failure_reports_failed(monkeypatch): def test_copy_tree_skips_pycache_and_pyc(tmp_path): # _copy_tree must not copy compiled-Python detritus into the agent's skills dir. - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup src = tmp_path / "src" (src / "references").mkdir(parents=True) @@ -182,7 +188,7 @@ def test_copy_tree_skips_pycache_and_pyc(tmp_path): def test_copy_tree_creates_missing_parent_dirs(tmp_path): # The destination's parents may not exist yet (~/.claude/skills on a fresh # machine); _copy_tree must create the whole chain (mkdir parents=True). - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup src = tmp_path / "src" src.mkdir() @@ -196,7 +202,7 @@ def test_copy_tree_creates_missing_parent_dirs(tmp_path): def test_copy_tree_into_existing_dir_is_tolerated(tmp_path): # _copy_tree may run with the destination already present (a forced reinstall over # an existing skill dir); the mkdir must tolerate it (exist_ok=True), not raise. - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup src = tmp_path / "src" src.mkdir() @@ -243,7 +249,7 @@ def test_setup_no_subcommand_lists_commands(): def test_install_cli_skill_fails_when_bundle_missing(monkeypatch, tmp_path): - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup monkeypatch.setattr(setup, "_bundled_cli_skill", lambda: tmp_path / "nonexistent") step = setup.install_cli_skill(force=False) @@ -252,7 +258,7 @@ def test_install_cli_skill_fails_when_bundle_missing(monkeypatch, tmp_path): def test_install_cli_skill_fails_when_copy_lacks_skill_md(monkeypatch, tmp_path): - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup empty = tmp_path / "emptybundle" empty.mkdir() @@ -263,7 +269,7 @@ def test_install_cli_skill_fails_when_copy_lacks_skill_md(monkeypatch, tmp_path) def test_remove_cli_skill_fails_when_rmtree_noops(monkeypatch): - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup dest = _cli_skill_path() dest.mkdir(parents=True) @@ -278,7 +284,7 @@ def test_remove_cli_skill_tolerates_rmtree_error(monkeypatch): # Removal is best-effort (ignore_errors=True): a deletion failure must surface as a # clean "failed" step (skill still present), never an uncaught OSError. Without # ignore_errors, rmtree would raise instead of returning. - from aai_cli import setup_exec as setup + from aai_cli.app import setup_exec as setup dest = _cli_skill_path() dest.mkdir(parents=True) diff --git a/tests/test_setup_install.py b/tests/test_setup_install.py index 016571f1..46dc472d 100644 --- a/tests/test_setup_install.py +++ b/tests/test_setup_install.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner from aai_cli.main import app -from tests.setup_helpers import ( +from tests._setup_helpers import ( FakeRun, _all_tools_present, _cli_skill_path, @@ -30,7 +30,7 @@ def test_install_happy_path_runs_all_steps(monkeypatch): _all_tools_present(monkeypatch) # MCP not yet present -> `mcp get` returns non-zero. fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 0 @@ -67,7 +67,7 @@ def test_install_skill_failed_when_npx_succeeds_but_nothing_installed(monkeypatc # code — otherwise install says "installed" while status says "not_installed". _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 1}, creates_skill=False) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 1 # skill step failed @@ -94,7 +94,7 @@ def record(cmd, *args, **kwargs): seen.append((list(cmd), kwargs)) return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="") - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", record) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", record) result = runner.invoke(app, ["setup", "install"]) assert result.exit_code in (0, 1) assert seen, "expected subprocess.run to be called" @@ -113,7 +113,7 @@ def record(cmd, *args, **kwargs): def test_install_scope_passthrough(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--scope", "project"]) assert result.exit_code == 0 @@ -133,7 +133,7 @@ def test_install_scope_passthrough(monkeypatch): def test_install_scope_local_passthrough(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--scope", "local"]) assert result.exit_code == 0 @@ -152,7 +152,7 @@ def test_install_scope_local_passthrough(monkeypatch): def test_install_invalid_scope_exits_2(monkeypatch): _all_tools_present(monkeypatch) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", FakeRun()) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", FakeRun()) result = runner.invoke(app, ["setup", "install", "--scope", "bogus"]) assert result.exit_code == 2 @@ -161,7 +161,7 @@ def test_install_idempotent_when_mcp_present(monkeypatch): _all_tools_present(monkeypatch) # `mcp get` returns 0 -> already registered. fake = FakeRun({("claude", "mcp", "get"): 0}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 0 @@ -174,7 +174,7 @@ def test_install_failure_exits_nonzero(monkeypatch): _all_tools_present(monkeypatch) # mcp not present, but `mcp add` fails. fake = FakeRun({("claude", "mcp", "get"): 1, ("claude", "mcp", "add"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 1 @@ -185,7 +185,7 @@ def test_install_force_remove_failure_reports_failed(monkeypatch): _all_tools_present(monkeypatch) # present, but the forced remove fails fake = FakeRun({("claude", "mcp", "get"): 0, ("claude", "mcp", "remove"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--force", "--json"]) assert result.exit_code == 1 @@ -196,7 +196,7 @@ def test_install_force_remove_failure_reports_failed(monkeypatch): def test_install_force_removes_then_adds(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 0}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--force"]) assert result.exit_code == 0 @@ -206,11 +206,11 @@ def test_install_force_removes_then_adds(monkeypatch): def test_install_skips_mcp_when_claude_missing(monkeypatch): monkeypatch.setattr( - "aai_cli.setup_exec.shutil.which", + "aai_cli.app.setup_exec.shutil.which", lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", ) fake = FakeRun() - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 0 # skip is not a failure @@ -233,7 +233,7 @@ def test_install_skill_idempotent_when_present(monkeypatch): skill.mkdir(parents=True) (skill / "SKILL.md").write_text("# AssemblyAI") fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 0 @@ -249,7 +249,7 @@ def test_install_force_reinstalls_skill(monkeypatch): skill.mkdir(parents=True) (skill / "SKILL.md").write_text("# AssemblyAI") fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--force", "--json"]) assert result.exit_code == 0 @@ -267,11 +267,11 @@ def test_install_force_reinstalls_skill(monkeypatch): def test_install_skips_skill_when_npx_missing(monkeypatch): monkeypatch.setattr( - "aai_cli.setup_exec.shutil.which", + "aai_cli.app.setup_exec.shutil.which", lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", ) fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 0 @@ -292,7 +292,7 @@ def test_install_aai_cli_skill_idempotent_when_present(monkeypatch): cli_skill.mkdir(parents=True) (cli_skill / "SKILL.md").write_text("# old") fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--json"]) assert result.exit_code == 0 @@ -307,7 +307,7 @@ def test_install_aai_cli_skill_force_reinstalls(monkeypatch): cli_skill.mkdir(parents=True) (cli_skill / "SKILL.md").write_text("# old") fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", fake) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", fake) result = runner.invoke(app, ["setup", "install", "--force", "--json"]) assert result.exit_code == 0 @@ -328,7 +328,7 @@ def test_status_reports_all_installed(monkeypatch, tmp_path): (d / "SKILL.md").write_text("# x") # `mcp get` returns 0 -> present. monkeypatch.setattr( - "aai_cli.setup_exec.subprocess.run", + "aai_cli.app.setup_exec.subprocess.run", FakeRun({("claude", "mcp", "get"): 0}), ) @@ -344,7 +344,7 @@ def test_status_reports_all_installed(monkeypatch, tmp_path): def test_status_reports_not_installed(monkeypatch): _all_tools_present(monkeypatch) # no skill dirs created monkeypatch.setattr( - "aai_cli.setup_exec.subprocess.run", + "aai_cli.app.setup_exec.subprocess.run", FakeRun({("claude", "mcp", "get"): 1}), ) @@ -359,10 +359,10 @@ def test_status_reports_not_installed(monkeypatch): def test_status_mcp_unknown_when_claude_missing(monkeypatch): monkeypatch.setattr( - "aai_cli.setup_exec.shutil.which", + "aai_cli.app.setup_exec.shutil.which", lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", ) - monkeypatch.setattr("aai_cli.setup_exec.subprocess.run", FakeRun()) + monkeypatch.setattr("aai_cli.app.setup_exec.subprocess.run", FakeRun()) result = runner.invoke(app, ["setup", "status", "--json"]) assert result.exit_code == 0 diff --git a/tests/test_setup_render.py b/tests/test_setup_render.py index 2a2b1295..7ff2d3eb 100644 --- a/tests/test_setup_render.py +++ b/tests/test_setup_render.py @@ -1,8 +1,8 @@ import io -from aai_cli import theme -from aai_cli.setup_exec import render -from aai_cli.steps import Step +from aai_cli.app.setup_exec import render +from aai_cli.ui import theme +from aai_cli.ui.steps import Step def test_render_steps_colors_status() -> None: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 9a1420a9..683e2e4b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -24,7 +24,7 @@ def test_version_flag_prints_and_exits(): def test_quiet_suppresses_env_override_warning(monkeypatch): # --env contradicting the profile normally warns on stderr; --quiet silences it. - from aai_cli import config + from aai_cli.core import config config.set_api_key("default", "sk_live") config.set_profile_env("default", "production") @@ -41,7 +41,7 @@ def test_env_override_warning_is_structured_in_json_mode(monkeypatch, mocker): # pipeline gets a machine-readable hint instead of an unexplained auth failure. import json - from aai_cli import config + from aai_cli.core import config config.set_api_key("default", "sk_live") config.set_profile_env("default", "production") @@ -56,7 +56,7 @@ def test_env_override_warning_is_structured_in_json_mode(monkeypatch, mocker): def test_sandbox_alone_targets_sandbox(): - from aai_cli import environments + from aai_cli.core import environments result = runner.invoke(app, ["--sandbox"]) assert result.exit_code == 0 @@ -64,7 +64,7 @@ def test_sandbox_alone_targets_sandbox(): def test_sandbox_with_agreeing_env_is_fine(): - from aai_cli import environments + from aai_cli.core import environments result = runner.invoke(app, ["--sandbox", "--env", "sandbox000"]) assert result.exit_code == 0 @@ -90,7 +90,7 @@ def test_sandbox_conflict_warning_is_structured_in_json_mode(mocker): # a machine-readable hint instead of a decorated human line. import json - from aai_cli import config + from aai_cli.core import config config.set_api_key("default", "sk_live") mocker.patch( diff --git a/tests/test_snapshots_errors.py b/tests/test_snapshots_errors.py index 15df25e6..ef1012f0 100644 --- a/tests/test_snapshots_errors.py +++ b/tests/test_snapshots_errors.py @@ -11,8 +11,8 @@ import pytest -from aai_cli import output -from aai_cli.errors import APIError, CLIError, NotAuthenticated, UsageError, auth_failure +from aai_cli.core.errors import APIError, CLIError, NotAuthenticated, UsageError, auth_failure +from aai_cli.ui import output from tests._snapshot_surface import normalize pytestmark = pytest.mark.usefixtures("fixed_render_size") diff --git a/tests/test_source_validation.py b/tests/test_source_validation.py index 6742a6ab..569d1a9d 100644 --- a/tests/test_source_validation.py +++ b/tests/test_source_validation.py @@ -8,8 +8,8 @@ import pytest from typer.testing import CliRunner -from aai_cli import client, config -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import client, config +from aai_cli.core.errors import CLIError, UsageError from aai_cli.main import app runner = CliRunner() @@ -46,7 +46,7 @@ def test_resolve_audio_source_source_plus_sample_rejected_even_without_checks(): def test_transcribe_source_plus_sample_exits_2(mocker, tmp_path): # No key configured: the conflict must fail before credential resolution. - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) clip = tmp_path / "clip.mp3" clip.write_bytes(b"fake") result = runner.invoke(app, ["transcribe", str(clip), "--sample"]) @@ -69,7 +69,7 @@ def test_resolve_audio_source_rejects_directory(tmp_path): def test_transcribe_directory_source_fails_before_credentials(mocker, tmp_path): # No key configured: a directory is batch mode, and an empty one must read as # "no audio files", never trigger a login (or an upload attempt). - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", str(tmp_path)]) assert result.exit_code == 2 # Rich may wrap the long tmp path mid-token (even inside a word), so compare with @@ -145,7 +145,7 @@ def test_transcripts_get_rejects_path_traversal_id(): def test_transcribe_missing_file_fails_before_credentials(mocker): # No key is configured: the path check must fire first, so the user sees # "file not found" instead of a login prompt (or a keyring error). - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "missing.wav"]) assert result.exit_code == 2 assert "File not found: missing.wav" in result.output diff --git a/tests/test_speak.py b/tests/test_speak.py index 4392cf57..bb4d059c 100644 --- a/tests/test_speak.py +++ b/tests/test_speak.py @@ -6,7 +6,7 @@ import pytest from typer.testing import CliRunner -from aai_cli import config +from aai_cli.core import config from aai_cli.main import app from aai_cli.tts import session diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 4ce64de7..e1285f7f 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -1,6 +1,6 @@ import io -from aai_cli import stdio +from aai_cli.core import stdio class _Tty(io.StringIO): diff --git a/tests/test_steps.py b/tests/test_steps.py index 1b766dbd..d62f6a76 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -1,4 +1,4 @@ -from aai_cli import steps +from aai_cli.ui import steps def test_render_steps_includes_name_status_detail() -> None: diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 7cc02a01..e081a882 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -13,9 +13,9 @@ import pytest from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult -from aai_cli.errors import APIError +from aai_cli.core import config +from aai_cli.core.errors import APIError from aai_cli.main import app runner = CliRunner() @@ -131,7 +131,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.app.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) def fake_stream_audio( @@ -155,7 +155,7 @@ def fake(api_key, source, *, params, on_begin=None, on_turn=None, on_termination def test_stream_sample_uses_hosted_clip(monkeypatch): - from aai_cli import client + from aai_cli.core import client config.set_api_key("default", "sk_live") monkeypatch.setattr("aai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg") @@ -311,7 +311,7 @@ def test_stream_downloadable_url_resolves_credentials_before_downloading(monkeyp # authentication *before* yt-dlp runs, so a signed-out user never downloads a # whole video only to be told to log in (mirrors transcribe's source -> auth -> # work ordering). - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: False) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: False) downloads = [] monkeypatch.setattr( "aai_cli.commands.stream._exec.youtube.download_media", diff --git a/tests/test_stream_command_flags.py b/tests/test_stream_command_flags.py index af087cdb..75e029a8 100644 --- a/tests/test_stream_command_flags.py +++ b/tests/test_stream_command_flags.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from aai_cli import config +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_stream_exec.py b/tests/test_stream_exec.py index 1e1d9b2a..4c557260 100644 --- a/tests/test_stream_exec.py +++ b/tests/test_stream_exec.py @@ -12,11 +12,11 @@ import pytest -from aai_cli import config, llm +from aai_cli.app.context import AppState from aai_cli.commands.stream import DEFAULT_SPEECH_MODEL from aai_cli.commands.stream import _exec as stream_exec -from aai_cli.context import AppState -from aai_cli.errors import UsageError +from aai_cli.core import config, llm +from aai_cli.core.errors import UsageError # The CLI's flag defaults, as data. Tests override per-case with dataclasses.replace. DEFAULTS = stream_exec.StreamOptions( diff --git a/tests/test_stream_llm.py b/tests/test_stream_llm.py index 7e935d57..bd16d794 100644 --- a/tests/test_stream_llm.py +++ b/tests/test_stream_llm.py @@ -3,7 +3,7 @@ from typer.testing import CliRunner -from aai_cli import config +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -28,7 +28,7 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): return f"answer:{transcript_text}" monkeypatch.setattr("aai_cli.commands.stream._exec.client.stream_audio", fake) - monkeypatch.setattr("aai_cli.llm.run_chain", fake_run_chain) + monkeypatch.setattr("aai_cli.core.llm.run_chain", fake_run_chain) result = runner.invoke( app, [ @@ -68,7 +68,7 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): return "done" monkeypatch.setattr("aai_cli.commands.stream._exec.client.stream_audio", fake) - monkeypatch.setattr("aai_cli.llm.run_chain", fake_run_chain) + monkeypatch.setattr("aai_cli.core.llm.run_chain", fake_run_chain) result = runner.invoke( app, ["stream", "--llm", "summarize", "--llm", "translate to french", "--json"] ) @@ -101,7 +101,7 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): return "x" monkeypatch.setattr("aai_cli.commands.stream._exec.client.stream_audio", fake) - monkeypatch.setattr("aai_cli.llm.run_chain", fake_run_chain) + monkeypatch.setattr("aai_cli.core.llm.run_chain", fake_run_chain) result = runner.invoke(app, ["stream", "--json"]) assert result.exit_code == 0 assert called["ran"] is False # no --llm -> no gateway call @@ -127,13 +127,13 @@ def _eot_turn(text): def _llm_session(*, interval, clock, monkeypatch, emitted): import io - from aai_cli.follow import FollowRenderer from aai_cli.streaming.render import StreamRenderer from aai_cli.streaming.session import StreamSession + from aai_cli.ui.follow import FollowRenderer # Capture each follow refresh (json mode emits one NDJSON object per refresh) and # make run_chain echo the transcript it summarized so assertions read the cadence. - monkeypatch.setattr("aai_cli.follow.output.emit_ndjson", lambda obj: emitted.append(obj)) + monkeypatch.setattr("aai_cli.ui.follow.output.emit_ndjson", lambda obj: emitted.append(obj)) monkeypatch.setattr( "aai_cli.streaming.session.llm.run_chain", lambda api_key, prompts, *, transcript_text, model, max_tokens: transcript_text, @@ -243,7 +243,7 @@ def test_stream_llm_chain_error_is_latched_on_reader_thread(monkeypatch, capsys) # warning), stop hammering the failing gateway, and keep the panel alive. import pytest - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError emitted: list[dict] = [] session = _llm_session( @@ -272,7 +272,7 @@ def test_stream_llm_chain_error_on_final_flush_raises(monkeypatch): # it propagates immediately instead of being deferred. import pytest - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError emitted: list[dict] = [] session = _llm_session( diff --git a/tests/test_stream_session.py b/tests/test_stream_session.py index 0de29751..f25dc180 100644 --- a/tests/test_stream_session.py +++ b/tests/test_stream_session.py @@ -4,8 +4,8 @@ from typer.testing import CliRunner -from aai_cli import config -from aai_cli.errors import APIError +from aai_cli.core import config +from aai_cli.core.errors import APIError from aai_cli.main import app runner = CliRunner() @@ -53,7 +53,7 @@ def test_stream_session_closes_renderer_on_error(monkeypatch): import pytest - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError from aai_cli.streaming.render import StreamRenderer from aai_cli.streaming.session import StreamSession @@ -238,7 +238,7 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): monkeypatch.setattr("aai_cli.commands.stream._exec.MacSystemAudioSource", FakeSystemAudio) monkeypatch.setattr("aai_cli.commands.stream._exec.MicrophoneSource", FakeMic) monkeypatch.setattr("aai_cli.commands.stream._exec.client.stream_audio", fake_stream_audio) - monkeypatch.setattr("aai_cli.llm.run_chain", fake_run_chain) + monkeypatch.setattr("aai_cli.core.llm.run_chain", fake_run_chain) result = runner.invoke(app, ["stream", "--system-audio", "--llm", "summarize", "--json"]) assert result.exit_code == 0 assert any("System: FakeSystemAudio" in value for value in transcript_inputs) diff --git a/tests/test_streaming_diagnostics.py b/tests/test_streaming_diagnostics.py index 5eeb2578..17c5cd78 100644 --- a/tests/test_streaming_diagnostics.py +++ b/tests/test_streaming_diagnostics.py @@ -9,15 +9,15 @@ import pytest -from aai_cli import debuglog -from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.core import debuglog +from aai_cli.core.errors import APIError, NotAuthenticated +from aai_cli.core.ws import WEBSOCKETS_LOGGERS from aai_cli.streaming.diagnostics import ( SDK_STREAMING_LOGGER, handshake_error, handshake_suggestion, silence_streaming_logging, ) -from aai_cli.ws import WEBSOCKETS_LOGGERS # The logger the assemblyai SDK's sync streaming client actually emits through. _SDK_CLIENT_LOGGER = "assemblyai.streaming.v3.client" diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index 73a7b587..5ca462b3 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -5,8 +5,8 @@ import pytest -from aai_cli import theme from aai_cli.streaming.render import StreamRenderer +from aai_cli.ui import theme def _turn(transcript, end_of_turn, speaker_label=None): @@ -66,8 +66,8 @@ def test_human_turn_labels_system_speaker_with_source(): def test_speaker_prefix_tints_each_speaker_distinctly(): - from aai_cli import theme from aai_cli.streaming.render import speaker_prefix + from aai_cli.ui import theme # A speaker label is colored by theme.speaker_style, so different speakers in the # same source render in different colors; an unlabeled sourced turn keeps the @@ -281,7 +281,7 @@ def test_human_live_region_construction_and_refresh(monkeypatch): # The live region must be built non-transient, never auto-refresh (we drive it), # and never redirect the process streams the JSON/threaded paths also write to. # Pin those kwargs and the forced per-update refresh with a fake Live. - import aai_cli.render as render_mod + import aai_cli.ui.render as render_mod fake = _FakeLive() diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index 76cfeaaf..b6938ec0 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -5,7 +5,7 @@ import pytest -from aai_cli.errors import CLIError +from aai_cli.core.errors import CLIError from aai_cli.streaming import sources from aai_cli.streaming.sources import FileSource @@ -180,7 +180,7 @@ def wait(self): pass monkeypatch.setattr(sources.subprocess, "Popen", lambda *a, **k: FailProc()) - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError with pytest.raises(APIError): list(sources.FileSource(str(p), sleep=lambda _s: None)) @@ -217,7 +217,7 @@ def wait(self): def test_filesource_ffmpeg_failure_empty_stderr_reports_exit_code(tmp_path, monkeypatch): # When ffmpeg fails but writes nothing to stderr, the error message falls back to # the exit code. Pins the `detail or f'exit {returncode}'` (an `and` would blank it). - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError p = tmp_path / "bad.mp3" p.write_bytes(b"x") diff --git a/tests/test_sync_stt.py b/tests/test_sync_stt.py index 2688c0ac..2364841e 100644 --- a/tests/test_sync_stt.py +++ b/tests/test_sync_stt.py @@ -5,8 +5,8 @@ import httpx2 as httpx import pytest -from aai_cli import environments, sync_stt -from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.core import environments, sync_stt +from aai_cli.core.errors import APIError, NotAuthenticated def _patch_transport(monkeypatch, handler): diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index df84191b..9b3fc167 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -9,8 +9,8 @@ import pytest import typer -from aai_cli import config, telemetry -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import config, telemetry +from aai_cli.core.errors import CLIError, UsageError # --- token / url resolution ------------------------------------------------- diff --git a/tests/test_telemetry_command.py b/tests/test_telemetry_command.py index 297d2500..aad947d3 100644 --- a/tests/test_telemetry_command.py +++ b/tests/test_telemetry_command.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from aai_cli import config, telemetry +from aai_cli.core import config, telemetry from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_theme.py b/tests/test_theme.py index a3819262..530254e9 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,6 +1,6 @@ import io -from aai_cli import theme +from aai_cli.ui import theme def test_make_console_resolves_named_styles(): @@ -56,8 +56,8 @@ def test_you_color_reserved_outside_speaker_palette(): def test_output_console_is_themed_and_error_is_styled(monkeypatch): - from aai_cli import output, theme - from aai_cli.errors import CLIError + from aai_cli.core.errors import CLIError + from aai_cli.ui import output, theme buf = io.StringIO() monkeypatch.setattr( @@ -79,7 +79,7 @@ def test_pipe_safe_console_reraises_broken_pipe(): import pytest - from aai_cli import theme + from aai_cli.ui import theme class BrokenFile(io.StringIO): def write(self, s): diff --git a/tests/test_timeparse.py b/tests/test_timeparse.py index 589fcf19..47b34a4f 100644 --- a/tests/test_timeparse.py +++ b/tests/test_timeparse.py @@ -1,6 +1,6 @@ from datetime import UTC -from aai_cli import timeparse +from aai_cli.core import timeparse def test_parse_iso_utc_normalizes_z_and_offsets(): diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 8302d240..6cecf25c 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -9,8 +9,8 @@ import pytest from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -57,7 +57,7 @@ def _fake_transcript(mocker): def test_transcribe_sample_prints_text(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -71,7 +71,7 @@ def test_transcribe_sample_prints_text(mocker): def test_transcribe_json_output(mocker): _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -80,10 +80,10 @@ 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.app.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -97,7 +97,7 @@ def test_transcribe_unauthenticated_runs_login_then_transcribes(monkeypatch, moc def test_transcribe_output_text_field(mocker): _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -109,7 +109,7 @@ def test_transcribe_output_text_field(mocker): def test_transcribe_output_id_field(mocker): _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -122,7 +122,7 @@ def test_transcribe_output_srt_field(mocker): _auth() t = _fake_transcript(mocker) t.export_subtitles_srt.return_value = "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" - mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True, return_value=t) + mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=t) result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "srt"]) assert result.exit_code == 0 assert "00:00:00,000 --> 00:00:02,000" in result.output # SRT body, pipe-friendly @@ -133,7 +133,7 @@ def test_transcribe_output_vtt_field(mocker): _auth() t = _fake_transcript(mocker) t.export_subtitles_vtt.return_value = "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nhello world\n" - mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True, return_value=t) + mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=t) result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "vtt"]) assert result.exit_code == 0 assert "WEBVTT" in result.output # VTT body, pipe-friendly @@ -144,7 +144,7 @@ def test_transcribe_chars_per_caption_forwarded_to_export(mocker): _auth() t = _fake_transcript(mocker) t.export_subtitles_srt.return_value = "1\n00:00:00,000 --> 00:00:02,000\nhello\nworld\n" - mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True, return_value=t) + mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=t) result = runner.invoke( app, ["transcribe", "audio.mp3", "-o", "srt", "--chars-per-caption", "42"] ) @@ -157,7 +157,7 @@ def test_transcribe_chars_per_caption_forwarded_through_out_file(tmp_path, mocke _auth() t = _fake_transcript(mocker) t.export_subtitles_vtt.return_value = "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nhello\n" - mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True, return_value=t) + mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=t) out = tmp_path / "captions.vtt" result = runner.invoke( app, @@ -170,7 +170,7 @@ def test_transcribe_chars_per_caption_forwarded_through_out_file(tmp_path, mocke def test_transcribe_chars_per_caption_requires_subtitle_output(mocker): _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "audio.mp3", "--chars-per-caption", "42"]) assert result.exit_code == 2 assert "--chars-per-caption only applies to subtitle output" in result.output @@ -180,7 +180,7 @@ def test_transcribe_chars_per_caption_requires_subtitle_output(mocker): def test_transcribe_output_invalid_exits_2(mocker): _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -199,7 +199,7 @@ def fake_transcribe(api_key, audio, *, config): seen["bytes"] = pathlib.Path(audio).read_bytes() return _fake_transcript(mocker) - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", fake_transcribe) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", fake_transcribe) result = runner.invoke(app, ["transcribe", "-", "-o", "text"], input=b"RIFFfake-wav-bytes") assert result.exit_code == 0 assert result.output.strip() == "hello world" @@ -213,7 +213,7 @@ def test_transcribe_status_renders_enum_value(mocker): t = _fake_transcript(mocker) t.status = aai.TranscriptStatus.completed t.json_response = None - mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True, return_value=t) + mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=t) result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) assert result.exit_code == 0 assert '"status": "completed"' in result.output @@ -230,7 +230,7 @@ def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens, transcr return "a short summary" mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -259,7 +259,7 @@ def fake_transform( return f"out({prompt})" mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -290,7 +290,7 @@ def fake_transform( def test_transcribe_prompt_human_shows_only_transform(monkeypatch, mocker): _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -309,7 +309,7 @@ def test_transcribe_chained_prompts_human_labels_each_step(monkeypatch, mocker): # prints only the lone output; this one enumerates "Step N"). _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -332,11 +332,11 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, mocker, fake = tmp_path / "vid.m4a" fake.write_bytes(b"x") monkeypatch.setattr( - "aai_cli.transcribe_exec.youtube.download_media", + "aai_cli.app.transcribe.run.youtube.download_media", lambda url, d, *, download_sections=None: fake, ) tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -356,9 +356,9 @@ def _capture(url, d, *, download_sections=None): seen["sections"] = download_sections return fake - monkeypatch.setattr("aai_cli.transcribe_exec.youtube.download_media", _capture) + monkeypatch.setattr("aai_cli.app.transcribe.run.youtube.download_media", _capture) mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -377,11 +377,11 @@ def test_transcribe_podcast_page_url_downloads_then_transcribes(monkeypatch, moc fake = tmp_path / "episode.m4a" fake.write_bytes(b"x") monkeypatch.setattr( - "aai_cli.transcribe_exec.youtube.download_media", + "aai_cli.app.transcribe.run.youtube.download_media", lambda url, d, *, download_sections=None: fake, ) tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -405,7 +405,7 @@ def fake(api_key, audio, *, config): seen["bytes"] = Path(audio).read_bytes() return _fake_transcript(mocker) - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", fake) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", fake) result = runner.invoke(app, ["transcribe", "memory://bucket/call.mp3", "-o", "text"]) assert result.exit_code == 0 assert result.output.strip() == "hello world" @@ -416,7 +416,7 @@ def fake(api_key, audio, *, config): def test_transcribe_missing_remote_file_fails_cleanly(mocker, memory_fs): _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "memory://bucket/nope.mp3"]) assert result.exit_code == 2 assert "Remote file not found" in result.output @@ -430,9 +430,9 @@ def test_transcribe_direct_audio_url_passes_through_without_download(monkeypatch def _no_download(url, d, *, download_sections=None): raise AssertionError("direct audio URLs must not be downloaded") - monkeypatch.setattr("aai_cli.transcribe_exec.youtube.download_media", _no_download) + monkeypatch.setattr("aai_cli.app.transcribe.run.youtube.download_media", _no_download) tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -446,7 +446,7 @@ def test_transcribe_renders_summary_human(mocker): t = _fake_transcript(mocker) t.summary = "three bullet summary" t.chapters = [] - mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True, return_value=t) + mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=t) result = runner.invoke(app, ["transcribe", "audio.mp3", "--summarization"]) assert result.exit_code == 0 assert "Summary:" in result.output diff --git a/tests/test_transcribe_batch.py b/tests/test_transcribe_batch.py index ff7ea663..afa607a6 100644 --- a/tests/test_transcribe_batch.py +++ b/tests/test_transcribe_batch.py @@ -11,13 +11,14 @@ import pytest from typer.testing import CliRunner -from aai_cli import config, transcribe_batch -from aai_cli.errors import auth_failure +from aai_cli.app.transcribe import batch as transcribe_batch +from aai_cli.core import config +from aai_cli.core.errors import auth_failure from aai_cli.main import app runner = CliRunner() -_TRANSCRIBE = "aai_cli.transcribe_exec.client.transcribe" +_TRANSCRIBE = "aai_cli.app.transcribe.run.client.transcribe" @pytest.fixture(autouse=True) @@ -202,7 +203,7 @@ def test_url_sidecar_slug_truncates_to_64_chars(): def test_partial_failure_exits_1_and_completes_the_rest(tmp_path, mocker, monkeypatch): - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError _auth() (tmp_path / "a.mp3").write_bytes(b"a") @@ -238,7 +239,7 @@ def fake(api_key, audio, *, config): raise auth_failure() monkeypatch.setattr(_TRANSCRIBE, fake) - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: False) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: False) result = runner.invoke(app, ["transcribe", "*.mp3"]) assert result.exit_code == 4 assert "rejected" in result.output @@ -264,7 +265,7 @@ def fake(api_key, audio, *, config): raise auth_failure() monkeypatch.setattr(_TRANSCRIBE, fake) - monkeypatch.setattr("aai_cli.context._interactive_session", lambda: False) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: False) result = runner.invoke(app, ["transcribe", "*.mp3"]) assert result.exit_code == 4 assert seen["cancel_futures"] is True diff --git a/tests/test_transcribe_batch_llm.py b/tests/test_transcribe_batch_llm.py index aa7f5584..cea853b6 100644 --- a/tests/test_transcribe_batch_llm.py +++ b/tests/test_transcribe_batch_llm.py @@ -9,12 +9,13 @@ import pytest from typer.testing import CliRunner -from aai_cli import config, transcribe_batch +from aai_cli.app.transcribe import batch as transcribe_batch +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() -_TRANSCRIBE = "aai_cli.transcribe_exec.client.transcribe" +_TRANSCRIBE = "aai_cli.app.transcribe.run.client.transcribe" @pytest.fixture(autouse=True) @@ -55,7 +56,7 @@ def _ndjson(result): # --- the --llm chain in batch mode ------------------------------------------------ -_TRANSFORM = "aai_cli.llm.transform_transcript" +_TRANSFORM = "aai_cli.core.llm.transform_transcript" def _patch_transform(monkeypatch): @@ -135,7 +136,7 @@ def test_batch_llm_stores_chain_steps_in_each_sidecar(tmp_path, mocker, monkeypa def test_failed_llm_chain_leaves_resumable_transcription(tmp_path, mocker, monkeypatch): - from aai_cli.errors import APIError + from aai_cli.core.errors import APIError _auth() (tmp_path / "a.mp3").write_bytes(b"aaa") diff --git a/tests/test_transcribe_batch_sources.py b/tests/test_transcribe_batch_sources.py index a1668dd8..2d247285 100644 --- a/tests/test_transcribe_batch_sources.py +++ b/tests/test_transcribe_batch_sources.py @@ -14,13 +14,15 @@ import pytest from typer.testing import CliRunner -from aai_cli import config, transcribe_batch, transcribe_sources -from aai_cli.errors import UsageError +from aai_cli.app.transcribe import batch as transcribe_batch +from aai_cli.app.transcribe import sources as transcribe_sources +from aai_cli.core import config +from aai_cli.core.errors import UsageError from aai_cli.main import app runner = CliRunner() -_TRANSCRIBE = "aai_cli.transcribe_exec.client.transcribe" +_TRANSCRIBE = "aai_cli.app.transcribe.run.client.transcribe" @pytest.fixture(autouse=True) diff --git a/tests/test_transcribe_flags.py b/tests/test_transcribe_flags.py index 4fec6677..2c33f5ca 100644 --- a/tests/test_transcribe_flags.py +++ b/tests/test_transcribe_flags.py @@ -7,7 +7,7 @@ import pytest from typer.testing import CliRunner -from aai_cli import config +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -52,7 +52,7 @@ def _enum_or_str(value): def test_transcribe_passes_speaker_labels(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -63,7 +63,7 @@ def test_transcribe_passes_speaker_labels(mocker): def test_transcribe_prompt_biases_speech_model(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -76,7 +76,7 @@ def test_transcribe_prompt_biases_speech_model(mocker): def test_transcribe_maps_analysis_flags(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -102,7 +102,7 @@ def test_transcribe_maps_analysis_flags(mocker): def test_transcribe_redact_pii_policy_csv(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -127,7 +127,7 @@ def test_transcribe_redact_pii_policy_csv(mocker): def test_transcribe_config_escape_hatch(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -138,7 +138,7 @@ def test_transcribe_config_escape_hatch(mocker): def test_transcribe_unknown_config_field_exits_2(mocker): _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -150,7 +150,7 @@ def test_transcribe_unknown_config_field_exits_2(mocker): def test_transcribe_webhook_auth_header(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -173,7 +173,7 @@ def test_transcribe_webhook_auth_header(mocker): def test_transcribe_negative_audio_start_exits_2(mocker): _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "audio.mp3", "--audio-start", "-100"]) assert result.exit_code == 2 tx.assert_not_called() @@ -181,7 +181,7 @@ def test_transcribe_negative_audio_start_exits_2(mocker): def test_transcribe_language_code_with_detection_exits_2(mocker): _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke( app, ["transcribe", "audio.mp3", "--language-code", "en_us", "--language-detection"], @@ -196,7 +196,7 @@ def test_transcribe_language_flags_alone_are_accepted(mocker): # Only the combination is contradictory; each flag works on its own. _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -210,7 +210,7 @@ def test_transcribe_language_flags_alone_are_accepted(mocker): def test_transcribe_speakers_expected_without_labels_exits_2(mocker): _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "audio.mp3", "--speakers-expected", "2"]) assert result.exit_code == 2 assert "--speakers-expected only applies when diarization is enabled." in result.output @@ -221,7 +221,7 @@ def test_transcribe_speakers_expected_without_labels_exits_2(mocker): def test_transcribe_speakers_expected_with_labels_is_accepted(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -237,7 +237,7 @@ def test_transcribe_speakers_expected_with_config_speaker_labels_is_accepted(moc # runs on the merged config, not just the curated flag. _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -254,7 +254,7 @@ def test_transcribe_temperature_out_of_range_exits_2(mocker, value): # The API documents temperature as 0 (most deterministic) to 1 (least); reject # out-of-range values client-side instead of letting them flow to the request. _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "audio.mp3", "--temperature", value]) assert result.exit_code == 2 tx.assert_not_called() @@ -264,7 +264,7 @@ def test_transcribe_temperature_out_of_range_exits_2(mocker, value): def test_transcribe_temperature_bounds_are_inclusive(mocker, value): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -275,7 +275,7 @@ def test_transcribe_temperature_bounds_are_inclusive(mocker, value): def test_transcribe_negative_audio_end_exits_2(mocker): _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "audio.mp3", "--audio-end", "-100"]) assert result.exit_code == 2 tx.assert_not_called() @@ -284,7 +284,7 @@ def test_transcribe_negative_audio_end_exits_2(mocker): def test_transcribe_audio_end_zero_is_accepted(mocker): _auth() tx = mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -297,7 +297,7 @@ def test_transcribe_json_with_non_json_output_field_exits_2(mocker): # --json means "the full JSON payload" (same as -o json); -o text contradicts it # and must not silently win. _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "text", "--json"]) assert result.exit_code == 2 assert "--json and -o text can't be combined." in result.output @@ -310,7 +310,7 @@ def test_transcribe_json_with_o_json_is_accepted(mocker): _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -323,7 +323,7 @@ def test_transcribe_warns_on_non_audio_extension(mocker, tmp_path): _auth() (tmp_path / "notes.txt").write_bytes(b"fake") mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -337,7 +337,7 @@ def test_transcribe_non_audio_warning_suppressed_by_quiet(mocker, tmp_path): _auth() (tmp_path / "notes.txt").write_bytes(b"fake") mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -352,7 +352,7 @@ def test_transcribe_non_audio_warning_is_structured_under_json(mocker, tmp_path) _auth() (tmp_path / "notes.txt").write_bytes(b"fake") mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -370,7 +370,7 @@ def test_transcribe_no_warning_for_audio_or_extensionless_files(mocker, tmp_path _auth() (tmp_path / name).write_bytes(b"fake") mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -384,7 +384,7 @@ def test_transcribe_no_warning_for_urls_or_sample(mocker, argv): # Remote sources aren't local files; the extension heuristic doesn't apply. _auth() mocker.patch( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", autospec=True, return_value=_fake_transcript(mocker), ) @@ -395,7 +395,7 @@ def test_transcribe_no_warning_for_urls_or_sample(mocker, argv): def test_transcribe_unknown_pii_policy_exits_2_and_lists_valid(mocker): _auth() - tx = mocker.patch("aai_cli.transcribe_exec.client.transcribe", autospec=True) + tx = mocker.patch("aai_cli.app.transcribe.run.client.transcribe", autospec=True) result = runner.invoke( app, ["transcribe", "audio.mp3", "--redact-pii", "--redact-pii-policy", "not_a_policy"], diff --git a/tests/test_transcribe_out.py b/tests/test_transcribe_out.py index faaa753f..08cc84dd 100644 --- a/tests/test_transcribe_out.py +++ b/tests/test_transcribe_out.py @@ -4,7 +4,7 @@ import pytest from typer.testing import CliRunner -from aai_cli import config +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -18,7 +18,7 @@ def audio_file(tmp_path, monkeypatch): (tmp_path / "audio.mp3").write_bytes(b"fake-audio") -_TRANSCRIBE = "aai_cli.transcribe_exec.client.transcribe" +_TRANSCRIBE = "aai_cli.app.transcribe.run.client.transcribe" def _auth(): @@ -118,7 +118,7 @@ def test_transcribe_out_missing_parent_dir_fails_before_transcribing(tmp_path): def test_transcribe_out_unwritable_parent_dir_fails_before_transcribing(tmp_path, monkeypatch): import os - from aai_cli import transcribe_validate + from aai_cli.app.transcribe import validate as transcribe_validate _auth() out = tmp_path / "x.txt" diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py index 98eb7baa..57c14b28 100644 --- a/tests/test_transcribe_render.py +++ b/tests/test_transcribe_render.py @@ -2,8 +2,8 @@ from rich.console import Console -from aai_cli import theme -from aai_cli import transcribe_render as tr +from aai_cli.app.transcribe import render as tr +from aai_cli.ui import theme def _render(transcript) -> str: @@ -127,7 +127,7 @@ def test_renders_entities_topics_content_safety_highlights(): def test_fmt_ms_rolls_over_to_hours(): # Sub-hour stays MM:SS; at an hour the format grows an hours field so a # 1h chapter reads 1:00:00 instead of the ambiguous 60:00. - from aai_cli.transcribe_render import _fmt_ms + from aai_cli.app.transcribe.render import _fmt_ms assert _fmt_ms(0) == "00:00" assert _fmt_ms(59_000) == "00:59" @@ -141,7 +141,7 @@ def test_sentiment_percentages_always_sum_to_100(): # Largest-remainder rounding: three equal counts split 34/33/33, not 33x3=99. from collections import Counter - from aai_cli.transcribe_render import _percentages + from aai_cli.app.transcribe.render import _percentages pcts = _percentages(Counter(positive=1, neutral=1, negative=1)) assert sum(pcts.values()) == 100 diff --git a/tests/test_transcribe_show_code.py b/tests/test_transcribe_show_code.py index 97522455..c5ce4ee1 100644 --- a/tests/test_transcribe_show_code.py +++ b/tests/test_transcribe_show_code.py @@ -15,7 +15,7 @@ def test_transcribe_show_code_prints_without_transcribing(monkeypatch): # Print-only: emits code, never calls the API, needs no auth. called = [] monkeypatch.setattr( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", lambda *a, **k: called.append(True), ) result = runner.invoke(app, ["transcribe", "--sample", "--speaker-labels", "--show-code"]) @@ -34,7 +34,7 @@ def test_transcribe_show_code_includes_download_sections(monkeypatch): def _boom(*a, **k): raise AssertionError("must not transcribe") - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", _boom) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) result = runner.invoke( app, ["transcribe", "https://youtu.be/abc", "--download-sections", "*0:00-5:00", "--show-code"], @@ -50,7 +50,7 @@ def test_transcribe_show_code_without_source_uses_placeholder(monkeypatch): def _boom(*a, **k): raise AssertionError("must not transcribe") - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", _boom) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) result = runner.invoke(app, ["transcribe", "--show-code"]) assert result.exit_code == 0 assert "import assemblyai as aai" in result.output @@ -63,7 +63,7 @@ def _boom(*a, **k): raise AssertionError("must not transcribe") monkeypatch.setattr( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", _boom, ) result = runner.invoke(app, ["transcribe", "--sample", "--show-code", "--json"]) @@ -77,7 +77,7 @@ def test_transcribe_show_code_includes_llm_gateway_without_running(monkeypatch): def _boom(*a, **k): raise AssertionError("must not call the API") - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", _boom) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", _boom) result = runner.invoke( app, @@ -94,7 +94,7 @@ def test_transcribe_show_code_output_srt_generates_export(monkeypatch): def _boom(*a, **k): raise AssertionError("must not transcribe") - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", _boom) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) result = runner.invoke(app, ["transcribe", "--sample", "-o", "srt", "--show-code"]) assert result.exit_code == 0 compile(result.output, "", "exec") # the emitted script is runnable @@ -107,7 +107,7 @@ def test_transcribe_show_code_output_vtt_with_chars_per_caption(monkeypatch): def _boom(*a, **k): raise AssertionError("must not transcribe") - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", _boom) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) result = runner.invoke( app, ["transcribe", "--sample", "-o", "vtt", "--chars-per-caption", "42", "--show-code"], @@ -121,7 +121,7 @@ def test_transcribe_show_code_output_utterances_generates_loop(monkeypatch): def _boom(*a, **k): raise AssertionError("must not transcribe") - monkeypatch.setattr("aai_cli.transcribe_exec.client.transcribe", _boom) + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) result = runner.invoke(app, ["transcribe", "--sample", "-o", "utterances", "--show-code"]) assert result.exit_code == 0 compile(result.output, "", "exec") @@ -133,7 +133,7 @@ def test_transcribe_show_code_rejects_bucket_urls(monkeypatch): # so a bucket source is rejected up front instead of emitting a broken script. called = [] monkeypatch.setattr( - "aai_cli.transcribe_exec.client.transcribe", + "aai_cli.app.transcribe.run.client.transcribe", lambda *a, **k: called.append(True), ) result = runner.invoke(app, ["transcribe", "s3://bucket/call.mp3", "--show-code"]) diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 30184387..a73f4079 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -2,8 +2,8 @@ from typer.testing import CliRunner -from aai_cli import config from aai_cli.auth.flow import LoginResult +from aai_cli.core import config from aai_cli.main import app runner = CliRunner() @@ -163,7 +163,7 @@ def test_list_empty_shows_human_empty_state(mocker): 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) + monkeypatch.setattr("aai_cli.app.context._interactive_session", lambda: True) 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!!"]) @@ -191,7 +191,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.app.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) rows = [{"id": "t1", "status": "completed"}] list_ = mocker.patch( @@ -244,7 +244,7 @@ def test_get_errored_transcript_exits_nonzero(mocker): def test_list_table_colors_status(monkeypatch, mocker): - from aai_cli.theme import make_console + from aai_cli.ui.theme import make_console config.set_api_key("default", "sk_live") # Pin a truecolor console with an empty _environ so the rendered ANSI is @@ -252,7 +252,7 @@ def test_list_table_colors_status(monkeypatch, mocker): # at render time, which leaks across tests and flips the color depth. With # _environ={} the depth is fixed by color_system alone. monkeypatch.setattr( - "aai_cli.output.console", + "aai_cli.ui.output.console", make_console(force_terminal=True, color_system="truecolor", _environ={}), ) rows = [ diff --git a/tests/test_tts_audio.py b/tests/test_tts_audio.py index 5622598b..fdde061a 100644 --- a/tests/test_tts_audio.py +++ b/tests/test_tts_audio.py @@ -7,8 +7,8 @@ import pytest -from aai_cli.errors import CLIError -from aai_cli.microphone import audio_missing_error +from aai_cli.core.errors import CLIError +from aai_cli.core.microphone import audio_missing_error from aai_cli.tts import audio diff --git a/tests/test_tts_dialogue.py b/tests/test_tts_dialogue.py index 164cc1b0..9745ddd5 100644 --- a/tests/test_tts_dialogue.py +++ b/tests/test_tts_dialogue.py @@ -2,7 +2,7 @@ import pytest -from aai_cli.errors import UsageError +from aai_cli.core.errors import UsageError from aai_cli.tts import dialogue diff --git a/tests/test_tts_session.py b/tests/test_tts_session.py index f8549bbb..6637c2e0 100644 --- a/tests/test_tts_session.py +++ b/tests/test_tts_session.py @@ -5,8 +5,8 @@ import pytest -from aai_cli import environments -from aai_cli.errors import APIError, CLIError, NotAuthenticated +from aai_cli.core import environments +from aai_cli.core.errors import APIError, CLIError, NotAuthenticated from aai_cli.tts import session diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 7f41a02b..506e055c 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -11,7 +11,9 @@ import pytest from rich.console import Console -from aai_cli import __version__, config, output, theme, update_check +from aai_cli import __version__ +from aai_cli.core import config +from aai_cli.ui import output, theme, update_check def test_update_cache_roundtrips(tmp_path, monkeypatch): @@ -239,7 +241,7 @@ def fake_popen(args, *, stdout, stderr, start_new_session, env): } return object() - monkeypatch.setattr("aai_cli.procs.subprocess.Popen", fake_popen) + monkeypatch.setattr("aai_cli.core.procs.subprocess.Popen", fake_popen) update_check.spawn_refresh() assert calls["args"][:3] == [sys.executable, "-m", "aai_cli"] diff --git a/tests/test_update_command.py b/tests/test_update_command.py index 77b58081..5f2936d9 100644 --- a/tests/test_update_command.py +++ b/tests/test_update_command.py @@ -5,8 +5,10 @@ from typer.testing import CliRunner -from aai_cli import __version__, config, update_check +from aai_cli import __version__ +from aai_cli.core import config from aai_cli.main import app +from aai_cli.ui import update_check runner = CliRunner() @@ -36,6 +38,15 @@ def fake_run(argv, check): return calls +def test_hidden_update_check_command_refreshes_cache(monkeypatch): + """The detached `_update-check` command delegates to fetch_and_cache().""" + called = {"hit": False} + monkeypatch.setattr(update_check, "fetch_and_cache", lambda: called.__setitem__("hit", True)) + result = runner.invoke(app, ["_update-check"]) + assert result.exit_code == 0 + assert called["hit"] is True + + def test_update_check_reports_available(monkeypatch): _pin_latest(monkeypatch, "999.0.0") result = runner.invoke(app, ["update", "--check"]) diff --git a/tests/test_wer.py b/tests/test_wer.py index a39230f7..bb6b0743 100644 --- a/tests/test_wer.py +++ b/tests/test_wer.py @@ -1,10 +1,10 @@ -"""WER scoring (`aai_cli.wer`): normalization, alignment counts, pooling.""" +"""WER scoring (`aai_cli.core.wer`): normalization, alignment counts, pooling.""" import dataclasses import pytest -from aai_cli import wer +from aai_cli.core import wer def _assign(obj, attribute, value): diff --git a/tests/test_ws.py b/tests/test_ws.py index 11895c73..ddfe310e 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -9,9 +9,9 @@ import logging import types -from aai_cli import debuglog -from aai_cli.errors import APIError, NotAuthenticated -from aai_cli.ws import ( +from aai_cli.core import debuglog +from aai_cli.core.errors import APIError, NotAuthenticated +from aai_cli.core.ws import ( WEBSOCKETS_LOGGERS, auth_or_api_error, handshake_status, diff --git a/tests/test_youtube.py b/tests/test_youtube.py index 01d1a775..53f89b7b 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -5,8 +5,8 @@ import pytest -from aai_cli import youtube -from aai_cli.errors import CLIError, UsageError +from aai_cli.core import youtube +from aai_cli.core.errors import CLIError, UsageError def test_is_youtube_url_variants(): @@ -206,7 +206,7 @@ def extract(): youtube.download_media("https://youtu.be/x", tmp_path) logger = captured["opts"]["logger"] # Structurally quiet: no propagation to root, only swallow-everything handlers. - assert logger.name == "aai_cli.youtube.yt_dlp" + assert logger.name == "aai_cli.core.youtube.yt_dlp" assert logger.propagate is False assert logger.handlers assert all(isinstance(h, logging.NullHandler) for h in logger.handlers)