Files
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

196 lines
8.3 KiB
Python

"""Regression test for the configurable upload timeouts.
Audit item #20 (`thread-safety-concurrency-audit.md` §20):
Pre-fix, the resumable-upload `_start_resumable_upload` helper and the
finalize POST in `_upload_file_streaming` instantiated
`httpx.AsyncClient(timeout=httpx.Timeout(...))` with hardcoded values
(10.0s connect / 60.0s read for start, 10.0s connect / 300.0s read for
finalize). Callers uploading very large files on slow networks (or
testing with deliberately short timeouts) had no way to override.
Post-fix: `NotebookLMClient.__init__` / `from_storage` accept
`upload_timeout: httpx.Timeout | None = None`, threaded to
`SourcesAPI`, and used at both hardcoded sites. ``None`` (default)
preserves the original hardcoded values for back-compat — defaults
are NOT changed silently.
The test asserts the timeout passed to ``httpx.AsyncClient`` at the
upload sites matches the configured value, and that the default
unchanged when no override is supplied.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import httpx
import pytest
from notebooklm import NotebookLMClient
# Mock-only tests (no real HTTP, no cassette) — opt out of the
# integration-tree enforcement hook in ``tests/integration/conftest.py``.
pytestmark = pytest.mark.allow_no_vcr
@pytest.fixture
def tmp_upload_file(tmp_path: Path) -> Path:
"""Tiny payload for streaming uploads — content doesn't matter."""
path = tmp_path / "upload.txt"
path.write_bytes(b"x" * 256)
return path
def _make_capturing_async_client(
captured: list[httpx.Timeout | None],
) -> type[httpx.AsyncClient]:
"""Build an ``httpx.AsyncClient`` subclass that records the ``timeout`` kwarg.
Returns a class so ``async with httpx.AsyncClient(...)`` continues to
work and ``super().__init__`` builds a fully valid client — replacing
only request *dispatch*, not construction.
Crucially, ``__init__`` always runs to completion for **every**
construction (so all timeouts are captured — the finalize POST asserts
``captured[-1]``), but ``send`` is overridden to raise a transport error
**before any socket I/O**. The upload helpers fail fast and
deterministically regardless of CI network egress: there is no real
``connect()``/``read()`` to race ``pytest-timeout``. (``httpx``'s
``post``/``request`` funnel through ``send``, so this intercepts the
one request path the upload sites use without opening a connection.)
"""
real_async_client = httpx.AsyncClient
class CapturingClient(real_async_client): # type: ignore[misc, valid-type]
def __init__(self, *args: object, **kwargs: object) -> None:
captured.append(kwargs.get("timeout")) # type: ignore[arg-type]
super().__init__(*args, **kwargs) # type: ignore[arg-type]
async def send(self, *args: object, **kwargs: object) -> httpx.Response:
# Fail before any network I/O. ``httpx.ConnectError`` is an
# ``httpx.HTTPError`` subclass, satisfying the ``pytest.raises``
# below without ever opening a socket.
raise httpx.ConnectError("network disabled in upload-timeout test")
return CapturingClient
async def test_custom_upload_timeout_propagates_to_start(
auth_tokens, tmp_upload_file: Path
) -> None:
"""``upload_timeout=Timeout(5.0, read=10.0)`` reaches ``_start_resumable_upload``."""
custom = httpx.Timeout(5.0, read=10.0)
captured: list[httpx.Timeout | None] = []
capturing = _make_capturing_async_client(captured)
async with NotebookLMClient(auth_tokens, upload_timeout=custom) as client:
with patch.object(httpx, "AsyncClient", capturing):
# Call the helper directly — exercises the start-resumable-upload
# site in isolation. The patched client raises in ``send`` before
# opening a socket, so the POST fails fast and network-free; we
# only care that the timeout kwarg was captured at construction.
with pytest.raises((httpx.HTTPError, OSError)):
await client.sources._start_resumable_upload(
notebook_id="nb-test",
filename=tmp_upload_file.name,
file_size=tmp_upload_file.stat().st_size,
source_id="src-test",
content_type="text/plain",
)
assert captured, "Expected at least one httpx.AsyncClient construction"
timeout = captured[0]
assert isinstance(timeout, httpx.Timeout)
assert timeout.connect == 5.0
assert timeout.read == 10.0
async def test_default_upload_timeout_preserves_back_compat_start(auth_tokens) -> None:
"""No override -> ``_start_resumable_upload`` still uses the original 10.0/60.0 hardcode."""
captured: list[httpx.Timeout | None] = []
capturing = _make_capturing_async_client(captured)
async with NotebookLMClient(auth_tokens) as client: # no upload_timeout
with patch.object(httpx, "AsyncClient", capturing):
with pytest.raises((httpx.HTTPError, OSError)):
await client.sources._start_resumable_upload(
notebook_id="nb-test",
filename="dummy.txt",
file_size=256,
source_id="src-test",
content_type="text/plain",
)
assert captured
timeout = captured[0]
assert isinstance(timeout, httpx.Timeout)
assert timeout.connect == 10.0
assert timeout.read == 60.0
async def test_custom_upload_timeout_propagates_to_finalize(
auth_tokens, tmp_upload_file: Path
) -> None:
"""``upload_timeout=Timeout(5.0, read=10.0)`` reaches the finalize POST site."""
custom = httpx.Timeout(5.0, read=10.0)
captured: list[httpx.Timeout | None] = []
capturing = _make_capturing_async_client(captured)
async with NotebookLMClient(auth_tokens, upload_timeout=custom) as client:
with patch.object(httpx, "AsyncClient", capturing):
with pytest.raises((httpx.HTTPError, OSError)):
await client.sources._upload_file_streaming(
upload_url="https://notebooklm.google.com/upload/_/?upload_id=timeout",
file_obj=tmp_upload_file,
)
assert captured, "Expected at least one httpx.AsyncClient construction"
finalize_timeout = captured[-1]
assert isinstance(finalize_timeout, httpx.Timeout)
assert finalize_timeout.connect == 5.0
assert finalize_timeout.read == 10.0
async def test_default_upload_timeout_preserves_back_compat_finalize(
auth_tokens, tmp_upload_file: Path
) -> None:
"""No override -> finalize POST still uses the original 10.0/300.0 hardcode."""
captured: list[httpx.Timeout | None] = []
capturing = _make_capturing_async_client(captured)
async with NotebookLMClient(auth_tokens) as client: # no upload_timeout
with patch.object(httpx, "AsyncClient", capturing):
with pytest.raises((httpx.HTTPError, OSError)):
await client.sources._upload_file_streaming(
upload_url="https://notebooklm.google.com/upload/_/?upload_id=timeout",
file_obj=tmp_upload_file,
)
assert captured
finalize_timeout = captured[-1]
assert isinstance(finalize_timeout, httpx.Timeout)
assert finalize_timeout.connect == 10.0
assert finalize_timeout.read == 300.0
async def test_from_storage_accepts_upload_timeout(monkeypatch, auth_tokens) -> None:
"""``from_storage`` honors the ``upload_timeout`` kwarg and threads it to SourcesAPI."""
from notebooklm import auth as auth_module
async def _fake_from_storage(*args: object, **kwargs: object):
return auth_tokens
monkeypatch.setattr(auth_module.AuthTokens, "from_storage", _fake_from_storage)
custom = httpx.Timeout(7.0, read=14.0)
# Context not entered — only inspecting constructor-level wiring.
# ``NotebookLMClient.__aenter__()`` / ``ClientLifecycle.open()`` never run, so there are no
# background tasks or open sockets to clean up. We use the legacy
# await form to get a built-but-unentered client; suppress the
# DeprecationWarning since this is intentional.
with pytest.warns(DeprecationWarning, match="removed in v1.0"):
client = await NotebookLMClient.from_storage(upload_timeout=custom)
assert client.sources._upload_timeout is custom