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
190 lines
7.3 KiB
Python
190 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Compress the artifacts polling cassette after recording.
|
|
|
|
The live ``artifacts_poll_rename_wait.yaml`` cassette is recorded by
|
|
``tests/integration/test_polling_vcr.py::TestPollingReplay::test_poll_rename_wait``
|
|
running with ``NOTEBOOKLM_VCR_RECORD=1``. The raw recording can include
|
|
50-100+ ``LIST_ARTIFACTS`` interactions because the live API takes 30-60 s
|
|
to finish a flashcard generation and ``wait_for_completion`` polls at
|
|
exponential intervals (1 s → 2 s → 4 s → ... → 5 s cap) until status
|
|
flips to ``COMPLETED``.
|
|
|
|
We do not need every poll response on disk — the replay test only needs
|
|
enough interactions to exercise the polling loop. This script keeps:
|
|
|
|
* The first ``LIST_ARTIFACTS`` interaction — consumed by the explicit
|
|
``poll_status`` call in the test.
|
|
* The next ``KEEP_PROCESSING`` ``LIST_ARTIFACTS`` interactions — exercise
|
|
the ``wait_for_completion`` backoff path while the artifact is still
|
|
``in_progress``.
|
|
* The first ``LIST_ARTIFACTS`` interaction that reports
|
|
``status == COMPLETED`` — terminates the wait loop.
|
|
* The single ``CREATE_ARTIFACT`` and ``RENAME_ARTIFACT`` interactions
|
|
that bookend the chain.
|
|
|
|
Run from the repo root::
|
|
|
|
uv run python tests/scripts/compress_polling_cassette.py
|
|
|
|
The script is idempotent — running it on an already-compressed cassette
|
|
is a no-op because no PROCESSING interactions exist beyond the kept
|
|
prefix. It writes the result back to the same file path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
CASSETTE_PATH = REPO_ROOT / "tests" / "cassettes" / "artifacts_poll_rename_wait.yaml"
|
|
|
|
# RPC IDs (mirror src/notebooklm/rpc/types.py — duplicated here so this script
|
|
# can run without importing the notebooklm package).
|
|
RPCID_CREATE_ARTIFACT = "R7cb6c"
|
|
RPCID_LIST_ARTIFACTS = "gArtLc"
|
|
RPCID_RENAME_ARTIFACT = "rc3d8d"
|
|
|
|
# How many in-progress LIST_ARTIFACTS responses to retain after the first
|
|
# (explicit poll_status) one. 3 in-progress + 1 completed gives the
|
|
# ``wait_for_completion`` loop four iterations to walk, comfortably above
|
|
# the ``MIN_POLLING_INTERACTIONS = 3`` floor enforced by the test.
|
|
KEEP_PROCESSING = 3
|
|
|
|
|
|
def _rpcid(interaction: dict) -> str | None:
|
|
qs = parse_qs(urlparse(interaction["request"]["uri"]).query)
|
|
rpcids = qs.get("rpcids", [])
|
|
return rpcids[0] if rpcids else None
|
|
|
|
|
|
def _is_completed(interaction: dict, task_id: str) -> bool:
|
|
"""True if this LIST_ARTIFACTS response reports the task as COMPLETED.
|
|
|
|
The artifact array shape is roughly::
|
|
|
|
[task_id, title, type, source_ids, status, ...]
|
|
|
|
where ``status == 3`` is ``COMPLETED`` (see ``ArtifactStatus`` in
|
|
``src/notebooklm/rpc/types.py``). We don't fully decode the WRB
|
|
envelope here — a simple substring check on ``,3,`` immediately after
|
|
the task block is robust enough for this compression step.
|
|
"""
|
|
body = interaction["response"]["body"]["string"]
|
|
if task_id not in body:
|
|
return False
|
|
idx = body.find(task_id)
|
|
chunk = body[idx : idx + 400]
|
|
# Status code 3 appears after the source_ids triple. A bare ``,3,`` in
|
|
# the chunk is the COMPLETED marker; PROCESSING is ``,1,``.
|
|
return ",3," in chunk
|
|
|
|
|
|
def _extract_task_id(cassette: dict) -> str:
|
|
"""Pull the task_id back out of the recorded CREATE_ARTIFACT response."""
|
|
for interaction in cassette["interactions"]:
|
|
if _rpcid(interaction) != RPCID_CREATE_ARTIFACT:
|
|
continue
|
|
body = interaction["response"]["body"]["string"]
|
|
# The CREATE_ARTIFACT response carries the task_id as the first
|
|
# quoted string in the inner JSON. We scan from the wrb.fr envelope
|
|
# for the first ``\"...\"`` token. The inner JSON is itself a
|
|
# JSON-encoded string, so quote characters are backslash-escaped:
|
|
# ``[["wrb.fr","R7cb6c","[[\"<task_id>\", ...`` (two leading ``[``)
|
|
# or ``[\"<task_id>\"`` depending on the response shape — both
|
|
# start the same way once we look past the leading ``[`` runs.
|
|
envelope = '"' + RPCID_CREATE_ARTIFACT + '","'
|
|
env_idx = body.find(envelope)
|
|
if env_idx == -1:
|
|
continue
|
|
# First escaped quote after the envelope opens the inner JSON
|
|
# string. The task_id is whatever sits between that ``\"`` and the
|
|
# next ``\"``.
|
|
first_quote = body.find('\\"', env_idx)
|
|
if first_quote == -1:
|
|
continue
|
|
start = first_quote + 2 # skip the ``\"`` escape
|
|
end = body.find('\\"', start)
|
|
if end == -1:
|
|
continue
|
|
return body[start:end]
|
|
raise RuntimeError(
|
|
"Could not locate task_id in CREATE_ARTIFACT response — has the "
|
|
"response shape drifted? Re-record and inspect the raw cassette."
|
|
)
|
|
|
|
|
|
def compress(cassette_path: Path = CASSETTE_PATH) -> tuple[int, int]:
|
|
"""Compress ``cassette_path`` in place. Returns ``(before, after)`` counts."""
|
|
with cassette_path.open(encoding="utf-8") as fh:
|
|
cassette = yaml.safe_load(fh)
|
|
|
|
interactions = cassette["interactions"]
|
|
before = len(interactions)
|
|
|
|
task_id = _extract_task_id(cassette)
|
|
|
|
create_idx: int | None = None
|
|
rename_idx: int | None = None
|
|
list_processing: list[int] = []
|
|
list_completed: int | None = None
|
|
|
|
for i, interaction in enumerate(interactions):
|
|
rpc = _rpcid(interaction)
|
|
if rpc == RPCID_CREATE_ARTIFACT and create_idx is None:
|
|
create_idx = i
|
|
elif rpc == RPCID_RENAME_ARTIFACT and rename_idx is None:
|
|
rename_idx = i
|
|
elif rpc == RPCID_LIST_ARTIFACTS:
|
|
if list_completed is None and _is_completed(interaction, task_id):
|
|
list_completed = i
|
|
elif list_completed is None:
|
|
list_processing.append(i)
|
|
|
|
if create_idx is None:
|
|
raise RuntimeError("No CREATE_ARTIFACT interaction found in cassette")
|
|
if rename_idx is None:
|
|
raise RuntimeError("No RENAME_ARTIFACT interaction found in cassette")
|
|
if list_completed is None:
|
|
raise RuntimeError("No COMPLETED LIST_ARTIFACTS interaction found in cassette")
|
|
|
|
# Keep the first (1 + KEEP_PROCESSING) processing polls plus the completed one.
|
|
# The first one is consumed by the explicit poll_status call in the test;
|
|
# the rest drive wait_for_completion's iteration loop.
|
|
keep_processing = list_processing[: 1 + KEEP_PROCESSING]
|
|
|
|
keep_indices = sorted({create_idx, *keep_processing, list_completed, rename_idx})
|
|
cassette["interactions"] = [interactions[i] for i in keep_indices]
|
|
|
|
with cassette_path.open("w", encoding="utf-8") as fh:
|
|
yaml.safe_dump(
|
|
cassette,
|
|
fh,
|
|
default_flow_style=False,
|
|
allow_unicode=True,
|
|
sort_keys=False,
|
|
)
|
|
|
|
return before, len(cassette["interactions"])
|
|
|
|
|
|
def main() -> int:
|
|
if not CASSETTE_PATH.exists():
|
|
print(f"ERROR: cassette not found at {CASSETTE_PATH}", file=sys.stderr)
|
|
print(
|
|
"Record it first with: NOTEBOOKLM_VCR_RECORD=1 uv run pytest "
|
|
"tests/integration/test_polling_vcr.py",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
before, after = compress()
|
|
print(f"Compressed {CASSETTE_PATH.name}: {before} -> {after} interactions")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|