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
105 lines
5.0 KiB
Python
105 lines
5.0 KiB
Python
"""Consistency gate: the CLI ``error_handler`` agrees with ``_app.errors.classify``.
|
|
|
|
Per ADR-0021, ``_app.errors.classify`` is the single neutral source of the failure
|
|
**category**; the CLI ``error_handler`` projects that category onto its own string-code
|
|
vocabulary (and the MCP server onto its manifest-pinned codes). Historically each layer
|
|
kept its own exception→code ladder, which could silently drift.
|
|
|
|
This gate pins the projection: for a properly-constructed exemplar of **every**
|
|
:class:`~notebooklm._app.errors.ErrorCategory`, the ``--json`` code the
|
|
``error_handler`` actually emits must equal the code this module maps that category to.
|
|
If someone adds an exception (or a category) and updates only one ladder, this fails.
|
|
|
|
It complements ``tests/unit/app/test_app_errors.py`` (every ``NotebookLMError`` subclass
|
|
classifies into *some* category) and ``tests/unit/cli/test_error_handler.py`` (each type
|
|
emits the expected envelope) by asserting the two are *the same decision*.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from notebooklm import exceptions as exc
|
|
from notebooklm._app import SourceMutationError
|
|
from notebooklm._app.errors import ErrorCategory, classify
|
|
from notebooklm.cli.error_handler import handle_errors
|
|
|
|
# The CLI code each neutral category projects onto. The distinct codes
|
|
# (NOT_FOUND/AUTH_ERROR/…) recover 1:1; the transient/RPC-family categories
|
|
# (TIMEOUT/SERVER/RPC) and the library/source-mutation catch-alls currently
|
|
# fold into the generic ``NOTEBOOKLM_ERROR`` (a SourceMutationError that reaches
|
|
# the central handler — it is normally caught by the source command's own
|
|
# handler first — gets the library code). UNEXPECTED is the non-library bug code.
|
|
_CATEGORY_TO_CLI_CODE: dict[ErrorCategory, str] = {
|
|
ErrorCategory.NOT_FOUND: "NOT_FOUND",
|
|
ErrorCategory.AUTH: "AUTH_ERROR",
|
|
ErrorCategory.RATE_LIMITED: "RATE_LIMITED",
|
|
ErrorCategory.VALIDATION: "VALIDATION_ERROR",
|
|
ErrorCategory.CONFIG: "CONFIG_ERROR",
|
|
ErrorCategory.NETWORK: "NETWORK_ERROR",
|
|
ErrorCategory.NOTEBOOK_LIMIT: "NOTEBOOK_LIMIT",
|
|
ErrorCategory.ARTIFACT_TIMEOUT: "ARTIFACT_TIMEOUT",
|
|
ErrorCategory.TIMEOUT: "NOTEBOOKLM_ERROR",
|
|
ErrorCategory.SERVER: "NOTEBOOKLM_ERROR",
|
|
ErrorCategory.RPC: "NOTEBOOKLM_ERROR",
|
|
ErrorCategory.SOURCE_MUTATION: "NOTEBOOKLM_ERROR",
|
|
ErrorCategory.LIBRARY: "NOTEBOOKLM_ERROR",
|
|
ErrorCategory.UNEXPECTED: "UNEXPECTED_ERROR",
|
|
}
|
|
|
|
# One exemplar per category, constructed so the handler's attribute-dependent
|
|
# rendering (retry_after / NotebookLimit.to_error_response_extra / the artifact
|
|
# status block / NotFound id attrs) succeeds. ``ids`` name the category so a
|
|
# failure points at the drifting pair directly.
|
|
_EXEMPLARS: list[tuple[ErrorCategory, BaseException]] = [
|
|
(ErrorCategory.NOT_FOUND, exc.SourceNotFoundError("src_456")),
|
|
(ErrorCategory.AUTH, exc.AuthError("auth failed")),
|
|
(ErrorCategory.RATE_LIMITED, exc.RateLimitError("slow down", retry_after=5)),
|
|
(ErrorCategory.VALIDATION, exc.ValidationError("bad input")),
|
|
(ErrorCategory.CONFIG, exc.ConfigurationError("missing config")),
|
|
(ErrorCategory.NETWORK, exc.NetworkError("connection refused")),
|
|
(ErrorCategory.NOTEBOOK_LIMIT, exc.NotebookLimitError(499, limit=500)),
|
|
(ErrorCategory.ARTIFACT_TIMEOUT, exc.ArtifactTimeoutError("nb-1", "task-1", 30.0)),
|
|
(ErrorCategory.TIMEOUT, exc.WaitTimeoutError("generic wait timed out")),
|
|
(ErrorCategory.SERVER, exc.ServerError("upstream 503")),
|
|
(ErrorCategory.RPC, exc.RPCError("decode failed", method_id="abc123")),
|
|
(ErrorCategory.SOURCE_MUTATION, SourceMutationError("ambiguous", "AMBIGUOUS_ID")),
|
|
(ErrorCategory.LIBRARY, exc.NotebookLMError("some library error")),
|
|
(ErrorCategory.UNEXPECTED, RuntimeError("boom")),
|
|
]
|
|
|
|
|
|
def test_category_map_covers_every_category() -> None:
|
|
"""Every ``ErrorCategory`` has a CLI-code projection — a new category fails here."""
|
|
assert set(_CATEGORY_TO_CLI_CODE) == set(ErrorCategory)
|
|
|
|
|
|
def test_one_exemplar_per_category() -> None:
|
|
"""Exactly one exemplar per category, so the parametrization is exhaustive."""
|
|
assert {category for category, _ in _EXEMPLARS} == set(ErrorCategory)
|
|
|
|
|
|
def _emitted_code(exception: BaseException, capsys: pytest.CaptureFixture[str]) -> str:
|
|
"""The ``code`` field of the ``--json`` envelope the handler emits for ``exception``."""
|
|
with pytest.raises(SystemExit), handle_errors(json_output=True):
|
|
raise exception
|
|
return json.loads(capsys.readouterr().out)["code"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("expected_category", "exception"),
|
|
_EXEMPLARS,
|
|
ids=[category.name for category, _ in _EXEMPLARS],
|
|
)
|
|
def test_error_handler_code_matches_classify_category(
|
|
expected_category: ErrorCategory,
|
|
exception: BaseException,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
# 1. classify lands the exemplar in the expected category.
|
|
assert classify(exception).category is expected_category
|
|
# 2. the CLI handler's emitted code is the projection of that category.
|
|
assert _emitted_code(exception, capsys) == _CATEGORY_TO_CLI_CODE[expected_category]
|