chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""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("{")
|
||||
Reference in New Issue
Block a user