Files
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

421 lines
19 KiB
Python

"""Behavioral conformance for the public ``get`` / ``get_or_none`` miss contract (ADR-0019).
The static sibling, ``test_public_api_contract.py``, walks
``inspect.signature(...)`` return annotations across the whole public surface. It
is the Tier-1 *shape* floor: it proves ``get_or_none`` is annotated ``Optional``,
``delete`` returns ``None``, and every still-``Optional`` ``get`` carries a
reason-tagged exemption. But a static walk **never executes a method**, so it
cannot catch the exact historical ``mind_maps`` bug — a ``get()`` correctly
annotated ``MindMap | None`` that *forgot to warn* on a miss (#1358). That
miss-behaviour is hand-duplicated across ``_sources`` / ``_artifacts`` /
``_notes`` / ``_mind_maps_api`` as each namespace moved off the old
None-on-miss warning runway — exactly the kind of copy that silently rots when
one copy is dropped.
This module adds the **behavioural** half of the Tier-1 floor. For each lookup
namespace it instantiates the backing API with a fake backend (reusing the
constructor-injection substrate under ``tests/_fixtures/`` — no network, auth, or
event loop beyond what those provide) arranged to yield a genuine MISS, then
asserts the not-found contract:
* ``await get(<missing id>)`` raises its ``*NotFoundError`` (the v0.8.0 contract,
#1247 — now live for all five namespaces);
* ``await get_or_none(<missing id>)`` emits **no** ``DeprecationWarning`` **and**
returns ``None`` (the sanctioned silent optional-lookup).
**Flip-durability (the load-bearing design choice).** The per-namespace table
:data:`LOOKUP_CASES` carries, for each namespace, its API factory, a
miss-arranger, the ``get`` arguments, the resource name, and the
``*NotFoundError`` type. The ``get_warns`` flag selects the assertion:
``get_warns=False`` → ``pytest.raises(<*NotFoundError>)``;
``get_warns=True`` → ``pytest.warns(DeprecationWarning)`` + ``None``. The v0.8.0
``get()``→raise flip (#1247) landed by flipping every namespace's ``get_warns``
to ``False`` with a *single table-driven edit*; ``test_no_namespace_remains_on_the_warn_runway``
now pins the warn branch unused (no row may regain ``get_warns=True``). The harness
keeps both branches so the flip is exercised by construction and the table mirrors
how the static ``GET_OPTIONAL_EXEMPTIONS`` allowlist drained to empty: the contract
lives in one visible, reason-tagged table, never scattered.
"""
from __future__ import annotations
import warnings
from collections.abc import Callable
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._artifacts import ArtifactsAPI
from notebooklm._labels import LabelsAPI
from notebooklm._mind_map import NoteBackedMindMapService
from notebooklm._mind_maps_api import MindMapsAPI
from notebooklm._note_service import NoteService
from notebooklm._notebooks import NotebooksAPI
from notebooklm._notes import NotesAPI
from notebooklm._sources import SourcesAPI
from notebooklm.exceptions import (
ArtifactNotFoundError,
LabelNotFoundError,
MindMapNotFoundError,
NotebookNotFoundError,
NoteNotFoundError,
SourceNotFoundError,
)
# This behavioural table is the executable companion of the static
# ``LOOKUP_NAMESPACES`` set in ``test_public_api_contract.py``: the same six
# namespaces that expose the ``get`` / ``get_or_none`` pair.
# ``test_table_covers_all_lookup_namespaces`` below pins the two in lock-step so
# a namespace can never gain the lookup pair without also gaining behavioural
# coverage of its miss contract.
# ---------------------------------------------------------------------------
# Per-namespace factories + miss-arrangers
#
# Each factory builds the backing API through constructor injection only
# (``make_fake_core`` / ``MagicMock`` collaborators) so the behavioural walk
# needs no auth, event loop, or network — mirroring the fixtures in
# ``test_get_or_none.py`` but consolidated behind one post-#1247 table.
# ---------------------------------------------------------------------------
def _make_sources_api() -> SourcesAPI:
# No ``make_fake_core`` here: ``_arrange_list_miss`` overrides ``api.list``
# before any RPC path is reached, so the first positional collaborator is
# never called (matches how ``test_get_or_none.py`` builds its sources API).
return SourcesAPI(MagicMock(), uploader=MagicMock())
def _make_artifacts_api() -> ArtifactsAPI:
from tests._fixtures.fake_core import make_fake_core
core = make_fake_core(rpc_call=AsyncMock(), get_source_ids=AsyncMock(return_value=[]))
mind_maps = MagicMock(spec=NoteBackedMindMapService)
mind_maps.list_mind_maps = AsyncMock(return_value=[])
notebooks = MagicMock()
notebooks.get_source_ids = AsyncMock(return_value=[])
return ArtifactsAPI(
rpc=core,
drain=core,
lifecycle=core,
notebooks=notebooks,
mind_maps=mind_maps,
note_service=MagicMock(spec=NoteService),
)
def _make_notes_api() -> NotesAPI:
from tests._fixtures.fake_core import make_fake_core
# ``None`` is the empty-notebook payload (a notebook with no notes); it is
# the realistic miss shape that ``fetch_note_rows`` resolves to ``[]``. A
# truthy non-list payload would now raise ``DecodingError`` as drift (#1344),
# so it can no longer stand in for "empty".
core = make_fake_core(rpc_call=AsyncMock(return_value=None))
note_service = NoteService(core)
mind_maps = NoteBackedMindMapService(note_service)
return NotesAPI(notes=note_service, mind_maps=mind_maps)
def _make_mind_maps_api() -> MindMapsAPI:
mind_maps = MagicMock(spec=NoteBackedMindMapService)
mind_maps.list_mind_maps = AsyncMock(return_value=[])
artifacts = MagicMock()
artifacts.list = AsyncMock(return_value=[])
notebooks = MagicMock()
return MindMapsAPI(
rpc=MagicMock(),
mind_maps=mind_maps,
artifacts=artifacts,
notebooks=notebooks,
)
def _make_labels_api() -> LabelsAPI:
# ``_arrange_list_miss`` overrides ``api.list`` before any RPC path is reached
# (``labels.get`` scans ``self.list``), so the rpc collaborator and
# ``list_sources`` are never called on the miss path.
return LabelsAPI(MagicMock(), list_sources=AsyncMock(return_value=[]))
def _make_notebooks_api() -> NotebooksAPI:
from tests._fixtures.fake_core import make_fake_core
# An empty/degenerate GET_NOTEBOOK payload is the unknown-id shape that
# ``notebooks.get`` post-validates into ``NotebookNotFoundError`` — so this
# factory is already arranged for a miss (see ``_arrange_notebooks_miss``).
core = make_fake_core(rpc_call=AsyncMock(return_value=[[]]))
return NotebooksAPI(core.rpc_executor, sources_api=MagicMock())
def _arrange_list_miss(api: object) -> None:
"""Force a miss for the four non-``notebooks`` namespaces.
``sources`` / ``artifacts`` / ``mind_maps`` resolve a single ``get`` by
scanning ``self.list(...)``, so stubbing ``list`` to ``[]`` is a uniform,
backend-agnostic miss (the same lever the existing per-namespace tests pull).
``notes`` is the exception: ``NotesAPI.get_or_none`` resolves through
``_get_all_notes_and_mind_maps`` → ``fetch_note_rows``, **not** ``self.list``,
so the assigned ``api.list`` stub is a harmless no-op for it. The notes miss
comes from its factory (``_make_notes_api``) wiring a fake core whose
``rpc_call`` returns ``None`` — the empty-notebook payload that
``fetch_note_rows`` resolves to ``[]`` — so the ``get`` still misses. Keeping
one shared arranger across all four rows is deliberate: it stays a single
table lever even though one namespace reaches the empty result by a different
internal path.
"""
api.list = AsyncMock(return_value=[]) # type: ignore[attr-defined]
def _arrange_notebooks_miss(api: object) -> None:
"""Notebooks already returns the degenerate payload from its factory.
``notebooks.get`` validates the RPC payload directly (it does not scan
``list``); ``_make_notebooks_api`` wires the empty payload, so no further
arrangement is needed. Kept explicit so every row carries an arranger and
the miss setup is never an implicit factory side effect that a reader misses.
"""
return None
@dataclass(frozen=True)
class LookupCase:
"""One namespace's miss-contract row — the unit the flip edits.
Attributes:
namespace: Public client attribute name (keys this row to the static
``LOOKUP_NAMESPACES`` set in ``test_public_api_contract.py``).
factory: Builds the backing API via constructor injection only.
arrange_miss: Configures the built instance to yield a genuine miss.
get_args: Positional args for ``get`` / ``get_or_none`` (per-arity).
resource: Singular resource name. Load-bearing in the warn-runway
assertion: the miss warning must name ``<resource>s.get()``, which is
what distinguishes a correct warning from a *wrong-resource* one
(the exact #1358-class bug). Also makes failures self-describing.
not_found_error: The ``*NotFoundError`` ``get`` raises **after** the
#1247 flip; asserted today only for already-flipped rows.
get_warns: ``True`` while ``get`` is in the warn-runway (warns + returns
``None`` on a miss); ``False`` once it raises ``not_found_error``.
**The single field the #1247 flip toggles per namespace.**
"""
namespace: str
factory: Callable[[], object]
arrange_miss: Callable[[object], None]
get_args: tuple[str, ...]
resource: str
not_found_error: type[Exception]
get_warns: bool
# The flip-durable table. ``get_warns=True`` is the warn-runway state #1247 will
# flip to ``False`` (a single per-row edit, mirroring ``GET_OPTIONAL_EXEMPTIONS``
# shrinking in the static gate). ``notebooks`` ships ``get_warns=False`` today —
# it already raises — so the post-flip ``pytest.raises`` branch is exercised on
# every run and cannot bit-rot before #1247 lands.
LOOKUP_CASES: tuple[LookupCase, ...] = (
LookupCase(
namespace="notebooks",
factory=_make_notebooks_api,
arrange_miss=_arrange_notebooks_miss,
get_args=("nb_missing",),
resource="notebook",
not_found_error=NotebookNotFoundError,
get_warns=False, # already flipped: notebooks.get raises today
),
LookupCase(
namespace="sources",
factory=_make_sources_api,
arrange_miss=_arrange_list_miss,
get_args=("nb_1", "missing"),
resource="source",
not_found_error=SourceNotFoundError,
get_warns=False, # flipped: raises *NotFoundError on a miss (#1247)
),
LookupCase(
namespace="artifacts",
factory=_make_artifacts_api,
arrange_miss=_arrange_list_miss,
get_args=("nb_1", "missing"),
resource="artifact",
not_found_error=ArtifactNotFoundError,
get_warns=False, # flipped: raises *NotFoundError on a miss (#1247)
),
LookupCase(
namespace="notes",
factory=_make_notes_api,
arrange_miss=_arrange_list_miss,
get_args=("nb_1", "missing"),
resource="note",
not_found_error=NoteNotFoundError,
get_warns=False, # flipped: raises *NotFoundError on a miss (#1247)
),
LookupCase(
namespace="mind_maps",
factory=_make_mind_maps_api,
arrange_miss=_arrange_list_miss,
get_args=("nb_1", "missing"),
resource="mind_map",
not_found_error=MindMapNotFoundError,
get_warns=False, # flipped: raises *NotFoundError on a miss (#1247)
),
LookupCase(
namespace="labels",
factory=_make_labels_api,
arrange_miss=_arrange_list_miss,
get_args=("nb_1", "missing"),
resource="label",
not_found_error=LabelNotFoundError,
get_warns=False, # v0.8.0: labels.get raises LabelNotFoundError on a miss
),
)
_CASES_BY_ID = [pytest.param(case, id=case.namespace) for case in LOOKUP_CASES]
def test_no_namespace_remains_on_the_warn_runway() -> None:
"""#1247 has landed: every namespace get() raises on a miss, so no
LookupCase may carry get_warns=True (flipping one back fails here)."""
warned = [c.namespace for c in LOOKUP_CASES if c.get_warns]
assert warned == [], (
f"These namespaces still claim the get()-warn runway (get_warns=True): "
f"{warned}. After #1247 every get() raises *NotFoundError on a miss."
)
def _build_missing(case: LookupCase) -> object:
"""Build the backing API and arrange it to yield a miss."""
api = case.factory()
case.arrange_miss(api)
return api
# ---------------------------------------------------------------------------
# The two error-contract modes the miss path is exercised under
#
# ``NOTEBOOKLM_FUTURE_ERRORS`` is retired in v0.8.0 and ignored. Both ``get``
# test methods still run under set/unset modes so the matrix verifies behavior
# is identical with the compatibility env var present or absent.
# ---------------------------------------------------------------------------
_FUTURE_MODES = [
pytest.param(False, id="future-off"),
pytest.param(True, id="future-on"),
]
@pytest.fixture
def _apply_future_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> bool:
"""Set/clear the retired ``NOTEBOOKLM_FUTURE_ERRORS`` env var."""
# Hermetic both ways: the future-off branch asserts a DeprecationWarning
# fires, so a parent process exporting NOTEBOOKLM_QUIET_DEPRECATIONS=1 would
# otherwise silence the warn path and fail the warn-runway rows. Clear it so
# the matrix is independent of the ambient environment.
monkeypatch.delenv("NOTEBOOKLM_QUIET_DEPRECATIONS", raising=False)
future_on: bool = request.param
if future_on:
monkeypatch.setenv("NOTEBOOKLM_FUTURE_ERRORS", "1")
else:
monkeypatch.delenv("NOTEBOOKLM_FUTURE_ERRORS", raising=False)
return future_on
# ---------------------------------------------------------------------------
# The table is pinned to the static gate's lookup set
# ---------------------------------------------------------------------------
def test_table_covers_all_lookup_namespaces() -> None:
"""Every namespace with the ``get`` / ``get_or_none`` pair has a behavioural row.
Pins this behavioural table to the static gate's ``LOOKUP_NAMESPACES`` so a
namespace can never gain (or rename away) the lookup pair without its miss
contract being covered here too — the static and behavioural halves of the
Tier-1 floor stay in lock-step.
"""
from tests.unit.test_public_api_contract import LOOKUP_NAMESPACES
covered = {case.namespace for case in LOOKUP_CASES}
assert covered == set(LOOKUP_NAMESPACES), (
f"behavioural LOOKUP_CASES cover {sorted(covered)}, but the static gate "
f"pins LOOKUP_NAMESPACES = {sorted(LOOKUP_NAMESPACES)}; add/remove a row."
)
# ---------------------------------------------------------------------------
# get() — the warn-runway / raise contract (the field #1247 flips)
# ---------------------------------------------------------------------------
class TestGetMissContract:
@pytest.mark.asyncio
@pytest.mark.parametrize("_apply_future_errors", _FUTURE_MODES, indirect=True)
@pytest.mark.parametrize("case", _CASES_BY_ID)
async def test_get_on_miss_warns_or_raises(
self, case: LookupCase, _apply_future_errors: bool
) -> None:
"""``get(<missing>)`` raises after #1247, with the retired env var ignored."""
future_on = _apply_future_errors
api = _build_missing(case)
if case.get_warns and not future_on:
# Warn-runway: a DeprecationWarning fires AND None comes back.
with pytest.warns(DeprecationWarning) as record:
result = await api.get(*case.get_args) # type: ignore[attr-defined]
assert result is None, f"{case.namespace}.get must return None on a miss (warn-runway)"
# Tie the warning to *this* namespace's resource, not just any
# DeprecationWarning: the message must name both ``<resource>s.get()``
# and the matching ``*NotFoundError``. This is what catches the exact
# #1358-class bug — a get() that warns, but with the wrong resource
# (e.g. mind_maps emitting the source warning) — which a bare
# ``pytest.warns(DeprecationWarning)`` would wave through.
assert len(record) == 1, (
f"{case.namespace}.get must emit exactly one DeprecationWarning on a miss"
)
message = str(record[0].message)
assert f"{case.resource}s.get()" in message, (
f"{case.namespace}.get warning must name '{case.resource}s.get()'; got: {message!r}"
)
assert case.not_found_error.__name__ in message, (
f"{case.namespace}.get warning must name {case.not_found_error.__name__}; "
f"got: {message!r}"
)
else:
# Post-flip: a miss raises the namespace's *NotFoundError, and no
# DeprecationWarning may fire on the raising path.
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
with pytest.raises(case.not_found_error):
await api.get(*case.get_args) # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# get_or_none() — the sanctioned silent optional-lookup (invariant across the flip)
# ---------------------------------------------------------------------------
class TestGetOrNoneMissContract:
@pytest.mark.asyncio
@pytest.mark.parametrize("_apply_future_errors", _FUTURE_MODES, indirect=True)
@pytest.mark.parametrize("case", _CASES_BY_ID)
async def test_get_or_none_on_miss_is_silent_and_none(
self, case: LookupCase, _apply_future_errors: bool
) -> None:
"""Public ``get_or_none(<missing>)`` returns ``None`` with NO DeprecationWarning.
This contract is invariant across the #1247 flip — ``get_or_none`` is the
sanctioned ``None``-on-miss path for every namespace, before and after
``get`` starts raising — so it is asserted unconditionally for all rows.
It is also invariant under the retired ``NOTEBOOKLM_FUTURE_ERRORS`` env
var, which is ignored in v0.8.0.
"""
api = _build_missing(case)
with warnings.catch_warnings():
# Escalate so any self-warn from the public get_or_none path is a
# hard failure (the library must never trip its own get() deprecation).
warnings.simplefilter("error", DeprecationWarning)
result = await api.get_or_none(*case.get_args) # type: ignore[attr-defined]
assert result is None, f"{case.namespace}.get_or_none must return None on a miss"