diff --git a/aai_cli/errors.py b/aai_cli/errors.py index 0fc12225..0d23147b 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -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 diff --git a/aai_cli/streaming/session.py b/aai_cli/streaming/session.py index 1c028e70..0e55a213 100644 --- a/aai_cli/streaming/session.py +++ b/aai_cli/streaming/session.py @@ -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 @@ -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: diff --git a/aai_cli/transcribe_batch.py b/aai_cli/transcribe_batch.py index b88117ef..e4dbedfe 100644 --- a/aai_cli/transcribe_batch.py +++ b/aai_cli/transcribe_batch.py @@ -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 @@ -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: diff --git a/aai_cli/transcribe_exec.py b/aai_cli/transcribe_exec.py index c457b3fb..d0693ef6 100644 --- a/aai_cli/transcribe_exec.py +++ b/aai_cli/transcribe_exec.py @@ -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 @@ -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: @@ -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: @@ -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: diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index de0ea243..5587b83d 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -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): diff --git a/tests/test_errors.py b/tests/test_errors.py index 7690278b..f00a1be2 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -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(): diff --git a/tests/test_stream_command_flags.py b/tests/test_stream_command_flags.py index 7dc06020..4757b3f9 100644 --- a/tests/test_stream_command_flags.py +++ b/tests/test_stream_command_flags.py @@ -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(): diff --git a/tests/test_stream_llm.py b/tests/test_stream_llm.py index 5f160752..19c8cf9a 100644 --- a/tests/test_stream_llm.py +++ b/tests/test_stream_llm.py @@ -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): diff --git a/tests/test_stream_session.py b/tests/test_stream_session.py index 72873d8c..3d0caad9 100644 --- a/tests/test_stream_session.py +++ b/tests/test_stream_session.py @@ -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(): diff --git a/tests/test_transcribe_batch_sources.py b/tests/test_transcribe_batch_sources.py index 736f2114..e6836d21 100644 --- a/tests/test_transcribe_batch_sources.py +++ b/tests/test_transcribe_batch_sources.py @@ -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): diff --git a/tests/test_transcribe_flags.py b/tests/test_transcribe_flags.py index 6cff130b..5087ea02 100644 --- a/tests/test_transcribe_flags.py +++ b/tests/test_transcribe_flags.py @@ -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() @@ -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() diff --git a/tests/test_transcribe_out.py b/tests/test_transcribe_out.py index 52c43d55..25591081 100644 --- a/tests/test_transcribe_out.py +++ b/tests/test_transcribe_out.py @@ -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()