项目文件夹

文件
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

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:]))