teng-lin--notebooklm-py
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
220 行
8.2 KiB
Python
220 行
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Re-encode a recorded cassette so its response body is gzip-compressed
|
|
and the response advertises ``Content-Encoding: gzip``.
|
|
|
|
Why this exists
|
|
---------------
|
|
``decode_compressed_response=True`` in :mod:`tests.vcr_config` strips
|
|
``Content-Encoding`` from the headers on record (after VCR transparently
|
|
gunzips the body), so 89/89 cassettes in ``tests/cassettes/`` carry NO
|
|
encoding header and a plaintext body. That hid issue #769 — every RPC
|
|
failed in production with ``Error -3 while decompressing data: incorrect
|
|
header check`` because :func:`notebooklm._streaming_post.stream_post_with_size_cap`
|
|
rebuilt the response with the upstream ``Content-Encoding: gzip`` header
|
|
attached to *already-decoded* body bytes, triggering a second gzip-decode
|
|
inside :class:`httpx.Response.__init__`. Cassettes that don't carry the
|
|
header on replay simply skip that branch and pass.
|
|
|
|
This script ports a cassette to the production wire shape: gzip the
|
|
response body, restore the ``Content-Encoding: gzip`` header, and strip
|
|
the now-meaningless ``Transfer-Encoding`` and ``Content-Length`` headers
|
|
(the originals described the chunked / decoded wire form, not the gzipped
|
|
buffer we hand to vcrpy on replay).
|
|
|
|
Run from the repo root::
|
|
|
|
uv run python tests/scripts/inject_gzip_into_cassette.py \\
|
|
tests/cassettes/artifacts_revise_slide.yaml \\
|
|
tests/cassettes/gzip_coverage/artifacts_revise_slide_gzipped.yaml
|
|
|
|
Idempotent: re-running on a cassette that already advertises
|
|
``Content-Encoding: gzip`` rewrites it from scratch using the gzipped
|
|
body that vcrpy has already deserialized to ``bytes``, so a second pass
|
|
produces a byte-identical file.
|
|
|
|
Library use:
|
|
Other tooling can ``from tests.scripts.inject_gzip_into_cassette
|
|
import inject_gzip_into_cassette`` and pass a cassette dict in
|
|
memory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gzip
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
# Use the SAFE loader: cassettes are arbitrary YAML on disk and a malicious
|
|
# ``!!python/object/apply`` tag would execute code under the full Loader.
|
|
# ``!!binary`` (the encoding we rely on for gzipped bodies) is part of the
|
|
# core YAML schema so it works under ``CSafeLoader`` / ``SafeLoader``.
|
|
try:
|
|
from yaml import CSafeDumper as Dumper
|
|
from yaml import CSafeLoader as Loader
|
|
except ImportError: # pragma: no cover — libyaml is bundled with PyYAML wheels
|
|
from yaml import SafeDumper as Dumper # type: ignore[assignment]
|
|
from yaml import SafeLoader as Loader
|
|
|
|
|
|
# Headers stripped from the response when we rewrite the body. The recorded
|
|
# Transfer-Encoding / Content-Length values described the chunked / decoded
|
|
# wire form; once we re-gzip the body, neither matches reality and leaving
|
|
# them in confuses httpx on replay. Content-Encoding is included so the
|
|
# rewrite always re-adds the canonical-cased header regardless of how the
|
|
# cassette was originally recorded.
|
|
_STRIP_RESPONSE_HEADERS = frozenset(
|
|
{
|
|
"transfer-encoding",
|
|
"content-length",
|
|
"content-encoding",
|
|
}
|
|
)
|
|
|
|
_CONTENT_ENCODING_HEADER = "Content-Encoding"
|
|
|
|
|
|
def _has_content_encoding(headers: dict[str, list[str]]) -> bool:
|
|
"""Case-insensitive check for ``Content-Encoding`` in a cassette
|
|
header dict."""
|
|
return any(k.lower() == "content-encoding" for k in headers)
|
|
|
|
|
|
def _coerce_body_to_bytes(body_string: Any) -> bytes:
|
|
"""Normalize the recorded ``response.body.string`` to ``bytes``.
|
|
|
|
PyYAML deserializes ``!!binary`` blobs as :class:`bytes` and plain
|
|
block scalars as :class:`str`. The cassette format permits either,
|
|
so accept both and return raw bytes.
|
|
"""
|
|
if isinstance(body_string, bytes):
|
|
return body_string
|
|
if isinstance(body_string, str):
|
|
return body_string.encode("utf-8")
|
|
raise TypeError(
|
|
f"Unexpected response.body.string type {type(body_string).__name__}; expected bytes or str."
|
|
)
|
|
|
|
|
|
def _inject_into_response(response: dict[str, Any]) -> bool:
|
|
"""Mutate ``response`` in place so the body is gzip-compressed and
|
|
``Content-Encoding: gzip`` is set.
|
|
|
|
Returns ``True`` when the body was (re)written; ``False`` only when
|
|
the response has no ``body.string`` to process (missing key or
|
|
``None``). A response that already advertises ``Content-Encoding``
|
|
is gunzipped first and then re-gzipped from the decoded form so the
|
|
rewrite is idempotent — still a rewrite, still ``True``.
|
|
"""
|
|
body = response.get("body") or {}
|
|
if "string" not in body:
|
|
return False
|
|
|
|
raw = body["string"]
|
|
if raw is None:
|
|
return False
|
|
|
|
body_bytes = _coerce_body_to_bytes(raw)
|
|
|
|
headers = response.setdefault("headers", {})
|
|
already_encoded = _has_content_encoding(headers)
|
|
|
|
if already_encoded:
|
|
# The body on disk is already gzipped — gunzip first so the
|
|
# rewrite produces a byte-identical file on a second pass.
|
|
try:
|
|
body_bytes = gzip.decompress(body_bytes)
|
|
except OSError as exc: # pragma: no cover — defensive
|
|
raise ValueError(
|
|
"Response already advertises Content-Encoding but body is not valid gzip."
|
|
) from exc
|
|
|
|
# ``gzip.compress`` writes one byte that varies across Python releases
|
|
# (the OS field at offset 9): 3.10 picks an OS-dependent constant
|
|
# while 3.11+ pins it to ``0xff`` (unknown). Pin ``mtime=0`` and
|
|
# overwrite the OS field so the same source cassette produces a
|
|
# byte-identical artifact under every Python in the CI matrix
|
|
# (``3.10`` – ``3.14``). ``0xff`` is the documented "unknown" value
|
|
# from RFC 1952 §2.3.1 and round-trips through every gzip decoder.
|
|
gzipped = bytearray(gzip.compress(body_bytes, mtime=0))
|
|
gzipped[9] = 0xFF
|
|
body["string"] = bytes(gzipped)
|
|
|
|
# Drop any case-variant of the headers in _STRIP_RESPONSE_HEADERS
|
|
# (including Content-Encoding) so the rewrite re-adds the canonical
|
|
# header name below.
|
|
for key in list(headers):
|
|
if key.lower() in _STRIP_RESPONSE_HEADERS:
|
|
del headers[key]
|
|
headers[_CONTENT_ENCODING_HEADER] = ["gzip"]
|
|
|
|
return True
|
|
|
|
|
|
def inject_gzip_into_cassette(cassette: dict[str, Any]) -> int:
|
|
"""Rewrite every batchexecute-style response in ``cassette`` to be
|
|
gzip-encoded.
|
|
|
|
Only responses whose request is a POST to a path containing
|
|
``batchexecute`` are touched — those are the ones that flow through
|
|
:func:`notebooklm._streaming_post.stream_post_with_size_cap` and
|
|
exercise the rebuild step that bit #769. SPA-shell GET responses are
|
|
left alone so the cassette diff stays focused.
|
|
|
|
Returns the number of responses that were rewritten.
|
|
"""
|
|
rewritten = 0
|
|
for interaction in cassette.get("interactions", []) or []:
|
|
request = interaction.get("request") or {}
|
|
if request.get("method", "").upper() != "POST":
|
|
continue
|
|
uri = request.get("uri", "") or ""
|
|
if "batchexecute" not in uri:
|
|
continue
|
|
response = interaction.get("response") or {}
|
|
if _inject_into_response(response):
|
|
rewritten += 1
|
|
return rewritten
|
|
|
|
|
|
def _load_cassette(path: Path) -> dict[str, Any]:
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
return yaml.load(fh, Loader=Loader)
|
|
|
|
|
|
def _dump_cassette(cassette: dict[str, Any], path: Path) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8") as fh:
|
|
yaml.dump(cassette, fh, Dumper=Dumper)
|
|
|
|
|
|
def _main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("source", type=Path, help="cassette to read")
|
|
parser.add_argument(
|
|
"destination",
|
|
type=Path,
|
|
help="destination path (may equal source for in-place rewrite)",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
cassette = _load_cassette(args.source)
|
|
rewritten = inject_gzip_into_cassette(cassette)
|
|
if rewritten == 0:
|
|
print(
|
|
f"warning: no batchexecute POST interactions found in {args.source}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
_dump_cassette(cassette, args.destination)
|
|
print(f"rewrote {rewritten} response(s) in {args.destination}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(_main(sys.argv[1:]))
|