Files
teng-lin--notebooklm-py/tests/unit/test_from_storage_dual_protocol.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

254 lines
10 KiB
Python

"""Tests for the dual-protocol wrapper returned by ``NotebookLMClient.from_storage``.
In v0.5.0 ``from_storage`` stopped being an async coroutine and started
returning ``_FromStorageContext`` — an awaitable async-context-manager
wrapper that supports BOTH:
- ``async with NotebookLMClient.from_storage(...) as client:`` (canonical,
no ``DeprecationWarning``).
- ``async with await NotebookLMClient.from_storage(...) as client:`` /
bare ``await NotebookLMClient.from_storage(...)`` (legacy, emits
``DeprecationWarning`` naming the v1.0 removal).
This module covers both protocols, the deprecation message text, the
type identity of the yielded value, and the lazy-load behavior that
``_FromStorageContext`` itself does no I/O until used.
"""
from __future__ import annotations
import json
import warnings
from pathlib import Path
import pytest
from pytest_httpx import HTTPXMock
from notebooklm.client import NotebookLMClient, _FromStorageContext
def _write_storage_state(tmp_path: Path) -> Path:
"""Drop a minimal storage_state.json on disk and return the path."""
storage_file = tmp_path / "storage_state.json"
storage_state = {
"cookies": [
{"name": "SID", "value": "dual_sid", "domain": ".google.com"},
{
"name": "__Secure-1PSIDTS",
"value": "dual_1psidts",
"domain": ".google.com",
},
{"name": "HSID", "value": "dual_hsid", "domain": ".google.com"},
],
"origins": [],
}
storage_file.write_text(json.dumps(storage_state))
return storage_file
def _stub_homepage(httpx_mock: HTTPXMock) -> None:
"""Stub the notebooklm homepage GET used to seed the CSRF/session tokens."""
html = '"SNlM0e":"dual_csrf" "FdrFJe":"dual_session"'
httpx_mock.add_response(
url="https://notebooklm.google.com/",
content=html.encode(),
)
class TestCanonicalAsyncWith:
"""The new canonical path: ``async with from_storage(...) as client:``."""
@pytest.mark.asyncio
async def test_async_with_yields_connected_client(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""No ``await`` on the call; ``__aenter__`` builds + opens the client."""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
async with NotebookLMClient.from_storage(path=str(storage_file)) as client:
assert isinstance(client, NotebookLMClient)
assert client.is_connected is True
# The auth dropped onto the wrapper survives the lazy build.
assert client.auth.cookies[("SID", ".google.com", "/")] == "dual_sid"
# Exiting the context manager tears the session down again.
assert client.is_connected is False
@pytest.mark.asyncio
async def test_async_with_emits_no_deprecation_warning(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""The canonical path is the migration target — must be warning-free."""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
async with NotebookLMClient.from_storage(path=str(storage_file)) as client:
_ = client # nothing — just want a clean entry/exit
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations == [], (
f"Canonical `async with from_storage(...)` must not emit "
f"DeprecationWarning; got: {[str(w.message) for w in deprecations]}"
)
@pytest.mark.asyncio
async def test_async_with_forwards_chat_response_cap_override(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""Lazy from-storage build forwards chat response byte cap kwargs."""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
async with NotebookLMClient.from_storage(
path=str(storage_file),
chat_response_max_bytes=123456,
) as client:
assert client.chat._chat_response_max_bytes == 123456
class TestLegacyAwaitForm:
"""The legacy path: ``await from_storage(...)`` / ``async with await ...``."""
@pytest.mark.asyncio
async def test_await_still_returns_client(self, tmp_path: Path, httpx_mock: HTTPXMock) -> None:
"""Bare ``await`` returns a built-but-unentered client (legacy contract)."""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
with pytest.warns(DeprecationWarning, match="removed in v1.0"):
client = await NotebookLMClient.from_storage(path=str(storage_file))
assert isinstance(client, NotebookLMClient)
# ``from_storage`` historically returned an unentered client; the
# legacy path preserves that contract so existing call sites that
# then do ``async with client:`` keep working.
assert client.is_connected is False
@pytest.mark.asyncio
async def test_async_with_await_pipes_through(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""``async with await from_storage(...)`` still works end-to-end."""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
with pytest.warns(DeprecationWarning, match="removed in v1.0"):
unentered = await NotebookLMClient.from_storage(path=str(storage_file))
async with unentered as client:
assert isinstance(client, NotebookLMClient)
assert client.is_connected is True
assert client.is_connected is False
@pytest.mark.asyncio
async def test_deprecation_warning_mentions_v1_removal(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""The warning must name v1.0 so users have a migration target."""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
await NotebookLMClient.from_storage(path=str(storage_file))
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "Expected at least one DeprecationWarning"
msg = str(deprecations[0].message)
assert "removed in v1.0" in msg, (
f"DeprecationWarning must include 'removed in v1.0' for migration clarity; got: {msg!r}"
)
# The message should also point users at the new idiom.
assert "async with" in msg, (
f"DeprecationWarning should point at the canonical idiom; got: {msg!r}"
)
class TestWrapperShape:
"""Properties of the wrapper itself, independent of either protocol."""
def test_from_storage_is_sync(self, tmp_path: Path) -> None:
"""``from_storage`` no longer returns a coroutine — it returns a wrapper.
Crucially, calling it does NOT await anything: it just constructs
``_FromStorageContext`` with the kwargs captured. No I/O happens
until ``__aenter__`` or ``__await__``.
"""
# No event loop needed; calling ``from_storage`` is sync.
wrapper = NotebookLMClient.from_storage(
path=str(tmp_path / "nonexistent.json"),
)
assert isinstance(wrapper, _FromStorageContext)
# No coroutine, so nothing to await — and importantly, the missing
# storage file has NOT been read yet (no FileNotFoundError).
@pytest.mark.asyncio
async def test_yielded_value_is_notebooklmclient(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""Type-check assertion (runtime): ``async with ... as client`` is a client.
``_FromStorageContext.__aenter__`` is typed to return
``NotebookLMClient`` so static type-checkers (mypy / pyright)
narrow the ``as`` binding to ``NotebookLMClient``. We assert the
runtime side here; mypy enforces the static side via the type
annotation on ``__aenter__``.
"""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
async with NotebookLMClient.from_storage(path=str(storage_file)) as client:
assert isinstance(client, NotebookLMClient)
@pytest.mark.asyncio
async def test_build_is_idempotent_when_called_twice(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""Awaiting and then entering the same wrapper must not double-load auth.
Pathological but legal sequence: ``client = await wrapper`` (legacy
path returns the unentered client), then ``async with wrapper as
client2:`` enters via the same wrapper. Both call sites should
see the same ``NotebookLMClient`` instance, and the auth load
should run exactly once.
"""
storage_file = _write_storage_state(tmp_path)
# Only one homepage hit allowed — proves auth load doesn't re-run.
_stub_homepage(httpx_mock)
wrapper = NotebookLMClient.from_storage(path=str(storage_file))
with pytest.warns(DeprecationWarning, match="removed in v1.0"):
client_from_await = await wrapper
async with wrapper as client_from_aenter:
assert client_from_aenter is client_from_await
assert client_from_aenter.is_connected is True
@pytest.mark.asyncio
async def test_async_with_propagates_body_exceptions(
self, tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
"""Exceptions inside the ``async with`` body must propagate.
``_FromStorageContext.__aexit__`` returns ``None`` (falsy), so
Python's async-with protocol re-raises the body exception. We
also confirm the underlying client gets torn down so the test
is asserting end-to-end teardown, not just the wrapper-level
no-suppress contract.
"""
storage_file = _write_storage_state(tmp_path)
_stub_homepage(httpx_mock)
sentinel: list[NotebookLMClient] = []
with pytest.raises(ValueError, match="body raised"):
async with NotebookLMClient.from_storage(path=str(storage_file)) as client:
sentinel.append(client)
assert client.is_connected is True
raise ValueError("body raised")
# Even though the body raised, ``__aexit__`` on the wrapper still
# closed the underlying client.
assert sentinel[0].is_connected is False