Files
teng-lin--notebooklm-py/tests/unit/test_composition_primitives.py
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

545 lines
21 KiB
Python

"""Tests for client-owned composition primitives.
Covers the client-owned composition helpers in :mod:`notebooklm._runtime.init`:
- :class:`notebooklm._runtime.init.ClientInternals` dataclass
- :func:`notebooklm._runtime.init.compose_client_internals`
- ``ClientComposed.bind_*`` write-once setters
- ``ClientComposed`` required-property guards
The redundant ``resolve_seam_defaults`` resolver was removed in issue
#1327 — ``compose_client_internals`` resolves seams directly via
``resolve_client_seams`` + ``_resolve_async_client_factory``, so the
parallel dict-shaped resolver had no production caller.
Session-elimination Phase 3 leaves ``NotebookLMClient`` as both composition
root and public surface; all composition runtime state belongs to
``ClientComposed`` or the client-owned collaborator bundle.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import pytest
from notebooklm._client_composed import ClientComposed
from notebooklm._client_seams import ClientSeams
from notebooklm._runtime.init import (
ClientInternals,
compose_client_internals,
)
from notebooklm.auth import AuthTokens
from notebooklm.client import NotebookLMClient
from tests._helpers.client_factory import build_client_shell_for_tests
def _make_auth() -> AuthTokens:
"""Build a minimal :class:`AuthTokens` for composition tests.
Cookies / CSRF / session id are sentinel values — these tests never
hit the network; they only need a token shape that passes
:func:`_validate_required_cookies`.
"""
return AuthTokens(
cookies={"SID": "x", "__Secure-1PSIDTS": "y"},
csrf_token="csrf",
session_id="sid",
)
# ---------------------------------------------------------------------------
# compose_client_internals — client-owned composition root
# ---------------------------------------------------------------------------
def test_compose_client_internals_returns_client_internals() -> None:
"""The helper returns collaborators + executor while binding ``ClientComposed``."""
holder = ClientComposed()
internals = compose_client_internals(auth=_make_auth(), composed=holder)
assert isinstance(internals, ClientInternals)
assert holder.executor is internals.executor
assert holder.runtime_collaborators is internals.collaborators
assert holder.transport is internals.executor._transport
assert holder.chain_host._transport is holder.transport
assert holder.chain_builder is not None
assert len(holder.middlewares) == 7
def test_shell_helpers_carry_client_holders() -> None:
"""Client shell helpers mirror production holder attributes."""
client = build_client_shell_for_tests(auth=_make_auth(), max_concurrent_rpcs=3)
assert isinstance(client._seams, ClientSeams)
assert isinstance(client._composed, ClientComposed)
assert client._composed.max_concurrent_rpcs == 3
assert client._composed.runtime_collaborators is client._collaborators
assert client._composed.executor is client._rpc_executor
def test_notebooklm_client_initializes_client_holders() -> None:
"""Production clients own the same holder shape returned by composition."""
client = NotebookLMClient(_make_auth(), max_concurrent_rpcs=2)
assert isinstance(client._seams, ClientSeams)
assert isinstance(client._composed, ClientComposed)
assert client._composed.runtime_collaborators is client._collaborators
assert client._composed.max_concurrent_rpcs == 2
assert client._composed.executor is client._rpc_executor
assert client._composed.transport is client._rpc_executor._transport
def test_invalid_max_concurrent_rpcs_rejected_before_zero_cap_semaphore() -> None:
"""Production and test construction reject invalid caps before composition use."""
auth = _make_auth()
with pytest.raises(ValueError, match="max_concurrent_rpcs must be >= 1, got 0"):
NotebookLMClient(auth, max_concurrent_rpcs=0)
with pytest.raises(ValueError, match="max_concurrent_rpcs must be >= 1, got 0"):
build_client_shell_for_tests(auth, max_concurrent_rpcs=0)
def test_prebuilt_client_composed_cap_must_match_constructor_cap() -> None:
"""A supplied holder cannot silently diverge from validated constructor args."""
holder = ClientComposed(max_concurrent_rpcs=5)
with pytest.raises(
ValueError,
match=(
r"composed\.max_concurrent_rpcs must match max_concurrent_rpcs "
r"\(got composed\.max_concurrent_rpcs=5, max_concurrent_rpcs=10\)"
),
):
compose_client_internals(
auth=_make_auth(),
max_concurrent_rpcs=10,
composed=holder,
)
def test_compose_client_internals_refuses_synthetic_error_first(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""``_refuse_synthetic_error_outside_test_context`` MUST run before any
other work in :func:`compose_client_internals`.
Pins the same contract as
:mod:`tests.unit.concurrency.test_synthetic_error_transport_guard` —
the guard fires at the *earliest* opportunity. Setting the env var
without ``PYTEST_CURRENT_TEST`` must raise from the helper before the
seam resolution, validation, or collaborator construction can run.
"""
monkeypatch.setenv("NOTEBOOKLM_VCR_RECORD_ERRORS", "5xx")
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
with (
caplog.at_level(logging.WARNING, logger="notebooklm._core"),
pytest.raises(RuntimeError, match="NOTEBOOKLM_VCR_RECORD_ERRORS"),
):
compose_client_internals(auth=_make_auth())
def test_compose_client_internals_preserves_late_binding_for_decode_response() -> None:
"""Post-construction ``seams.decode_response = rebound`` MUST still
steer the executor's decode path.
Pins the lambda-closure contract documented in the plan: the executor
is wired with ``decode_response=lambda *a, **kw: seams.decode_response(*a, **kw)``
so that test reassignments after construction continue to take effect.
"""
seams = ClientSeams(
decode_response=lambda *_a, **_kw: None,
sleep=asyncio.sleep,
is_auth_error=lambda _exc: False,
)
internals = compose_client_internals(auth=_make_auth(), seams=seams)
sentinel: list[Any] = []
def rebound(*args: Any, **kwargs: Any) -> str:
"""Recording stand-in for ``seams.decode_response``."""
sentinel.append(("decoded", args, kwargs))
return "rebound-result"
seams.decode_response = rebound
# The executor closure should dispatch through the live attribute,
# not the value frozen at construction time.
result = internals.executor._decode_response("payload", "method-id", allow_null=False)
assert result == "rebound-result"
assert sentinel and sentinel[-1][0] == "decoded"
def test_compose_client_internals_preserves_late_binding_for_is_auth_error() -> None:
"""Post-construction ``seams.is_auth_error = rebound`` MUST still
steer the executor's classifier.
Mirror of the ``decode_response`` test for the auth-error seam.
"""
seams = ClientSeams(
decode_response=lambda *_a, **_kw: None,
sleep=asyncio.sleep,
is_auth_error=lambda _exc: False,
)
internals = compose_client_internals(auth=_make_auth(), seams=seams)
def rebound(exc: Exception) -> bool:
"""Stand-in classifier — treats KeyError as auth-related."""
return isinstance(exc, KeyError)
seams.is_auth_error = rebound
assert internals.executor._is_auth_error(KeyError("auth")) is True
assert internals.executor._is_auth_error(RuntimeError("nope")) is False
def test_compose_client_internals_preserves_late_binding_for_sleep() -> None:
"""Post-construction ``seams.sleep = rebound`` MUST still steer the
executor's backoff path.
"""
seams = ClientSeams(
decode_response=lambda *_a, **_kw: None,
sleep=asyncio.sleep,
is_auth_error=lambda _exc: False,
)
internals = compose_client_internals(auth=_make_auth(), seams=seams)
calls: list[float] = []
async def rebound(delay: float) -> None:
"""Recording stand-in for ``seams.sleep`` (captures delays)."""
calls.append(delay)
seams.sleep = rebound
asyncio.run(internals.executor._sleep(0.25))
assert calls == [0.25]
def test_compose_client_internals_preserves_late_binding_for_refresh_retry_delay() -> None:
"""Post-construction ``chain_host._refresh_retry_delay = X`` MUST be seen
by the executor's ``refresh_retry_delay_provider`` lambda on the next
call.
The integration-test contract is that
``client._composed.chain_host._refresh_retry_delay = 0`` continues
to steer the live chain after construction. The lambda
``refresh_retry_delay_provider=lambda: chain_host._refresh_retry_delay``
re-reads the attribute on every invocation, so this is a live binding,
not a frozen snapshot.
"""
holder = ClientComposed()
internals = compose_client_internals(auth=_make_auth(), composed=holder)
chain_host = holder.chain_host
# The provider lambda must dereference the *current* attribute on
# each call — not the value captured at construction time.
initial = chain_host._refresh_retry_delay
assert internals.executor._refresh_retry_delay_provider() == initial
chain_host._refresh_retry_delay = 0.99
assert internals.executor._refresh_retry_delay_provider() == 0.99
def test_compose_client_internals_executor_timeout_provider_reads_lifecycle() -> None:
"""The executor's ``timeout_provider`` reads from the live
``ClientLifecycle._timeout`` collaborator attribute.
Pins the documented closure shape
``timeout_provider=lambda: collaborators.lifecycle._timeout`` (plan
line 253). A lifecycle-side mutation must surface on the next executor
call without re-binding.
"""
internals = compose_client_internals(auth=_make_auth())
initial = internals.collaborators.lifecycle._timeout
assert internals.executor._timeout_provider() == initial
internals.collaborators.lifecycle._timeout = 99.0
assert internals.executor._timeout_provider() == 99.0
# ---------------------------------------------------------------------------
# ClientComposed write-once binders
# ---------------------------------------------------------------------------
def test_client_composed_executor_binder_raises_on_double_bind() -> None:
holder = ClientComposed()
compose_client_internals(auth=_make_auth(), composed=holder)
with pytest.raises(RuntimeError, match="_executor already bound"):
holder.bind_executor(holder.executor)
def test_client_composed_transport_binder_raises_on_double_bind() -> None:
holder = ClientComposed()
compose_client_internals(auth=_make_auth(), composed=holder)
with pytest.raises(RuntimeError, match="_transport already bound"):
holder.bind_transport(holder.transport)
def test_client_composed_chain_metadata_binder_raises_on_double_bind() -> None:
holder = ClientComposed()
compose_client_internals(auth=_make_auth(), composed=holder)
# Build a sentinel ``WiredMiddleware`` carrying the existing values so
# the rejection comes from the write-once guard, not a missing field.
from notebooklm._runtime.init import WiredMiddleware
wired = WiredMiddleware(
chain_builder=holder.chain_builder,
middlewares=holder.middlewares,
authed_post_chain=holder.chain_host._authed_post_chain,
)
with pytest.raises(RuntimeError, match="_chain_builder already bound"):
holder.bind_chain_metadata(wired)
def test_client_composed_chain_host_binder_raises_on_double_bind() -> None:
holder = ClientComposed()
compose_client_internals(auth=_make_auth(), composed=holder)
with pytest.raises(RuntimeError, match="_chain_host already bound"):
holder.bind_chain_host(holder.chain_host)
def test_client_composed_runtime_collaborators_binder_raises_on_double_bind() -> None:
holder = ClientComposed()
internals = compose_client_internals(auth=_make_auth(), composed=holder)
with pytest.raises(RuntimeError, match="_runtime_collaborators already bound"):
holder.bind_runtime_collaborators(internals.collaborators)
# ---------------------------------------------------------------------------
# ClientComposed required-property guards
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("attr_name", "message"),
[
("transport", "_transport"),
("executor", "_executor"),
("chain_host", "_chain_host"),
("chain_builder", "_chain_builder"),
("middlewares", "_middlewares"),
("runtime_collaborators", "_runtime_collaborators"),
],
)
def test_client_composed_properties_raise_before_binding(attr_name: str, message: str) -> None:
holder = ClientComposed()
with pytest.raises(
RuntimeError,
match=rf"ClientComposed not fully constructed: {message} is None",
):
getattr(holder, attr_name)
# ---------------------------------------------------------------------------
# ClientComposed RPC-semaphore loop-affinity guard / reset (issue #1169)
# ---------------------------------------------------------------------------
def test_get_rpc_semaphore_returns_nullcontext_when_cap_is_none() -> None:
"""``max_concurrent_rpcs=None`` short-circuits to a no-op context.
The affinity guard MUST NOT run on the unbounded opt-out path — there is
no loop-bound primitive to protect, so even an unset binding is fine.
"""
from contextlib import nullcontext
holder = ClientComposed(max_concurrent_rpcs=None)
# No ``set_bound_loop`` call — the None-cap path never touches the guard.
ctx = holder.get_rpc_semaphore()
assert isinstance(ctx, type(nullcontext()))
def test_get_rpc_semaphore_no_binding_is_silent_noop() -> None:
"""An unbound holder builds the semaphore without raising.
Mirrors the sibling primitives: ``assert_bound_loop(None)`` is a silent
no-op so standalone holders (composition / unit fixtures) that never ran
``open()`` keep working.
"""
async def _exercise() -> None:
holder = ClientComposed(max_concurrent_rpcs=2)
# ``_bound_loop`` is None — the guard is a no-op and the semaphore is
# built lazily on this running loop.
async with holder.get_rpc_semaphore():
pass
assert holder._rpc_semaphore is not None
asyncio.run(_exercise())
def test_get_rpc_semaphore_same_loop_use_unaffected() -> None:
"""A holder bound to the running loop acquires its semaphore normally."""
async def _exercise() -> None:
holder = ClientComposed(max_concurrent_rpcs=2)
holder.set_bound_loop(asyncio.get_running_loop())
async with holder.get_rpc_semaphore():
pass
# Same instance is reused across calls on the same loop.
first = holder._rpc_semaphore
async with holder.get_rpc_semaphore():
pass
assert holder._rpc_semaphore is first
asyncio.run(_exercise())
def test_get_rpc_semaphore_cross_loop_raises_actionable_runtime_error() -> None:
"""A holder bound to loop A raises if its semaphore is acquired on loop B.
Two independent ``asyncio.run`` calls give two genuinely distinct loops.
The guard must fire with the shared loop-affinity diagnostic before the
stale ``asyncio.Semaphore`` (bound to the dead loop A) is reused.
"""
holder = ClientComposed(max_concurrent_rpcs=2)
async def _bind_under_loop_a() -> None:
holder.set_bound_loop(asyncio.get_running_loop())
# Construct the semaphore on loop A so loop B would reuse a stale one.
async with holder.get_rpc_semaphore():
pass
asyncio.run(_bind_under_loop_a())
assert holder._rpc_semaphore is not None
async def _acquire_under_loop_b() -> None:
with pytest.raises(RuntimeError, match="bound to a different event loop"):
async with holder.get_rpc_semaphore():
pass
# The actionable second sentence tells users how to fix it.
try:
async with holder.get_rpc_semaphore():
pass
except RuntimeError as exc:
assert "create a new client in the target loop" in str(exc)
else: # pragma: no cover - defensive
raise AssertionError("expected RuntimeError on cross-loop reuse")
asyncio.run(_acquire_under_loop_b())
def test_reset_after_open_discards_lazy_semaphore() -> None:
"""``reset_after_open`` drops the cached semaphore so the next call rebuilds.
This is what lets a client closed on loop A and reopened on loop B build a
fresh semaphore bound to loop B rather than reusing the stale one. The cap
itself is left untouched.
"""
async def _exercise() -> None:
holder = ClientComposed(max_concurrent_rpcs=3)
holder.set_bound_loop(asyncio.get_running_loop())
async with holder.get_rpc_semaphore():
pass
first = holder._rpc_semaphore
assert first is not None
holder.reset_after_open()
assert holder._rpc_semaphore is None
assert holder.max_concurrent_rpcs == 3
async with holder.get_rpc_semaphore():
pass
assert holder._rpc_semaphore is not None
assert holder._rpc_semaphore is not first
asyncio.run(_exercise())
def test_set_bound_loop_none_clears_binding_and_discards_semaphore() -> None:
"""``set_bound_loop(None)`` re-arms the no-op path and drops the stale semaphore."""
async def _exercise() -> None:
holder = ClientComposed(max_concurrent_rpcs=2)
holder.set_bound_loop(asyncio.get_running_loop())
async with holder.get_rpc_semaphore():
pass
assert holder._rpc_semaphore is not None
# Clearing the binding is a loop change (loop -> None), so the cached
# semaphore bound to the old loop is discarded for self-consistency.
holder.set_bound_loop(None)
assert holder._bound_loop is None
assert holder._rpc_semaphore is None
# With the binding cleared the guard is a no-op again.
async with holder.get_rpc_semaphore():
pass
asyncio.run(_exercise())
def test_set_bound_loop_same_loop_keeps_cached_semaphore() -> None:
"""Re-binding to the *same* loop must NOT discard the live semaphore.
Idempotent ``set_bound_loop`` calls with the unchanged loop are a no-op on
the cache — only a genuine loop change invalidates it.
"""
async def _exercise() -> None:
holder = ClientComposed(max_concurrent_rpcs=2)
loop = asyncio.get_running_loop()
holder.set_bound_loop(loop)
async with holder.get_rpc_semaphore():
pass
first = holder._rpc_semaphore
assert first is not None
# Same loop again — the cached semaphore survives.
holder.set_bound_loop(loop)
assert holder._rpc_semaphore is first
asyncio.run(_exercise())
def test_set_bound_loop_different_loop_discards_stale_semaphore() -> None:
"""A loop change via ``set_bound_loop`` alone discards the stale semaphore.
This pins the gemini-flagged self-consistency contract: even without a
matching ``reset_after_open`` call, rebinding to a different loop must
invalidate the semaphore bound to the previous loop so it is never reused.
"""
holder = ClientComposed(max_concurrent_rpcs=2)
async def _bind_and_build_under_loop_a() -> None:
holder.set_bound_loop(asyncio.get_running_loop())
async with holder.get_rpc_semaphore():
pass
asyncio.run(_bind_and_build_under_loop_a())
assert holder._rpc_semaphore is not None
async def _rebind_under_loop_b() -> None:
# set_bound_loop to a genuinely different loop must drop the stale
# semaphore so the next get_rpc_semaphore() rebuilds on loop B.
holder.set_bound_loop(asyncio.get_running_loop())
assert holder._rpc_semaphore is None
async with holder.get_rpc_semaphore():
pass
assert holder._rpc_semaphore is not None
asyncio.run(_rebind_under_loop_b())
def test_client_shell_reads_composition_from_client_composed() -> None:
client = build_client_shell_for_tests(_make_auth())
assert client._rpc_executor is client._composed.executor
assert client._rpc_executor._transport is client._composed.transport
assert client._composed.chain_host._transport is client._composed.transport
assert client._composed.chain_builder._drain_tracker is client._collaborators.drain_tracker
assert client._composed.middlewares[0]._drain_tracker is client._collaborators.drain_tracker
assert client._composed.middlewares[1]._metrics is client._collaborators.metrics