Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions aai_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ def __init__(self, message: str, *, suggestion: str | None = None) -> None:
super().__init__(message, error_type="usage_error", exit_code=2, suggestion=suggestion)


def mutually_exclusive(*flags: tuple[str, object], suggestion: str | None = None) -> None:
"""Raise a :class:`UsageError` naming the conflicting flags when more than one is set.

The shared primitive behind every "these flags conflict" check (gh's
``MutuallyExclusive``), so each call site is one declaration instead of a bespoke
validator. A flag counts as given when its value is truthy — call sites pass the
raw option values (``None``/``False``/``[]`` all mean "not passed").
"""
given = [name for name, value in flags if value]
if not given[1:]: # zero or one flag set: no conflict
return
*head, last = given
joined = ", ".join(head)
listed = f"{joined}, and {last}" if given[2:] else f"{joined} and {last}"
raise UsageError(f"{listed} can't be combined.", suggestion=suggestion)


# Word-level phrases that mark a failure as "the credentials were rejected" rather
# than a generic network/protocol error. Matched case-insensitively against str(exc).
# Deliberately NOT bare numbers like "401"/"403"/"1008": those match unrelated text
Expand Down
24 changes: 15 additions & 9 deletions aai_cli/streaming/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import typer

from aai_cli import choices, client, config_builder, llm, output
from aai_cli.errors import APIError, CLIError, UsageError
from aai_cli.errors import APIError, CLIError, UsageError, mutually_exclusive
from aai_cli.follow import FollowRenderer
from aai_cli.streaming.render import StreamRenderer, speaker_prefix

Expand Down Expand Up @@ -57,19 +57,25 @@ def validate_output_flags(*, json_mode: bool, output_field: choices.TextOrJson |
Same precedent as --llm + -o text: contradictory output shapes are a clean
usage error, not a silent coin-flip between plain text and NDJSON.
"""
if json_mode and output_field is choices.TextOrJson.text:
raise UsageError("--json can't be combined with -o text; pick one output format.")
mutually_exclusive(
("--json", json_mode),
("-o text", output_field is choices.TextOrJson.text),
suggestion="Pick one output format.",
)


def validate_sources(opts: SourceOptions, *, has_llm: bool, text_mode: bool) -> None:
"""Reject flag combinations that can't be honored, before any audio is opened."""
if opts.system_audio and opts.system_audio_only:
raise UsageError("Use either --system-audio or --system-audio-only, not both.")
mutually_exclusive(
("--system-audio", opts.system_audio),
("--system-audio-only", opts.system_audio_only),
)
_validate_input_source(opts)
if has_llm and text_mode:
raise UsageError(
"--llm renders a live panel (or NDJSON when piped); it can't be combined with -o text."
)
mutually_exclusive(
("--llm", has_llm),
("-o text", text_mode),
suggestion="--llm renders a live panel (or NDJSON when piped).",
)


def _validate_input_source(opts: SourceOptions) -> None:
Expand Down
24 changes: 13 additions & 11 deletions aai_cli/transcribe_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from rich.markup import escape

from aai_cli import client, jsonshape, output, stdio, theme, transcribe_exec
from aai_cli.errors import CLIError, NotAuthenticated, UsageError
from aai_cli.errors import CLIError, NotAuthenticated, UsageError, mutually_exclusive

if TYPE_CHECKING:
import assemblyai as aai
Expand Down Expand Up @@ -136,16 +136,18 @@ def reject_single_source_flags(
show_code: bool,
) -> None:
"""Batch mode writes one sidecar per source; the single-result flags don't apply."""
if show_code:
raise UsageError(
"--show-code generates code for a single source, not a batch.",
suggestion="Pass one file or URL with --show-code.",
)
if out is not None or output_field is not None or llm_prompt:
raise UsageError(
"--out, -o/--output, and --llm apply to a single source, not a batch.",
suggestion=f"Each source gets a '{SIDECAR_SUFFIX}' sidecar with the full result.",
)
mutually_exclusive(
("--show-code", show_code),
("multiple sources", True),
suggestion="Pass one file or URL with --show-code.",
)
mutually_exclusive(
("--out", out),
("-o/--output", output_field),
("--llm", llm_prompt),
("multiple sources", True),
suggestion=f"Each source gets a '{SIDECAR_SUFFIX}' sidecar with the full result.",
)


def sidecar_path(source: str) -> Path:
Expand Down
36 changes: 19 additions & 17 deletions aai_cli/transcribe_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from rich.markup import escape

from aai_cli import choices, client, llm, output, stdio, transcribe_render, youtube
from aai_cli.errors import UsageError
from aai_cli.errors import UsageError, mutually_exclusive

# 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
Expand All @@ -33,11 +33,11 @@ def validate_pii_policies(policies: list[str] | None) -> None:


def validate_language_flags(language_code: str | None, *, language_detection: bool | None) -> None:
if language_code and language_detection:
raise UsageError(
"--language-code and --language-detection can't be combined.",
suggestion="Force a language or auto-detect it, not both.",
)
mutually_exclusive(
("--language-code", language_code),
("--language-detection", language_detection),
suggestion="Force a language or auto-detect it, not both.",
)


def validate_speakers_expected(merged: dict[str, object]) -> None:
Expand All @@ -50,12 +50,12 @@ def validate_speakers_expected(merged: dict[str, object]) -> None:


def validate_out_with_llm(out: Path | None, llm_prompts: list[str] | None) -> None:
if out is not None and llm_prompts:
# --out captures the transcript itself; an LLM transform is a separate step.
raise UsageError(
"--out can't be combined with --llm.",
suggestion='Pipe the transform instead, e.g. -o text | assembly llm -f "…".',
)
# --out captures the transcript itself; an LLM transform is a separate step.
mutually_exclusive(
("--out", out),
("--llm", llm_prompts),
suggestion='Pipe the transform instead, e.g. -o text | assembly llm -f "…".',
)


def validate_out_path(out: Path | None) -> None:
Expand All @@ -80,11 +80,13 @@ def validate_json_with_output(
) -> None:
"""``--json`` promises the full JSON payload (same as ``-o json``); any other
``-o`` field contradicts it rather than silently winning."""
if json_mode and output_field is not None and output_field is not choices.TranscriptOutput.json:
raise UsageError(
f"--json conflicts with -o {output_field.value}.",
suggestion="Drop --json, or use -o json for the full JSON payload.",
)
if output_field is None or output_field is choices.TranscriptOutput.json:
return
mutually_exclusive(
("--json", json_mode),
(f"-o {output_field.value}", output_field),
suggestion="Drop --json, or use -o json for the full JSON payload.",
)


def warn_unrecognized_extension(source: str | None, *, json_mode: bool, quiet: bool) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agent_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ def test_agent_json_with_text_output_is_usage_error():
# Contradictory output shapes (--json + -o text) are rejected like stream's.
result = runner.invoke(app, ["agent", "--json", "-o", "text"])
assert result.exit_code == 2
assert "can't be combined with -o text" in result.output
assert "--json and -o text can't be combined." in result.output


def test_agent_headphones_notice_routes_to_stderr(monkeypatch):
Expand Down
39 changes: 38 additions & 1 deletion tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,41 @@
from aai_cli.errors import APIError, CLIError, NotAuthenticated, is_auth_failure
import pytest

from aai_cli.errors import (
APIError,
CLIError,
NotAuthenticated,
UsageError,
is_auth_failure,
mutually_exclusive,
)


def test_mutually_exclusive_two_set_flags_raise_usage_error():
with pytest.raises(UsageError) as exc:
mutually_exclusive(("--a", "x"), ("--b", True), suggestion="pick one")
assert exc.value.message == "--a and --b can't be combined."
assert exc.value.suggestion == "pick one"
assert exc.value.exit_code == 2
assert exc.value.error_type == "usage_error"


def test_mutually_exclusive_lists_three_set_flags_with_oxford_comma():
with pytest.raises(UsageError) as exc:
mutually_exclusive(("--a", 1), ("--b", ["x"]), ("--c", True))
assert exc.value.message == "--a, --b, and --c can't be combined."
assert exc.value.suggestion is None


def test_mutually_exclusive_names_only_the_set_flags():
with pytest.raises(UsageError) as exc:
mutually_exclusive(("--a", True), ("--b", None), ("--c", "y"))
assert exc.value.message == "--a and --c can't be combined."


def test_mutually_exclusive_allows_zero_or_one_set_flag():
mutually_exclusive(("--a", None), ("--b", False))
# Falsy values ("", []) mean "not passed", same as None.
mutually_exclusive(("--a", "x"), ("--b", ""), ("--c", []))


def test_not_authenticated_defaults():
Expand Down
3 changes: 2 additions & 1 deletion tests/test_stream_command_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ def test_stream_json_with_text_output_is_usage_error():
# credentials, like the --llm + -o text precedent.
result = runner.invoke(app, ["stream", "--json", "-o", "text"])
assert result.exit_code == 2
assert "can't be combined with -o text" in result.output
assert "--json and -o text can't be combined." in result.output
assert "Pick one output format." in result.output


def test_stream_stdin_with_sample_rejected():
Expand Down
4 changes: 3 additions & 1 deletion tests/test_stream_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ def test_stream_llm_rejects_output_text(monkeypatch):
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not stream")),
)
result = runner.invoke(app, ["stream", "--llm", "summarize", "-o", "text"])
assert result.exit_code == 2 # --llm renders a panel/NDJSON; -o text is contradictory
assert result.exit_code == 2
assert "--llm and -o text can't be combined." in result.output
assert "renders a live panel" in result.output # the why lives in the suggestion


def test_stream_without_prompt_does_not_transform(monkeypatch):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_stream_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ def test_stream_system_audio_rejects_both_modes():
config.set_api_key("default", "sk_live")
result = runner.invoke(app, ["stream", "--system-audio", "--system-audio-only"])
assert result.exit_code == 2
assert "either --system-audio" in result.output
assert "--system-audio and --system-audio-only can't be combined." in result.output


def test_stream_show_code_rejects_system_audio():
Expand Down
14 changes: 10 additions & 4 deletions tests/test_transcribe_batch_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,21 @@ def test_expand_sources_directory_error_message_names_the_path(tmp_path):


@pytest.mark.parametrize(
"extra",
[["--out", "x.txt"], ["-o", "text"], ["--llm", "summarize"], ["--show-code"]],
("extra", "flag_name", "hint"),
[
(["--out", "x.txt"], "--out", "sidecar with the full result"),
(["-o", "text"], "-o/--output", "sidecar with the full result"),
(["--llm", "summarize"], "--llm", "sidecar with the full result"),
(["--show-code"], "--show-code", "Pass one file or URL"),
],
)
def test_batch_rejects_single_source_flags(tmp_path, extra):
def test_batch_rejects_single_source_flags(tmp_path, extra, flag_name, hint):
_auth()
(tmp_path / "a.mp3").write_bytes(b"a")
result = runner.invoke(app, ["transcribe", "*.mp3", *extra])
assert result.exit_code == 2
assert "single source" in result.output
assert f"{flag_name} and multiple sources can't be combined." in result.output
assert hint in result.output


def test_glob_batch_writes_per_source_sidecars(tmp_path, mocker, monkeypatch):
Expand Down
4 changes: 3 additions & 1 deletion tests/test_transcribe_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def test_transcribe_language_code_with_detection_exits_2(mocker):
)
assert result.exit_code == 2
assert "--language-code and --language-detection can't be combined." in result.output
assert "Force a language or auto-detect it, not both." in result.output
tx.assert_not_called()


Expand Down Expand Up @@ -299,7 +300,8 @@ def test_transcribe_json_with_non_json_output_field_exits_2(mocker):
tx = mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True)
result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "text", "--json"])
assert result.exit_code == 2
assert "--json conflicts with -o text" in result.output
assert "--json and -o text can't be combined." in result.output
assert "use -o json for the full JSON payload" in result.output
tx.assert_not_called()


Expand Down
2 changes: 2 additions & 0 deletions tests/test_transcribe_out.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ def test_transcribe_out_with_llm_is_a_usage_error(tmp_path):
app, ["transcribe", "audio.mp3", "--llm", "summarize", "--out", str(out)]
)
assert result.exit_code == 2
assert "--out and --llm can't be combined." in result.output
assert "Pipe the transform instead" in result.output
assert not out.exists()


Expand Down
Loading