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
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Click ``IntRange`` validation for shared int options.
|
|
|
|
These tests exercise the Click option layer (P1.T7) — they verify that
|
|
``--interval``, ``--retry``, and ``--limit`` reject out-of-range integers at
|
|
parse time with ``exit_code == 2`` (Click's convention for ``UsageError``)
|
|
so command bodies never see invalid values such as ``--interval 0`` (which
|
|
would otherwise blow up inside the polling loop with ``ZeroDivisionError`` or
|
|
busy-spin) or ``--limit -1``.
|
|
|
|
Every test here fails at the Click parser before the command body runs, so
|
|
no auth or HTTP mocking is needed — the CLI never reaches a real call site.
|
|
"""
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from notebooklm.notebooklm_cli import cli
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --interval: IntRange(min=1) — zero would cause divide-by-zero / busy-loop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIntervalRange:
|
|
def test_research_wait_interval_zero_is_usage_error(self) -> None:
|
|
"""``research wait --interval 0`` must fail at parse time.
|
|
|
|
Before the IntRange guard, this would propagate into the sleep loop
|
|
and either busy-spin or trigger ZeroDivisionError downstream.
|
|
"""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["research", "wait", "--interval", "0"])
|
|
assert result.exit_code == 2
|
|
# Click's IntRange emits "is not in the range x<=...<=y" / "must be >= 1"
|
|
# depending on version; assert on a stable substring.
|
|
assert "--interval" in result.output or "interval" in result.output.lower()
|
|
assert "0" in result.output
|
|
|
|
def test_research_wait_interval_negative_is_usage_error(self) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["research", "wait", "--interval", "-1"])
|
|
assert result.exit_code == 2
|
|
|
|
def test_artifact_wait_interval_zero_is_usage_error(self) -> None:
|
|
"""``artifact wait --interval 0`` must also fail (shared options decorator).
|
|
|
|
``artifact wait`` uses ``wait_polling_options`` from
|
|
``cli/options.py``, so this exercises the shared decorator path.
|
|
"""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["artifact", "wait", "x", "--interval", "0"])
|
|
assert result.exit_code == 2
|
|
assert "interval" in result.output.lower()
|
|
|
|
def test_source_wait_interval_zero_is_usage_error(self) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["source", "wait", "x", "--interval", "0"])
|
|
assert result.exit_code == 2
|
|
assert "interval" in result.output.lower()
|
|
|
|
def test_interval_positive_value_accepts(self) -> None:
|
|
"""``--interval 1`` is the lower bound and must parse.
|
|
|
|
We only check that Click does NOT reject the value at parse time;
|
|
the command body itself may still fail (e.g. no auth) — that's fine,
|
|
the goal is "parser accepts 1".
|
|
"""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["research", "wait", "--interval", "1", "--help"])
|
|
# ``--help`` short-circuits the body, so a clean parse exits 0.
|
|
assert result.exit_code == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --retry: IntRange(min=0) — negative retries are meaningless
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRetryRange:
|
|
def test_generate_audio_retry_negative_is_usage_error(self) -> None:
|
|
"""``generate audio --retry -1`` must fail at parse time."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["generate", "audio", "--retry", "-1"])
|
|
assert result.exit_code == 2
|
|
assert "--retry" in result.output or "retry" in result.output.lower()
|
|
|
|
def test_generate_video_retry_negative_is_usage_error(self) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["generate", "video", "--retry", "-1"])
|
|
assert result.exit_code == 2
|
|
|
|
def test_retry_zero_accepts(self) -> None:
|
|
"""``--retry 0`` (the default — "no retries") must remain valid."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["generate", "audio", "--retry", "0", "--help"])
|
|
assert result.exit_code == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --limit: IntRange(min=0) — 0 means "show 0 rows"; negatives are rejected
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLimitRange:
|
|
def test_notebook_list_limit_negative_is_usage_error(self) -> None:
|
|
"""``list --limit -1`` must fail at parse time."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["list", "--limit", "-1"])
|
|
assert result.exit_code == 2
|
|
assert "--limit" in result.output or "limit" in result.output.lower()
|
|
|
|
def test_artifact_list_limit_negative_is_usage_error(self) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["artifact", "list", "--limit", "-1"])
|
|
assert result.exit_code == 2
|
|
|
|
def test_source_list_limit_negative_is_usage_error(self) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["source", "list", "--limit", "-1"])
|
|
assert result.exit_code == 2
|
|
|
|
def test_limit_zero_accepts(self) -> None:
|
|
"""``--limit 0`` must parse (semantics: show zero rows).
|
|
|
|
This is the explicit decision documented in the plan: the existing
|
|
slice ``rows[:0]`` yields an empty list, so 0 means "0 rows". Users
|
|
omit the flag entirely for "unlimited".
|
|
"""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["list", "--limit", "0", "--help"])
|
|
assert result.exit_code == 0
|
|
|
|
def test_limit_help_text_documents_zero_semantics(self) -> None:
|
|
"""The shared ``--limit`` help text must spell out the 0 = zero rows rule.
|
|
|
|
Click line-wraps long help strings in ``--help`` output, so collapse
|
|
whitespace before matching to make the assertion linewrap-tolerant.
|
|
"""
|
|
runner = CliRunner()
|
|
result = runner.invoke(cli, ["list", "--help"])
|
|
assert result.exit_code == 0
|
|
# Plan-mandated wording: "Show at most N rows. 0 = show no rows. Omit for unlimited."
|
|
collapsed = " ".join(result.output.split())
|
|
assert "0 = show no rows" in collapsed
|
|
assert "Omit for unlimited" in collapsed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# alias_command (P3.T6b) — Click command-alias builder
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# ``alias_command`` lives in ``cli/options.py`` because ``cli/helpers.py`` is
|
|
# constrained to a compatibility-facade surface (see
|
|
# ``tests/_guardrails/test_cli_boundary.py``). These tests use direct construction
|
|
# (no monkeypatching, per task spec) — they build a tiny throwaway group +
|
|
# source command and verify static and runtime properties via Click's
|
|
# ``CliRunner``. The cinematic-video aliases on the real ``generate`` and
|
|
# ``download`` groups are covered by the existing characterization tests
|
|
# (``tests/unit/cli/test_download_characterization.py``,
|
|
# ``tests/unit/cli/test_generate.py``) — those would have regressed if the
|
|
# helper's contract had drifted.
|
|
|
|
|
|
class TestAliasCommand:
|
|
def test_alias_command_registers_under_new_name(self) -> None:
|
|
"""The alias appears in the group's command registry under ``name``."""
|
|
import click
|
|
|
|
from notebooklm.cli.options import alias_command
|
|
|
|
@click.group()
|
|
def grp() -> None:
|
|
"""Test group."""
|
|
|
|
@grp.command("orig")
|
|
def orig() -> None:
|
|
"""Original command."""
|
|
click.echo("orig-called")
|
|
|
|
alias = alias_command(grp, orig, name="alias", help="Alias of orig.")
|
|
|
|
assert "alias" in grp.commands
|
|
assert grp.commands["alias"] is alias
|
|
assert alias.name == "alias"
|
|
|
|
def test_alias_command_overrides_help_text(self) -> None:
|
|
"""The alias renders the override help, not the source command's help."""
|
|
import click
|
|
|
|
from notebooklm.cli.options import alias_command
|
|
|
|
@click.group()
|
|
def grp() -> None:
|
|
"""Group."""
|
|
|
|
@grp.command("orig", help="Original help text.")
|
|
def orig() -> None:
|
|
click.echo("ok")
|
|
|
|
alias_command(grp, orig, name="alias", help="Alias help text.")
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(grp, ["alias", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "Alias help text." in result.output
|
|
assert "Original help text." not in result.output
|
|
|
|
def test_alias_command_shares_callback_and_params(self) -> None:
|
|
"""Invoking the alias runs the source command's callback with its params."""
|
|
import click
|
|
|
|
from notebooklm.cli.options import alias_command
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
@click.group()
|
|
def grp() -> None:
|
|
"""Group."""
|
|
|
|
@grp.command("orig")
|
|
@click.option("--count", type=int, default=1)
|
|
@click.argument("target")
|
|
def orig(count: int, target: str) -> None:
|
|
captured["count"] = count
|
|
captured["target"] = target
|
|
click.echo(f"{target}x{count}")
|
|
|
|
alias_command(grp, orig, name="alias", help="Alias.")
|
|
|
|
runner = CliRunner()
|
|
|
|
# Source command still works.
|
|
captured.clear()
|
|
result = runner.invoke(grp, ["orig", "--count", "3", "foo"])
|
|
assert result.exit_code == 0, result.output
|
|
assert captured == {"count": 3, "target": "foo"}
|
|
assert "foox3" in result.output
|
|
|
|
# Alias runs the same callback with the same params.
|
|
captured.clear()
|
|
result = runner.invoke(grp, ["alias", "--count", "5", "bar"])
|
|
assert result.exit_code == 0, result.output
|
|
assert captured == {"count": 5, "target": "bar"}
|
|
assert "barx5" in result.output
|
|
|
|
def test_alias_command_params_list_is_copied(self) -> None:
|
|
"""Mutating the source command's params after aliasing does not leak in.
|
|
|
|
The alias holds its own list (``list(source_command.params)``) so a
|
|
later ``source_command.params.append(...)`` cannot retroactively
|
|
change the alias's accepted flags. The ``Parameter`` instances inside
|
|
are still shared by reference — that's the desired alias contract.
|
|
"""
|
|
import click
|
|
|
|
from notebooklm.cli.options import alias_command
|
|
|
|
@click.group()
|
|
def grp() -> None:
|
|
"""Group."""
|
|
|
|
@grp.command("orig")
|
|
@click.option("--flag-a", is_flag=True)
|
|
def orig(flag_a: bool) -> None:
|
|
click.echo("ok")
|
|
|
|
alias = alias_command(grp, orig, name="alias", help="Alias.")
|
|
|
|
alias_param_count_before = len(alias.params)
|
|
orig.params.append(click.Option(["--injected"], is_flag=True))
|
|
|
|
assert len(alias.params) == alias_param_count_before
|
|
|
|
def test_alias_command_returns_registered_command(self) -> None:
|
|
"""The helper returns the same ``click.Command`` it just registered."""
|
|
import click
|
|
|
|
from notebooklm.cli.options import alias_command
|
|
|
|
@click.group()
|
|
def grp() -> None:
|
|
"""Group."""
|
|
|
|
@grp.command("orig")
|
|
def orig() -> None:
|
|
pass
|
|
|
|
alias = alias_command(grp, orig, name="alias", help="Alias.")
|
|
assert isinstance(alias, click.Command)
|
|
assert alias is grp.commands["alias"]
|