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
197 lines
8.0 KiB
Python
197 lines
8.0 KiB
Python
"""Guards against `from_storage` docstring drift.
|
|
|
|
`NotebookLMClient.from_storage` returns an awaitable async-context-manager
|
|
wrapper. The canonical idiom is bare ``async with
|
|
NotebookLMClient.from_storage(...) as client:`` — no ``await``. The
|
|
legacy ``async with await NotebookLMClient.from_storage(...)`` form
|
|
still works (it emits ``DeprecationWarning``; removed in v1.0) and is
|
|
the only form we permit in docstrings that explicitly call themselves
|
|
out as the migration reference.
|
|
|
|
For all other docstring example snippets, the parse-validity check below
|
|
is enough to keep things honest. The historical "must have `await`"
|
|
assertion is gone — that was correct under the old async-coroutine
|
|
``from_storage`` and is now exactly what we discourage.
|
|
|
|
Module docstrings in ``client.py`` and ``__init__.py`` historically
|
|
had the inverse bug (bare ``async with`` against a coroutine); this
|
|
test exists so future drift in either direction breaks a test instead
|
|
of silently shipping a broken example.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from notebooklm.client import NotebookLMClient
|
|
|
|
pytestmark = pytest.mark.repo_lint
|
|
|
|
# Files whose docstrings should not contain the broken example. We parse
|
|
# each file's AST and walk every docstring (module + class + function).
|
|
DOCSTRING_TARGETS = [
|
|
"src/notebooklm/__init__.py",
|
|
"src/notebooklm/client.py",
|
|
"src/notebooklm/_notebooks.py",
|
|
"src/notebooklm/_sources.py",
|
|
"src/notebooklm/_artifacts.py",
|
|
"src/notebooklm/_chat/api.py",
|
|
"src/notebooklm/_research.py",
|
|
"src/notebooklm/_notes.py",
|
|
"src/notebooklm/_settings.py",
|
|
"src/notebooklm/_sharing.py",
|
|
]
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _iter_docstrings(tree: ast.AST):
|
|
"""Yield every docstring found in the AST."""
|
|
for node in ast.walk(tree):
|
|
if isinstance(
|
|
node,
|
|
(ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef),
|
|
):
|
|
doc = ast.get_docstring(node, clean=False)
|
|
if doc:
|
|
yield node, doc
|
|
|
|
|
|
@pytest.mark.parametrize("relpath", DOCSTRING_TARGETS)
|
|
def test_from_storage_examples_prefer_canonical_idiom(relpath: str) -> None:
|
|
"""Docstring examples must not advertise the deprecated `await` form.
|
|
|
|
``async with await NotebookLMClient.from_storage(...) as client:``
|
|
still works in v0.5.0+ but emits ``DeprecationWarning`` (removed in
|
|
v1.0). Docstrings should advertise the new canonical idiom
|
|
``async with NotebookLMClient.from_storage(...) as client:``.
|
|
|
|
Exception: a single example line is exempt if it (or one of the
|
|
two immediately preceding lines, to allow ``Legacy:`` / ``# Legacy
|
|
form (deprecated)`` style headers) explicitly calls itself out as
|
|
the migration reference. The exemption is line-scoped — not
|
|
docstring-scoped — so a stray ``async with await from_storage()``
|
|
elsewhere in the same docstring still trips the guard.
|
|
"""
|
|
path = _repo_root() / relpath
|
|
tree = ast.parse(path.read_text())
|
|
|
|
offenders: list[tuple[str, int, str]] = []
|
|
for node, doc in _iter_docstrings(tree):
|
|
lines = doc.splitlines()
|
|
for idx, line in enumerate(lines, start=1):
|
|
if "from_storage(" not in line or "async with await" not in line:
|
|
continue
|
|
# Check this line and the two lines immediately above it for
|
|
# an explicit "Legacy" / "deprecated" marker. The two-line
|
|
# window matches the common pattern where a header line like
|
|
# "Legacy (deprecated, removed in v1.0):" precedes the
|
|
# example block on its own line (possibly followed by a
|
|
# blank line).
|
|
window_start = max(0, idx - 3) # idx is 1-based; window is the line plus two above
|
|
window = lines[window_start:idx]
|
|
window_text = "\n".join(window).lower()
|
|
if "legacy" in window_text or "deprecated" in window_text:
|
|
continue
|
|
offenders.append((getattr(node, "name", "<module>"), idx, line.strip()))
|
|
|
|
assert not offenders, (
|
|
f"{relpath}: found deprecated `async with await ...from_storage(...)` "
|
|
f"example(s); use the canonical `async with NotebookLMClient.from_storage(...)` "
|
|
f"form instead: {offenders}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("relpath", DOCSTRING_TARGETS)
|
|
def test_docstring_example_lines_parse(relpath: str) -> None:
|
|
"""Every example line that mentions `from_storage()` must be valid Python.
|
|
|
|
We don't execute the snippets — we only parse them. This catches
|
|
typos / unterminated strings / etc. so future drift breaks a test
|
|
instead of silently shipping a broken example.
|
|
"""
|
|
path = _repo_root() / relpath
|
|
tree = ast.parse(path.read_text())
|
|
|
|
for _node, doc in _iter_docstrings(tree):
|
|
for raw in doc.splitlines():
|
|
line = raw.strip()
|
|
if "from_storage(" not in line:
|
|
continue
|
|
# Skip pure-comment lines (e.g. an inline migration reference
|
|
# like ``# async with await NotebookLMClient.from_storage(...) ...``).
|
|
# The comment text isn't a parseable statement and isn't meant
|
|
# to be runnable code.
|
|
if line.startswith("#"):
|
|
continue
|
|
# Skip lines that are prose with embedded rst-quoted code
|
|
# (e.g. "Prefer ``async with from_storage(...) as client:`` idiom.").
|
|
# These aren't standalone code examples — they reference the
|
|
# idiom in running text.
|
|
if line.startswith("``") or "``" in line.split("from_storage(", 1)[0]:
|
|
continue
|
|
# Wrap the line inside an ``async def`` so constructs like
|
|
# ``async with X as y:`` (only legal inside an async function)
|
|
# parse. If the line is itself a compound-statement header
|
|
# (ends with ``:``), give it a ``pass`` body so the wrapper
|
|
# stays syntactically valid.
|
|
if line.rstrip().endswith(":"):
|
|
wrapped = f"async def _():\n {line}\n pass\n"
|
|
else:
|
|
wrapped = f"async def _():\n {line}\n"
|
|
try:
|
|
ast.parse(wrapped)
|
|
except SyntaxError as exc: # pragma: no cover - failure path
|
|
pytest.fail(f"{relpath}: example line {line!r} failed to parse: {exc}")
|
|
|
|
|
|
async def test_from_storage_smoke_constructs_client(tmp_path: Path, httpx_mock: HTTPXMock) -> None:
|
|
"""Smoke test: the documented example shape actually returns a client.
|
|
|
|
Mirrors what the module/class docstrings advertise (canonical idiom):
|
|
|
|
async with NotebookLMClient.from_storage(path=...) as client:
|
|
...
|
|
|
|
We assert that ``async with`` on the wrapper yields a connected
|
|
``NotebookLMClient`` instance — proves the example shape compiles
|
|
and runs end-to-end.
|
|
"""
|
|
storage_file = tmp_path / "storage_state.json"
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "smoke_sid", "domain": ".google.com"},
|
|
{
|
|
"name": "__Secure-1PSIDTS",
|
|
"value": "smoke_1psidts",
|
|
"domain": ".google.com",
|
|
},
|
|
{"name": "HSID", "value": "smoke_hsid", "domain": ".google.com"},
|
|
],
|
|
"origins": [],
|
|
}
|
|
storage_file.write_text(json.dumps(storage_state))
|
|
|
|
# ``from_storage`` performs a token fetch against notebooklm.google.com
|
|
# during the wrapper's lazy ``_build``; serve a minimal stub so the
|
|
# call resolves without touching the network.
|
|
html = '"SNlM0e":"smoke_csrf" "FdrFJe":"smoke_session"'
|
|
httpx_mock.add_response(
|
|
url="https://notebooklm.google.com/",
|
|
content=html.encode(),
|
|
)
|
|
|
|
async with NotebookLMClient.from_storage(path=str(storage_file)) as client:
|
|
assert isinstance(client, NotebookLMClient)
|
|
assert client.is_connected is True
|
|
|
|
# After exiting the context manager, the client is closed.
|
|
assert client.is_connected is False
|