09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
200 lines
6.5 KiB
Python
200 lines
6.5 KiB
Python
"""Root-boundary guard for ``--json`` validation errors.
|
|
|
|
Click validates option values, missing arguments, and unknown options before
|
|
command callbacks run. Those failures must still honor ``--json`` so automation
|
|
never receives usage text when it asked for a machine-readable error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Iterator
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import click
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
import notebooklm.auth as auth_module
|
|
import notebooklm.cli.helpers as helpers_module
|
|
from notebooklm.cli.grouped import SectionedGroup
|
|
from notebooklm.notebooklm_cli import cli
|
|
|
|
|
|
@pytest.fixture
|
|
def runner() -> CliRunner:
|
|
# Click 8.3 captures stdout/stderr separately by default; mix_stderr was removed.
|
|
return CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_auth_env() -> Iterator[None]:
|
|
with (
|
|
patch.object(helpers_module, "load_auth_from_storage") as mock_load,
|
|
patch.object(
|
|
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
|
|
) as mock_fetch,
|
|
):
|
|
mock_load.return_value = {
|
|
"SID": "test",
|
|
"HSID": "test",
|
|
"SSID": "test",
|
|
"APISID": "test",
|
|
"SAPISID": "test",
|
|
}
|
|
mock_fetch.return_value = ("csrf", "session")
|
|
yield
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case_id,argv",
|
|
[
|
|
("invalid_limit", ["list", "--limit", "-1", "--json"]),
|
|
("invalid_interval", ["research", "wait", "--interval", "0", "--json"]),
|
|
("invalid_retry", ["generate", "audio", "--retry", "-1", "--json"]),
|
|
("missing_argument", ["note", "get", "--json"]),
|
|
("unknown_option", ["list", "--json", "--definitely-not-an-option"]),
|
|
("root_callback_validation", ["--quiet", "-v", "status", "--json"]),
|
|
],
|
|
ids=lambda value: value if isinstance(value, str) else None,
|
|
)
|
|
def test_json_validation_errors_emit_json(
|
|
case_id: str,
|
|
argv: list[str],
|
|
runner: CliRunner,
|
|
) -> None:
|
|
result = runner.invoke(cli, argv, catch_exceptions=False)
|
|
|
|
assert result.exit_code != 0, result.output
|
|
assert result.stdout.strip(), f"{case_id}: expected JSON error on stdout"
|
|
assert result.stderr == "", f"{case_id}: stderr should stay empty under --json"
|
|
assert result.output == result.stdout, f"{case_id}: combined output should be JSON only"
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "VALIDATION_ERROR"
|
|
assert payload["message"]
|
|
|
|
|
|
def test_command_body_click_validation_emit_json(
|
|
runner: CliRunner,
|
|
mock_auth_env,
|
|
) -> None:
|
|
result = runner.invoke(
|
|
cli,
|
|
["note", "create", "positional", "--content", "flag", "-n", "nb_123", "--json"],
|
|
catch_exceptions=False,
|
|
)
|
|
|
|
assert result.exit_code != 0, result.output
|
|
assert result.stderr == ""
|
|
assert result.output == result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "VALIDATION_ERROR"
|
|
assert "Cannot use both" in payload["message"]
|
|
|
|
|
|
def test_json_abort_emit_json(runner: CliRunner) -> None:
|
|
@click.group(cls=SectionedGroup)
|
|
def root() -> None:
|
|
pass
|
|
|
|
@root.command()
|
|
@click.option("--json", "json_output", is_flag=True)
|
|
def aborting(json_output: bool) -> None: # noqa: ARG001
|
|
raise click.Abort()
|
|
|
|
result = runner.invoke(root, ["aborting", "--json"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1
|
|
assert result.stderr == ""
|
|
assert result.output == result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload == {
|
|
"error": True,
|
|
"code": "CANCELLED",
|
|
"message": "Cancelled by user",
|
|
}
|
|
|
|
|
|
def _walk_leaf_commands(
|
|
command: click.BaseCommand,
|
|
path: tuple[str, ...] = (),
|
|
) -> Iterator[tuple[tuple[str, ...], click.Command]]:
|
|
if isinstance(command, click.Group):
|
|
for name, child in command.commands.items():
|
|
yield from _walk_leaf_commands(child, path + (name,))
|
|
return
|
|
if isinstance(command, click.Command):
|
|
yield path, command
|
|
|
|
|
|
def _has_json_option(command: click.Command) -> bool:
|
|
return any(
|
|
isinstance(param, click.Option) and "--json" in param.opts for param in command.params
|
|
)
|
|
|
|
|
|
def _invalid_value_for_type(param_type: click.ParamType) -> str | None:
|
|
if isinstance(param_type, click.IntRange):
|
|
if param_type.min is not None:
|
|
return str(param_type.min - 1)
|
|
if param_type.max is not None:
|
|
return str(param_type.max + 1)
|
|
return "not-an-int"
|
|
if isinstance(param_type, click.types.IntParamType):
|
|
return "not-an-int"
|
|
if isinstance(param_type, click.Choice):
|
|
return "__not_a_valid_choice__"
|
|
return None
|
|
|
|
|
|
def _validated_json_option_cases() -> list[pytest.ParameterSet]:
|
|
cases: list[pytest.ParameterSet] = []
|
|
for path, command in _walk_leaf_commands(cli):
|
|
if not _has_json_option(command):
|
|
continue
|
|
for param in command.params:
|
|
if (
|
|
not isinstance(param, click.Option)
|
|
or "--json" in param.opts
|
|
or param.is_flag
|
|
or param.count
|
|
):
|
|
continue
|
|
value = _invalid_value_for_type(param.type)
|
|
if value is None:
|
|
continue
|
|
opt = next((name for name in param.opts if name.startswith("--")), param.opts[0])
|
|
case_id = "_".join((*path, opt.lstrip("-").replace("-", "_")))
|
|
cases.append(pytest.param(case_id, [*path, opt, value, "--json"], id=case_id))
|
|
return cases
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case_id,argv",
|
|
_validated_json_option_cases(),
|
|
)
|
|
def test_validated_json_options_emit_json_on_bad_values(
|
|
case_id: str,
|
|
argv: list[str],
|
|
runner: CliRunner,
|
|
) -> None:
|
|
result = runner.invoke(cli, argv, catch_exceptions=False)
|
|
|
|
assert result.exit_code != 0, result.output
|
|
assert result.stderr == "", f"{case_id}: stderr should stay empty under --json"
|
|
assert result.output == result.stdout, f"{case_id}: combined output should be JSON only"
|
|
payload = json.loads(result.stdout)
|
|
assert payload["error"] is True
|
|
assert payload["code"] == "VALIDATION_ERROR"
|
|
|
|
|
|
def test_text_validation_errors_keep_click_usage_output(runner: CliRunner) -> None:
|
|
result = runner.invoke(cli, ["list", "--limit", "-1"])
|
|
|
|
assert result.exit_code == 2
|
|
assert result.stdout == ""
|
|
assert "Usage:" in result.stderr
|
|
assert not result.stdout.strip().startswith("{")
|