chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ONE-TIME: clear every cookie value in Cookie/Set-Cookie headers, in place.
|
||||
|
||||
Re-scrubs the committed corpus for the name-agnostic cookie leak: cookies that
|
||||
were NOT on the name-anchored allowlist (``_ga`` / ``_ga_<id>`` / ``_gcl_au`` /
|
||||
``AEC`` / ``SEARCH_SAMESITE`` …) kept their REAL values in committed cassettes.
|
||||
This walks every ``tests/cassettes/*.yaml``, parses it with PyYAML to recover
|
||||
each request ``Cookie:`` and response ``Set-Cookie:`` header's LOGICAL value,
|
||||
computes the name-agnostic scrub via ``cassette_patterns.scrub_cookie_header`` /
|
||||
``scrub_set_cookie``, and splices the cleared ``name=value`` pairs back into the
|
||||
RAW text by literal substitution.
|
||||
|
||||
Why literal raw-text substitution (not yaml.dump round-trip): re-dumping the
|
||||
whole file would reformat every body scalar and produce a massive noisy diff.
|
||||
Cookie pairs are verified to never split across a YAML fold boundary in this
|
||||
corpus, so each ``name=realvalue`` substring is contiguous in the raw text and a
|
||||
literal replace to ``name=SCRUBBED`` is exact and formatting-preserving. Bodies
|
||||
and byte-count prefixes are untouched.
|
||||
|
||||
Retained as a historical repair utility; use
|
||||
``tests/scripts/check_cassettes_clean.py`` for ongoing cassette cleanliness
|
||||
checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
try:
|
||||
from yaml import CSafeLoader as Loader
|
||||
except ImportError: # pragma: no cover
|
||||
from yaml import SafeLoader as Loader # type: ignore[assignment]
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_TESTS_DIR = _REPO_ROOT / "tests"
|
||||
_CASSETTE_DIR = _TESTS_DIR / "cassettes"
|
||||
sys.path.insert(0, str(_TESTS_DIR))
|
||||
|
||||
from cassette_patterns import ( # noqa: E402
|
||||
SCRUB_PLACEHOLDERS,
|
||||
find_cookie_leaks,
|
||||
scrub_cookie_header,
|
||||
scrub_set_cookie,
|
||||
)
|
||||
|
||||
|
||||
def _pairs(original: str, scrubbed: str) -> list[tuple[str, str]]:
|
||||
"""Return ``(old_segment, new_segment)`` for each pair the scrub changed.
|
||||
|
||||
Splits the original and scrubbed header value on ``;`` (their segment lists
|
||||
are aligned because scrubbing only rewrites VALUES, never the count/order of
|
||||
segments) and yields the stripped ``name=value`` pairs that differ.
|
||||
"""
|
||||
out: list[tuple[str, str]] = []
|
||||
o_segs = original.split(";")
|
||||
s_segs = scrubbed.split(";")
|
||||
if len(o_segs) != len(s_segs):
|
||||
return out
|
||||
for o, s in zip(o_segs, s_segs, strict=True):
|
||||
o_core = o.strip()
|
||||
s_core = s.strip()
|
||||
if o_core != s_core and o_core:
|
||||
out.append((o_core, s_core))
|
||||
return out
|
||||
|
||||
|
||||
def _rescrub_file(path: Path) -> tuple[bool, int]:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = yaml.load(raw, Loader=Loader)
|
||||
if not isinstance(data, dict):
|
||||
return False, 0
|
||||
|
||||
replacements: list[tuple[str, str]] = []
|
||||
for interaction in data.get("interactions") or []:
|
||||
if not isinstance(interaction, dict):
|
||||
continue
|
||||
req = interaction.get("request") or {}
|
||||
resp = interaction.get("response") or {}
|
||||
for hk, hv in (req.get("headers") or {}).items():
|
||||
if isinstance(hk, str) and hk.lower() == "cookie":
|
||||
vals = hv if isinstance(hv, list) else [hv]
|
||||
for v in vals:
|
||||
if isinstance(v, str):
|
||||
replacements.extend(_pairs(v, scrub_cookie_header(v)))
|
||||
for hk, hv in (resp.get("headers") or {}).items():
|
||||
if isinstance(hk, str) and hk.lower() == "set-cookie":
|
||||
vals = hv if isinstance(hv, list) else [hv]
|
||||
for v in vals:
|
||||
if isinstance(v, str):
|
||||
replacements.extend(_pairs(v, scrub_set_cookie(v)))
|
||||
|
||||
if not replacements:
|
||||
return False, 0
|
||||
|
||||
new_raw = raw
|
||||
for old_seg, new_seg in replacements:
|
||||
# ``old_seg`` is a contiguous ``name=realvalue`` substring (verified to
|
||||
# never split across a fold boundary). Replace ALL occurrences (the same
|
||||
# cookie value recurs across every interaction in a cassette).
|
||||
if old_seg and old_seg in new_raw:
|
||||
new_raw = new_raw.replace(old_seg, new_seg)
|
||||
|
||||
if new_raw == raw:
|
||||
return False, 0
|
||||
path.write_text(new_raw, encoding="utf-8")
|
||||
return True, len(new_raw.encode("utf-8")) - len(raw.encode("utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
targets = [Path(p) for p in argv] if argv else sorted(_CASSETTE_DIR.glob("*.yaml"))
|
||||
changed = 0
|
||||
for path in targets:
|
||||
did, diff = _rescrub_file(path)
|
||||
if did:
|
||||
changed += 1
|
||||
print(f"scrubbed {path.name} ({diff:+d} bytes)")
|
||||
print(f"\nSummary: {changed}/{len(targets)} cassettes re-scrubbed.")
|
||||
|
||||
# Verify: no cookie leak survives in any header of any target.
|
||||
residual = 0
|
||||
parse_failures = 0
|
||||
for path in targets:
|
||||
try:
|
||||
data = yaml.load(path.read_text(encoding="utf-8"), Loader=Loader)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# A cassette we just rewrote no longer parses as YAML — treat that as
|
||||
# a verification failure, not a silent skip, or the script could exit
|
||||
# success after producing an unloadable cassette.
|
||||
parse_failures += 1
|
||||
print(f"FAILED to parse rewritten cassette {path}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
for interaction in data.get("interactions") or []:
|
||||
if not isinstance(interaction, dict):
|
||||
continue
|
||||
req = interaction.get("request") or {}
|
||||
resp = interaction.get("response") or {}
|
||||
for hk, hv in (req.get("headers") or {}).items():
|
||||
if isinstance(hk, str) and hk.lower() == "cookie":
|
||||
for v in hv if isinstance(hv, list) else [hv]:
|
||||
if isinstance(v, str) and find_cookie_leaks(v):
|
||||
residual += len(find_cookie_leaks(v))
|
||||
for hk, hv in (resp.get("headers") or {}).items():
|
||||
if isinstance(hk, str) and hk.lower() == "set-cookie":
|
||||
for v in hv if isinstance(hv, list) else [hv]:
|
||||
if isinstance(v, str) and find_cookie_leaks(v, set_cookie=True):
|
||||
residual += len(find_cookie_leaks(v, set_cookie=True))
|
||||
print(f"Residual cookie leaks after re-scrub: {residual}")
|
||||
if parse_failures:
|
||||
print(f"Unparseable cassettes after re-scrub: {parse_failures}", file=sys.stderr)
|
||||
print(f"(known placeholders: {sorted(SCRUB_PLACEHOLDERS)[:3]} ...)")
|
||||
return 1 if (residual or parse_failures) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Strip internal audit-tracker references from comments, docstrings, and markdown.
|
||||
|
||||
Conservative bulk pass: only drops *parenthetical* ID references that ride
|
||||
alongside prose and a small number of whole-sentence meta-commentary lines
|
||||
that introduce the now-removed ID convention. Inline noun-form refs (e.g.
|
||||
``T7.F2:`` at the start of a comment, ``audit §27 failure #1`` mid-sentence,
|
||||
``Pre-T7.F4``) are intentionally left for targeted hand edits because the
|
||||
surrounding sentence needs rewriting.
|
||||
|
||||
Whitespace is preserved outside the deleted-token regions; the script never
|
||||
collapses runs of whitespace or reformats multi-line constructs.
|
||||
|
||||
The script is run repeatedly across the cleanup phases (Phase 1: src/, docs/,
|
||||
CHANGELOG, pyproject, CLAUDE.md; Phase 2: tests/).
|
||||
Each invocation is restricted to a phase-specific allow-list of file paths so
|
||||
that one phase's run cannot accidentally edit another phase's files.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/_strip_audit_refs.py phase1
|
||||
python scripts/_strip_audit_refs.py phase2
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
T_ID = r"T\d+\.[A-Z][0-9a-z]*"
|
||||
PR_T_ID = r"PR-" + T_ID
|
||||
PHASE_T_ID = r"P\d+\.T\d+"
|
||||
AUDIT_SEC = r"audit §\d+[a-z]?"
|
||||
AUDIT_ROW = r"audit row [A-Z]?\d+[A-Za-z0-9]*"
|
||||
AUDIT_SECTION = r"audit section \d+"
|
||||
# CLI UX audit row IDs (``cli-ux-audit.md``): C1, C2, I1..I16, M1..M5.
|
||||
# Match in parentheticals only; the bare letters are too common to strip
|
||||
# unconditionally.
|
||||
CLI_UX_ROW_ID = r"(?:[ICM]\d+(?:,\s*[ICM]\d+)*)"
|
||||
# Phase markers (e.g. ``F8.T4``) used to coordinate landed-in-arc work.
|
||||
PHASE_F_ID = r"F\d+\.T\d+"
|
||||
|
||||
|
||||
# Parenthetical patterns — order matters: longest/most-specific first.
|
||||
PAREN_PATTERNS: list[tuple[str, str]] = [
|
||||
# Combined inside one parens, with / or , separators
|
||||
(rf" \({T_ID}\s*[/,]\s*{AUDIT_SEC}\)", ""),
|
||||
(rf" \({AUDIT_SEC}\s*[/,]\s*{T_ID}\)", ""),
|
||||
(rf" \({T_ID}\s*[/,]\s*{AUDIT_SECTION}\)", ""),
|
||||
(rf" \({AUDIT_SECTION}\s*[/,]\s*{T_ID}\)", ""),
|
||||
# T + audit-section with extra tail text inside parens
|
||||
(rf" \({T_ID}\s+{AUDIT_SEC}(?:[^()]*)?\)", ""),
|
||||
(rf" \({AUDIT_SEC}\s+{T_ID}(?:[^()]*)?\)", ""),
|
||||
# Audit-section failure-numbered variant: (audit §27 failure #1)
|
||||
(rf" \({AUDIT_SEC}\s+failure\s+#\d+\)", ""),
|
||||
# Standalone audit-row, audit-section, audit-§
|
||||
(rf" \({AUDIT_ROW}(?:[^()]*)?\)", ""),
|
||||
(rf" \({AUDIT_SECTION}\)", ""),
|
||||
(rf" \({AUDIT_SEC}\)", ""),
|
||||
# Standalone T-tier and PR-T (must come last to avoid stealing combined matches)
|
||||
(rf" \({T_ID}\)", ""),
|
||||
(rf" \({PR_T_ID}\)", ""),
|
||||
(rf" \({PHASE_T_ID}\)", ""),
|
||||
(rf" \({PHASE_F_ID}\)", ""),
|
||||
# CLI UX audit row IDs in parentheticals (cli-ux-audit.md):
|
||||
# ``(I1)``, ``(I3, I4)``, ``(C1)``, ``(M2)``, etc.
|
||||
(rf" \({CLI_UX_ROW_ID}\)", ""),
|
||||
# Phase task ID paired with a CLI-UX row ID, with or without leading
|
||||
# ``-`` and with a ``/`` separator: ``(P5.T2 / I7)``, ``(M2 / P5.T3)``.
|
||||
(rf" \({PHASE_T_ID}\s*/\s*{CLI_UX_ROW_ID}\)", ""),
|
||||
(rf" \({CLI_UX_ROW_ID}\s*/\s*{PHASE_T_ID}\)", ""),
|
||||
# Multi-section audit references: ``(audit §§13, 15, 16, 21)``.
|
||||
(r" \(audit §§\d+(?:,\s*\d+)*\)", ""),
|
||||
]
|
||||
|
||||
|
||||
# Whole-sentence patterns that read as meta-commentary about the audit tags.
|
||||
SENTENCE_PATTERNS: list[tuple[str, str]] = [
|
||||
(
|
||||
r"\s*Per-arc audit IDs \([^)]*\) are noted in parentheses on each non-cli-ux entry\.",
|
||||
"",
|
||||
),
|
||||
(
|
||||
r"\s*Audit-row IDs from `\.sisyphus/plans/cli-ux-audit\.md` \([^)]*\) are noted in parentheses on each entry\.",
|
||||
"",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase scopes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PHASE_1_FILES: list[str] = [
|
||||
# src/notebooklm/**/*.py
|
||||
"src/notebooklm/__init__.py",
|
||||
"src/notebooklm/_artifacts.py",
|
||||
"src/notebooklm/_auth/cookie_policy.py",
|
||||
"src/notebooklm/_auth/storage.py",
|
||||
"src/notebooklm/_idempotency.py",
|
||||
"src/notebooklm/_logging.py",
|
||||
"src/notebooklm/_mind_map.py",
|
||||
"src/notebooklm/_notebooks.py",
|
||||
"src/notebooklm/_research.py",
|
||||
"src/notebooklm/_sources.py",
|
||||
"src/notebooklm/auth.py",
|
||||
"src/notebooklm/cli/artifact_cmd.py",
|
||||
"src/notebooklm/cli/chat_cmd.py",
|
||||
"src/notebooklm/cli/download_cmd.py",
|
||||
"src/notebooklm/cli/error_handler.py",
|
||||
"src/notebooklm/cli/generate_cmd.py",
|
||||
"src/notebooklm/cli/helpers.py",
|
||||
"src/notebooklm/cli/note_cmd.py",
|
||||
"src/notebooklm/cli/notebook_cmd.py",
|
||||
"src/notebooklm/cli/options.py",
|
||||
"src/notebooklm/cli/research_cmd.py",
|
||||
"src/notebooklm/cli/session_cmd.py",
|
||||
"src/notebooklm/cli/source_cmd.py",
|
||||
"src/notebooklm/notebooklm_cli.py",
|
||||
"src/notebooklm/client.py",
|
||||
"src/notebooklm/exceptions.py",
|
||||
"src/notebooklm/types.py",
|
||||
# docs (user-facing).
|
||||
"docs/python-api.md",
|
||||
"docs/development.md",
|
||||
"docs/auth-cookie-lifecycle.md",
|
||||
"docs/cli-exit-codes.md",
|
||||
"docs/cli-reference.md",
|
||||
# root
|
||||
"CHANGELOG.md",
|
||||
"pyproject.toml",
|
||||
"CLAUDE.md",
|
||||
]
|
||||
|
||||
|
||||
PHASE_2_FILES: list[str] = [
|
||||
# Top-level test helpers.
|
||||
"tests/cassette_patterns.py",
|
||||
"tests/conftest.py",
|
||||
"tests/vcr_config.py",
|
||||
# Integration top-level.
|
||||
"tests/integration/conftest.py",
|
||||
"tests/integration/README.md",
|
||||
"tests/integration/test_artifacts_integration.py",
|
||||
"tests/integration/test_auth_refresh_vcr.py",
|
||||
"tests/integration/test_auto_refresh.py",
|
||||
"tests/integration/test_chat_multi_source_vcr.py",
|
||||
"tests/integration/test_empty_results_vcr.py",
|
||||
"tests/integration/test_error_paths_vcr.py",
|
||||
"tests/integration/test_mind_map_chain_vcr.py",
|
||||
"tests/integration/test_notebooks_integration.py",
|
||||
"tests/integration/test_notes_integration.py",
|
||||
"tests/integration/test_polling_vcr.py",
|
||||
"tests/integration/test_research_deep_poll_vcr.py",
|
||||
"tests/integration/test_save_chat_as_note_integration.py",
|
||||
"tests/integration/test_settings_integration.py",
|
||||
"tests/integration/test_sharing_integration.py",
|
||||
"tests/integration/test_sources_integration.py",
|
||||
"tests/integration/test_vcr_comprehensive.py",
|
||||
"tests/integration/test_vcr_example.py",
|
||||
"tests/integration/test_vcr_real_api.py",
|
||||
"tests/integration/test_workflow_tracer_vcr.py",
|
||||
# CLI VCR.
|
||||
"tests/integration/cli_vcr/conftest.py",
|
||||
"tests/integration/cli_vcr/test_artifacts.py",
|
||||
"tests/integration/cli_vcr/test_chat.py",
|
||||
"tests/integration/cli_vcr/test_doctor.py",
|
||||
"tests/integration/cli_vcr/test_downloads.py",
|
||||
"tests/integration/cli_vcr/test_generate.py",
|
||||
"tests/integration/cli_vcr/test_login_browser_cookies.py",
|
||||
"tests/integration/cli_vcr/test_notebooks.py",
|
||||
"tests/integration/cli_vcr/test_notes.py",
|
||||
"tests/integration/cli_vcr/test_profile.py",
|
||||
"tests/integration/cli_vcr/test_settings.py",
|
||||
"tests/integration/cli_vcr/test_sources.py",
|
||||
# Concurrency integration.
|
||||
"tests/integration/concurrency/conftest.py",
|
||||
"tests/integration/concurrency/helpers.py",
|
||||
"tests/integration/concurrency/README.md",
|
||||
"tests/integration/concurrency/test_add_file_toctou.py",
|
||||
"tests/integration/concurrency/test_aexit_exception_masking.py",
|
||||
"tests/integration/concurrency/test_artifact_poll_dedupe.py",
|
||||
"tests/integration/concurrency/test_auth_snapshot_torn_read.py",
|
||||
"tests/integration/concurrency/test_chat_history_race.py",
|
||||
"tests/integration/concurrency/test_cross_loop_affinity.py",
|
||||
"tests/integration/concurrency/test_download_blocks_loop.py",
|
||||
"tests/integration/concurrency/test_harness_smoke.py",
|
||||
"tests/integration/concurrency/test_helpers.py",
|
||||
"tests/integration/concurrency/test_idempotency_create.py",
|
||||
"tests/integration/concurrency/test_keepalive_path_canonicalize.py",
|
||||
"tests/integration/concurrency/test_max_concurrent_rpcs.py",
|
||||
"tests/integration/concurrency/test_note_create_cancel.py",
|
||||
"tests/integration/concurrency/test_pool_tuning.py",
|
||||
"tests/integration/concurrency/test_rate_limit_default.py",
|
||||
"tests/integration/concurrency/test_refresh_cancellation_propagation.py",
|
||||
"tests/integration/concurrency/test_refresh_cmd_race.py",
|
||||
"tests/integration/concurrency/test_research_task_crosswire.py",
|
||||
"tests/integration/concurrency/test_upload_blocks_loop.py",
|
||||
"tests/integration/concurrency/test_upload_cancel_dangling_session.py",
|
||||
"tests/integration/concurrency/test_upload_timeout_config.py",
|
||||
"tests/integration/concurrency/test_wait_for_sources_leak.py",
|
||||
# tests/scripts (test-adjacent helpers).
|
||||
"tests/scripts/check_cassettes_clean.py",
|
||||
"tests/scripts/check_method_coverage.py",
|
||||
"tests/scripts/compress_polling_cassette.py",
|
||||
"tests/scripts/setup-generation-notebook.py",
|
||||
# Unit conftest.
|
||||
"tests/unit/conftest.py",
|
||||
# Unit CLI tests.
|
||||
"tests/unit/cli/conftest.py",
|
||||
"tests/unit/cli/test_agent.py",
|
||||
"tests/unit/cli/test_artifact.py",
|
||||
"tests/unit/cli/test_chat.py",
|
||||
"tests/unit/cli/test_cli_contract.py",
|
||||
"tests/unit/cli/test_completion.py",
|
||||
"tests/unit/cli/test_doctor.py",
|
||||
"tests/unit/cli/test_download.py",
|
||||
"tests/unit/cli/test_encoding.py",
|
||||
"tests/unit/cli/test_error_handler.py",
|
||||
"tests/unit/cli/test_generate.py",
|
||||
"tests/unit/cli/test_grouped.py",
|
||||
"tests/unit/cli/test_help_text.py",
|
||||
"tests/unit/cli/test_helpers.py",
|
||||
"tests/unit/cli/test_language.py",
|
||||
"tests/unit/cli/test_note.py",
|
||||
"tests/unit/cli/test_notebook.py",
|
||||
"tests/unit/cli/test_profile.py",
|
||||
"tests/unit/cli/test_prompt_file.py",
|
||||
"tests/unit/cli/test_research.py",
|
||||
"tests/unit/cli/test_resolve.py",
|
||||
"tests/unit/cli/test_root_group.py",
|
||||
"tests/unit/cli/test_share.py",
|
||||
"tests/unit/cli/test_skill.py",
|
||||
"tests/unit/cli/test_source.py",
|
||||
"tests/unit/cli/test_storage_context_isolation.py",
|
||||
"tests/unit/cli/test_use_fails_closed.py",
|
||||
# Unit concurrency tests.
|
||||
"tests/unit/concurrency/test_auth_load_blocks_loop.py",
|
||||
"tests/unit/concurrency/test_close_cancellation_leak.py",
|
||||
"tests/unit/concurrency/test_download_collision.py",
|
||||
# Unit tests (top-level).
|
||||
"tests/unit/test_api_coverage.py",
|
||||
"tests/unit/test_artifact_downloads.py",
|
||||
"tests/unit/test_artifact_type_code_consistency.py",
|
||||
"tests/unit/test_artifacts_coverage.py",
|
||||
"tests/unit/test_artifacts_helpers.py",
|
||||
"tests/unit/test_artifacts_mind_map_injection.py",
|
||||
"tests/unit/test_artifacts_polling_retries.py",
|
||||
"tests/unit/test_atomic_io.py",
|
||||
"tests/unit/test_atomic_update_json.py",
|
||||
"tests/unit/test_auth_cookie_save_race.py",
|
||||
"tests/unit/test_auth_session.py",
|
||||
"tests/unit/test_backoff.py",
|
||||
"tests/unit/test_cassette_patterns.py",
|
||||
"tests/unit/test_cassette_sanitizer.py",
|
||||
"tests/unit/test_chat.py",
|
||||
"tests/unit/test_chat_ask_invariants.py",
|
||||
"tests/unit/test_chat_characterization.py",
|
||||
"tests/unit/test_chat_error_payload.py",
|
||||
"tests/unit/test_chat_helpers.py",
|
||||
"tests/unit/test_chat_history.py",
|
||||
"tests/unit/test_chat_references.py",
|
||||
"tests/unit/test_check_coverage_thresholds.py",
|
||||
"tests/unit/test_check_rpc_health.py",
|
||||
"tests/unit/test_chromium_profiles.py",
|
||||
"tests/unit/test_ci_audit_scripts.py",
|
||||
"tests/unit/test_ci_install_parity.py",
|
||||
"tests/unit/test_claude_md_freshness.py",
|
||||
"tests/unit/test_cli_source_delete.py",
|
||||
"tests/unit/test_client.py",
|
||||
"tests/unit/test_client_keepalive.py",
|
||||
"tests/unit/test_concurrency_cache_race.py",
|
||||
"tests/unit/test_concurrency_refresh_race.py",
|
||||
"tests/unit/test_conversation.py",
|
||||
"tests/unit/test_cookie_domain_split.py",
|
||||
"tests/unit/test_cookie_redaction.py",
|
||||
"tests/unit/test_decoder.py",
|
||||
"tests/unit/test_docstrings.py",
|
||||
"tests/unit/test_download_helpers.py",
|
||||
"tests/unit/test_download_result.py",
|
||||
"tests/unit/test_download_url.py",
|
||||
"tests/unit/test_e2e_conftest_options.py",
|
||||
"tests/unit/test_encoder.py",
|
||||
"tests/unit/test_env.py",
|
||||
"tests/unit/test_env_base_url.py",
|
||||
"tests/unit/test_exceptions.py",
|
||||
"tests/unit/test_firefox_containers.py",
|
||||
"tests/unit/test_init_order.py",
|
||||
"tests/unit/test_json_error_exit.py",
|
||||
"tests/unit/test_json_stdout_purity.py",
|
||||
"tests/unit/test_logging.py",
|
||||
"tests/unit/test_logging_correlation.py",
|
||||
"tests/unit/test_migration.py",
|
||||
"tests/unit/test_migration_lock.py",
|
||||
"tests/unit/test_notebook_api.py",
|
||||
"tests/unit/test_notebook_metadata.py",
|
||||
"tests/unit/test_notebooks_extractors.py",
|
||||
"tests/unit/test_notes_unit.py",
|
||||
"tests/unit/test_observability.py",
|
||||
"tests/unit/test_paths.py",
|
||||
"tests/unit/test_public_shims.py",
|
||||
"tests/unit/test_quota_failure_detection.py",
|
||||
"tests/unit/test_rate_limit_retry.py",
|
||||
"tests/unit/test_refresh_cmd_shlex.py",
|
||||
"tests/unit/test_refresh_lock_lazy_init.py",
|
||||
"tests/unit/test_refresh_lock_registry.py",
|
||||
"tests/unit/test_refresh_state_machine.py",
|
||||
"tests/unit/test_research.py",
|
||||
"tests/unit/test_research_api.py",
|
||||
"tests/unit/test_rescrub_cassettes_script.py",
|
||||
"tests/unit/test_response_preview.py",
|
||||
"tests/unit/test_retry_after.py",
|
||||
"tests/unit/test_rpc_health_coverage.py",
|
||||
"tests/unit/test_rpc_overrides.py",
|
||||
"tests/unit/test_rpc_types.py",
|
||||
"tests/unit/test_safe_index.py",
|
||||
"tests/unit/test_save_chat_as_note_encoder.py",
|
||||
"tests/unit/test_save_lock_contract.py",
|
||||
"tests/unit/test_select_artifact.py",
|
||||
"tests/unit/test_sharing_manager.py",
|
||||
"tests/unit/test_sharing_types.py",
|
||||
"tests/unit/test_source_add_service.py",
|
||||
"tests/unit/test_source_content_renderer.py",
|
||||
"tests/unit/test_source_listing_service.py",
|
||||
"tests/unit/test_source_polling_service.py",
|
||||
"tests/unit/test_source_selection.py",
|
||||
"tests/unit/test_source_status.py",
|
||||
"tests/unit/test_source_symlink.py",
|
||||
"tests/unit/test_source_upload_pipeline.py",
|
||||
"tests/unit/test_sources_upload.py",
|
||||
"tests/unit/test_streaming_chat_wire.py",
|
||||
"tests/unit/test_swallow_observability.py",
|
||||
"tests/unit/test_tier_enforcement_hook.py",
|
||||
"tests/unit/test_token_regex.py",
|
||||
"tests/unit/test_types.py",
|
||||
"tests/unit/test_url_utils.py",
|
||||
"tests/unit/test_user_settings_api.py",
|
||||
"tests/unit/test_vcr_config.py",
|
||||
"tests/unit/test_version_check.py",
|
||||
"tests/unit/test_version_gate.py",
|
||||
"tests/unit/test_warning_dedupe.py",
|
||||
"tests/unit/test_windows_compatibility.py",
|
||||
"tests/unit/test_with_client_handle_errors.py",
|
||||
"tests/unit/test_youtube_extraction.py",
|
||||
]
|
||||
|
||||
|
||||
PHASE_SCOPES: dict[str, list[str]] = {
|
||||
"phase1": PHASE_1_FILES,
|
||||
"phase2": PHASE_2_FILES,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def transform_text(text: str) -> str:
|
||||
out = text
|
||||
for pat, repl in PAREN_PATTERNS:
|
||||
out = re.sub(pat, repl, out)
|
||||
for pat, repl in SENTENCE_PATTERNS:
|
||||
out = re.sub(pat, repl, out)
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2 or argv[1] not in PHASE_SCOPES:
|
||||
valid_phases = "|".join(sorted(PHASE_SCOPES.keys()))
|
||||
print(f"usage: {argv[0]} {{{valid_phases}}}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
phase = argv[1]
|
||||
targets = PHASE_SCOPES[phase]
|
||||
|
||||
changed: list[Path] = []
|
||||
missing: list[str] = []
|
||||
for rel in targets:
|
||||
path = REPO_ROOT / rel
|
||||
if not path.exists():
|
||||
missing.append(rel)
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
new = transform_text(text)
|
||||
if new != text:
|
||||
path.write_text(new, encoding="utf-8")
|
||||
changed.append(path)
|
||||
|
||||
if missing:
|
||||
print(f"WARNING: {len(missing)} target paths missing (skipped):")
|
||||
for m in missing:
|
||||
print(f" {m}")
|
||||
|
||||
print(f"Edited {len(changed)} files:")
|
||||
for p in sorted(changed):
|
||||
print(f" {p.relative_to(REPO_ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,761 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"extra_public_names": {
|
||||
"notebooklm": [
|
||||
"configure_logging",
|
||||
"DEFAULT_STORAGE_PATH"
|
||||
]
|
||||
},
|
||||
"allowed_breaks": [
|
||||
{
|
||||
"code": "changed-signature",
|
||||
"object": "notebooklm.NotebookLMClient.artifacts.export",
|
||||
"reason": "v0.8.0 #1874: export()'s `content` parameter is now keyword-only so its positional slots align with export_report/export_data_table (title in slot 3) — this closes a silent title->content misbind footgun (a caller writing export(nb, artifact_id, \"My Title\") previously bound the title into `content`). export() also now enforces exactly-one-of(artifact_id, content). Only callers passing `content` positionally (the footgun path) break; pass content=... instead. See CHANGELOG.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-signature",
|
||||
"object": "notebooklm.client.NotebookLMClient.artifacts.export",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.artifacts.export above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.ANIME",
|
||||
"reason": "v0.8.0 #1594: VideoStyle wire values were stale and generated the wrong NotebookLM video style. Updated to match live Web UI captures; see CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.CLASSIC",
|
||||
"reason": "v0.8.0 #1594: VideoStyle wire values were stale and generated the wrong NotebookLM video style. Updated to match live Web UI captures; see CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.CUSTOM",
|
||||
"reason": "v0.8.0 #1594: CUSTOM is the live Web UI's proto-default video style value (0) and is encoded by omitting/defaulting the style slot plus appending the custom style prompt. See CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.HERITAGE",
|
||||
"reason": "v0.8.0 #1594: VideoStyle wire values were stale and generated the wrong NotebookLM video style. Updated to match live Web UI captures; see CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.KAWAII",
|
||||
"reason": "v0.8.0 #1594: VideoStyle wire values were stale and generated the wrong NotebookLM video style. Updated to match live Web UI captures; see CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.PAPER_CRAFT",
|
||||
"reason": "v0.8.0 #1594: VideoStyle wire values were stale and generated the wrong NotebookLM video style. Updated to match live Web UI captures; see CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.WATERCOLOR",
|
||||
"reason": "v0.8.0 #1594: VideoStyle wire values were stale and generated the wrong NotebookLM video style. Updated to match live Web UI captures; see CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.VideoStyle.WHITEBOARD",
|
||||
"reason": "v0.8.0 #1594: VideoStyle wire values were stale and generated the wrong NotebookLM video style. Updated to match live Web UI captures; see CHANGELOG.md and docs/rpc-reference.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.ANIME",
|
||||
"reason": "Same break as notebooklm.VideoStyle.ANIME above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.CLASSIC",
|
||||
"reason": "Same break as notebooklm.VideoStyle.CLASSIC above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.CUSTOM",
|
||||
"reason": "Same break as notebooklm.VideoStyle.CUSTOM above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.HERITAGE",
|
||||
"reason": "Same break as notebooklm.VideoStyle.HERITAGE above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.KAWAII",
|
||||
"reason": "Same break as notebooklm.VideoStyle.KAWAII above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.PAPER_CRAFT",
|
||||
"reason": "Same break as notebooklm.VideoStyle.PAPER_CRAFT above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.WATERCOLOR",
|
||||
"reason": "Same break as notebooklm.VideoStyle.WATERCOLOR above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "changed-enum-value",
|
||||
"object": "notebooklm.types.VideoStyle.WHITEBOARD",
|
||||
"reason": "Same break as notebooklm.VideoStyle.WHITEBOARD above; this entry covers the notebooklm.types re-export."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.NotebookLMClient.notebooks.share",
|
||||
"reason": "v0.8.0 #1363: removed the v0.5.0-deprecated NotebooksAPI.share() no-behavior-change wrapper over client.sharing.set_public. Use client.sharing.set_public(notebook_id, public) for the public-sharing toggle and client.notebooks.get_share_url(notebook_id, artifact_id) for the deep-link URL (get_share_url stays). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.client.NotebookLMClient.notebooks.share",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.notebooks.share above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.NotebookLMClient.research.start",
|
||||
"reason": "v0.7.0 #1209: now returns the typed ResearchStart dataclass instead of dict[str, Any] (MappingCompatMixin preserved dict-subscript access, dropped in v0.8.0). v0.8.0 #1342: the return type narrows from ResearchStart | None to ResearchStart — a 'couldn't-start' payload (empty/non-list or falsey task_id) now raises DecodingError instead of returning None. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.client.NotebookLMClient.research.start",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.research.start above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.NotebookLMClient.sources.refresh",
|
||||
"reason": "v0.8.0 #1290: the uninformative always-True return becomes None; the -> bool annotation is dropped. Any failure raises before the return, so the bool carried no information. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.client.NotebookLMClient.sources.refresh",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.sources.refresh above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.NotebookLMClient.chat.delete_conversation",
|
||||
"reason": "v0.8.0 #1290: the uninformative always-True return becomes None; the -> bool annotation is dropped. Any failure raises before the return, so the bool carried no information. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.client.NotebookLMClient.chat.delete_conversation",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.chat.delete_conversation above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.NotebookLMClient.sources.get",
|
||||
"reason": "v0.8.0 #1247: get() now raises SourceNotFoundError on a miss instead of returning None; the return annotation narrows from 'notebooklm.types.Source | None' to 'notebooklm.types.Source' (matches notebooks.get). Use get_or_none() for the None-on-miss lookup. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.client.NotebookLMClient.sources.get",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.sources.get above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.NotebookLMClient.artifacts.get",
|
||||
"reason": "v0.8.0 #1247: get() now raises ArtifactNotFoundError on a miss instead of returning None; the return annotation narrows from 'notebooklm.types.Artifact | None' to 'notebooklm.types.Artifact' (matches notebooks.get). Use get_or_none() for the None-on-miss lookup. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.client.NotebookLMClient.artifacts.get",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.artifacts.get above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.NotebookLMClient.notes.get",
|
||||
"reason": "v0.8.0 #1247: get() now raises NoteNotFoundError on a miss instead of returning None; the return annotation narrows from 'notebooklm.types.Note | None' to 'notebooklm.types.Note' (matches notebooks.get). Use get_or_none() for the None-on-miss lookup. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.client.NotebookLMClient.notes.get",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.notes.get above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.NotebookLMClient.mind_maps.get",
|
||||
"reason": "v0.8.0 #1247: get() now raises MindMapNotFoundError on a miss instead of returning None; the return annotation narrows from 'notebooklm.types.MindMap | None' to 'notebooklm.types.MindMap' (matches notebooks.get). Use get_or_none() for the None-on-miss lookup. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-return",
|
||||
"object": "notebooklm.client.NotebookLMClient.mind_maps.get",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.mind_maps.get above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchTask.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchTask.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchTask.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchTask.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchStart.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchStart.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchStart.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchStart.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.MindMapResult.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.MindMapResult.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.MindMapResult.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.MindMapResult.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.SourceGuide.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.SourceGuide.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.SourceGuide.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.SourceGuide.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchSource.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchSource.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchSource.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.ResearchSource.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchTask.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchTask.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchTask.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchTask.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchStart.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchStart.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchStart.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchStart.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.MindMapResult.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.MindMapResult.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.MindMapResult.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.MindMapResult.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.SourceGuide.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.SourceGuide.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.SourceGuide.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.SourceGuide.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchSource.get",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchSource.items",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchSource.keys",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.types.ResearchSource.values",
|
||||
"reason": "v0.8.0 #1251: MappingCompatMixin dropped; the typed return is now attribute-only, removing the deprecated dict-style get/keys/items/values shims (warned in v0.7.0). See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-signature",
|
||||
"object": "notebooklm.NotebookLMClient.research.wait_for_completion",
|
||||
"reason": "v0.8.0 #1254: removed the deprecated interval= alias (DeprecationWarning cycle from v0.7.0 completed). Use initial_interval= (same cadence). Passing interval= now raises TypeError. See docs/deprecations.md Removed in v0.8.0 and CHANGELOG.md."
|
||||
},
|
||||
{
|
||||
"code": "changed-signature",
|
||||
"object": "notebooklm.client.NotebookLMClient.research.wait_for_completion",
|
||||
"reason": "Same break as above; covers the audit dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.AccountTier",
|
||||
"reason": "v0.8.0 #1738: removed the unreliable promotions-based AccountTier public type. The tier came from FetchRecommendations (a promotions endpoint) and could not distinguish free from paid; the authoritative signal is AccountLimits. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.NotebookLMClient.settings.get_account_tier",
|
||||
"reason": "v0.8.0 #1738: removed SettingsAPI.get_account_tier(). The tier came from GET_USER_TIER (FetchRecommendations, a promotions endpoint) and could not distinguish free from paid; use client.settings.get_account_limits() for quota decisions. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-member",
|
||||
"object": "notebooklm.client.NotebookLMClient.settings.get_account_tier",
|
||||
"reason": "Same break as notebooklm.NotebookLMClient.settings.get_account_tier above; this entry covers the audit's dotted-module-path view of the same callable."
|
||||
},
|
||||
{
|
||||
"code": "removed-enum-member",
|
||||
"object": "notebooklm.rpc.RPCMethod.GET_USER_TIER",
|
||||
"reason": "v0.8.0 #1738: removed the GET_USER_TIER (ozz5Z) RPC method with the AccountTier feature. It mapped to FetchRecommendations, a promotions endpoint, not a subscription-tier lookup. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ArtifactStatus",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.types.ArtifactStatus. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ArtifactTypeCode",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal artifact type-code enum (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.AudioFormat",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.AudioFormat / notebooklm.types.AudioFormat. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.AudioLength",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.AudioLength / notebooklm.types.AudioLength. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.AuthError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.AuthError / notebooklm.exceptions.AuthError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.BATCHEXECUTE_URL",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute endpoint constant (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ChatGoal",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.ChatGoal / notebooklm.types.ChatGoal. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ChatResponseLength",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.ChatResponseLength / notebooklm.types.ChatResponseLength. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ClientError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.ClientError / notebooklm.exceptions.ClientError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.DriveMimeType",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.DriveMimeType / notebooklm.types.DriveMimeType. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ExportType",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.ExportType / notebooklm.types.ExportType. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.FLASHCARDS_VARIANT",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal artifact-variant constant (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.INTERACTIVE_MIND_MAP_VARIANT",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal artifact-variant constant (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.InfographicDetail",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.InfographicDetail / notebooklm.types.InfographicDetail. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.InfographicOrientation",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.InfographicOrientation / notebooklm.types.InfographicOrientation. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.InfographicStyle",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.InfographicStyle / notebooklm.types.InfographicStyle. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.NetworkError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.NetworkError / notebooklm.exceptions.NetworkError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.QUERY_URL",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute endpoint constant (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.QUIZ_VARIANT",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal artifact-variant constant (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.QuizDifficulty",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.QuizDifficulty / notebooklm.types.QuizDifficulty. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.QuizQuantity",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.QuizQuantity / notebooklm.types.QuizQuantity. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.RPCError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.RPCError / notebooklm.exceptions.RPCError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.RPCErrorCode",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal RPC error-code helper (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.RPCTimeoutError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.RPCTimeoutError / notebooklm.exceptions.RPCTimeoutError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.RateLimitError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.RateLimitError / notebooklm.exceptions.RateLimitError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ReportFormat",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.ReportFormat / notebooklm.types.ReportFormat. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.ServerError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.ServerError / notebooklm.exceptions.ServerError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.SlideDeckFormat",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.SlideDeckFormat / notebooklm.types.SlideDeckFormat. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.SlideDeckLength",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.SlideDeckLength / notebooklm.types.SlideDeckLength. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.UPLOAD_URL",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute endpoint constant (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.UnknownRPCMethodError",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Exception re-export; the canonical public import is notebooklm.UnknownRPCMethodError / notebooklm.exceptions.UnknownRPCMethodError. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.VideoFormat",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.VideoFormat / notebooklm.types.VideoFormat. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.VideoStyle",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Enum re-export; the canonical public import is notebooklm.VideoStyle / notebooklm.types.VideoStyle. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.artifact_status_to_str",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Helper re-export; the canonical public import is notebooklm.types.artifact_status_to_str. Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.build_request_body",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.collect_rpc_ids",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.decode_response",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.encode_rpc_request",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.extract_rpc_result",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.get_batchexecute_url",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute endpoint helper (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.get_error_message_for_code",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal RPC error-code helper (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.get_query_url",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute endpoint helper (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.get_upload_url",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute endpoint helper (no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.nest_source_ids",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.parse_chunked_response",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.safe_index",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute decode helper (defined in notebooklm.rpc._safe_index, re-exported via notebooklm.rpc.decoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.rpc.strip_anti_xssi",
|
||||
"reason": "v0.8.0 #1589: de-bless internal RPC export from notebooklm.rpc.__all__ (notebooklm.rpc.* is internal per docs/stability.md, except RPCMethod/resolve_rpc_id). Internal batchexecute wire helper (defined in notebooklm.rpc.decoder / notebooklm.rpc.encoder; no blessed public alias). Still importable explicitly for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.ALLOWED_COOKIE_DOMAINS",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.cookie_policy.ALLOWED_COOKIE_DOMAINS; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.CookieSaveResult",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.storage.CookieSaveResult; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.CookieSnapshot",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.storage.CookieSnapshot; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.CookieSnapshotKey",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.storage.CookieSnapshotKey; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.CookieSnapshotValue",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.storage.CookieSnapshotValue; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.KEEPALIVE_ROTATE_URL",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.keepalive.KEEPALIVE_ROTATE_URL; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.MINIMUM_REQUIRED_COOKIES",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.cookie_policy.MINIMUM_REQUIRED_COOKIES; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.NOTEBOOKLM_DISABLE_KEEPALIVE_POKE_ENV",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.paths.NOTEBOOKLM_DISABLE_KEEPALIVE_POKE_ENV; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.NOTEBOOKLM_REFRESH_CMD_ENV",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.paths.NOTEBOOKLM_REFRESH_CMD_ENV; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.NOTEBOOKLM_REFRESH_CMD_USE_SHELL_ENV",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.paths.NOTEBOOKLM_REFRESH_CMD_USE_SHELL_ENV; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.advance_cookie_snapshot_after_save",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.storage.advance_cookie_snapshot_after_save; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.authuser_query",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.account.authuser_query; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.extract_csrf_from_html",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.extraction.extract_csrf_from_html; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.extract_session_id_from_html",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.extraction.extract_session_id_from_html; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.extract_wiz_field",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.extraction.extract_wiz_field; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.fetch_tokens",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.refresh.fetch_tokens; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.format_authuser_value",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.account.format_authuser_value; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.load_auth_from_storage",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.tokens.load_auth_from_storage; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.load_httpx_cookies",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.cookies.load_httpx_cookies; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.normalize_cookie_map",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.cookies.normalize_cookie_map; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.recover_psidts_in_memory",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.psidts_recovery.recover_psidts_in_memory; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.save_cookies_to_storage",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.storage.save_cookies_to_storage; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
},
|
||||
{
|
||||
"code": "removed-export",
|
||||
"object": "notebooklm.auth.snapshot_cookie_jar",
|
||||
"reason": "v0.8.0 #1592: de-bless internal auth re-export from notebooklm.auth.__all__ (notebooklm.auth.* is internal per docs/stability.md). Canonical home notebooklm._auth.storage.snapshot_cookie_jar; no blessed public alias. Still importable from notebooklm.auth for back-compat. See CHANGELOG.md and docs/deprecations.md."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
"""Audit public API compatibility against a previous release tag.
|
||||
|
||||
This is a release gate, not a replacement for unit tests. It compares the
|
||||
runtime public surface in this checkout against a baseline git ref (by default
|
||||
the latest reachable stable release tag; pre-releases are skipped) and reports
|
||||
unapproved removals, call-signature changes, or return-annotation changes.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/audit_public_api_compat.py
|
||||
uv run python scripts/audit_public_api_compat.py --baseline-ref v0.4.1
|
||||
uv run python scripts/audit_public_api_compat.py --json
|
||||
uv run python scripts/audit_public_api_compat.py --check-stale
|
||||
|
||||
``--check-stale`` additionally fails when an ``allowed_breaks`` entry matches no
|
||||
current break against the baseline (it is already in the baseline). This keeps
|
||||
the allowlist from silently accumulating cruft — prune such entries at each
|
||||
release boundary (see ``docs/releasing.md`` → prune-allowlist-at-release).
|
||||
|
||||
Exit codes:
|
||||
0 No unapproved compatibility breaks (and, with --check-stale, none stale).
|
||||
1 Unapproved public API breakage detected, or stale allowlist entries
|
||||
under --check-stale.
|
||||
2 Script/setup error, bad baseline ref, or import/introspection failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import textwrap
|
||||
from dataclasses import asdict, dataclass
|
||||
from fnmatch import fnmatchcase
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PUBLIC_PACKAGE = "notebooklm"
|
||||
DEFAULT_ALLOWLIST = "scripts/api-compat-allowlist.json"
|
||||
EXCLUDED_TOP_LEVEL_MODULES = {"__main__", "notebooklm_cli"}
|
||||
EXTRA_PUBLIC_PACKAGES = ("rpc",)
|
||||
CLIENT_NAMESPACE_ATTRIBUTES = (
|
||||
"artifacts",
|
||||
"chat",
|
||||
"labels",
|
||||
"mind_maps",
|
||||
"notes",
|
||||
"notebooks",
|
||||
"research",
|
||||
"settings",
|
||||
"sharing",
|
||||
"sources",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiBreak:
|
||||
"""A backward-incompatible public surface change."""
|
||||
|
||||
code: str
|
||||
object: str
|
||||
detail: str
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.code}:{self.object}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Allowance:
|
||||
"""A reviewed compatibility break that is allowed for this release."""
|
||||
|
||||
code: str
|
||||
object: str
|
||||
reason: str
|
||||
|
||||
def matches(self, breakage: ApiBreak) -> bool:
|
||||
# These are fnmatch globs, so "*" can cross dots. Keep release
|
||||
# allowlists exact unless a broad match is intentional.
|
||||
return fnmatchcase(breakage.code, self.code) and fnmatchcase(
|
||||
breakage.object,
|
||||
self.object,
|
||||
)
|
||||
|
||||
|
||||
def _run_git(args: list[str], cwd: Path, *, capture: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=capture,
|
||||
text=False,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def latest_release_tag(repo_root: Path) -> str:
|
||||
"""Return the latest reachable stable release tag.
|
||||
|
||||
Restricts ``git describe`` to release-shaped tags (``--match``) and drops
|
||||
pre-release suffixes (``--exclude`` of aN/bN/rcN), so a pushed ``v0.8.0a1``
|
||||
does not become the compat baseline. Keeping the baseline on the last stable
|
||||
release means the audit checks the real ``vPREV -> vNEXT`` upgrade path for
|
||||
the whole pre-release cycle, and the allowlist prunes once at the final tag.
|
||||
"""
|
||||
result = _run_git(
|
||||
[
|
||||
"describe",
|
||||
"--tags",
|
||||
"--abbrev=0",
|
||||
"--match",
|
||||
"v[0-9]*.[0-9]*.[0-9]*", # release-shaped tags only (skips recovery/*, docs-*, …)
|
||||
"--exclude",
|
||||
"*a[0-9]*", # drop aN pre-releases
|
||||
"--exclude",
|
||||
"*b[0-9]*", # drop bN pre-releases
|
||||
"--exclude",
|
||||
"*rc[0-9]*", # drop rcN pre-releases
|
||||
],
|
||||
repo_root,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
raise RuntimeError(
|
||||
"could not resolve latest stable release tag: "
|
||||
f"{stderr.strip()}. Fetch tags/history or pass --baseline-ref explicitly."
|
||||
)
|
||||
return result.stdout.decode("utf-8").strip()
|
||||
|
||||
|
||||
def export_git_ref(repo_root: Path, ref: str, destination: Path) -> Path:
|
||||
"""Extract ``ref`` into ``destination`` and return the extracted repo path."""
|
||||
result = _run_git(["archive", "--format=tar", ref], repo_root)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"could not archive baseline ref {ref!r}: {stderr.strip()}")
|
||||
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = destination / "baseline.tar"
|
||||
archive_path.write_bytes(result.stdout)
|
||||
source_root = destination / "baseline"
|
||||
source_root.mkdir()
|
||||
with tarfile.open(archive_path) as archive:
|
||||
archive.extractall(source_root, filter="data")
|
||||
return source_root
|
||||
|
||||
|
||||
_COLLECTOR = r"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import typing
|
||||
import warnings
|
||||
|
||||
ROOT = pathlib.Path(sys.argv[1]).resolve()
|
||||
EXTRA_PUBLIC_NAMES = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
|
||||
CLIENT_NAMESPACE_ATTRIBUTES = set(json.loads(sys.argv[3])) if len(sys.argv) > 3 else set()
|
||||
PKG = sys.argv[4]
|
||||
EXCLUDED = set(json.loads(sys.argv[5]))
|
||||
EXTRA_PACKAGES = tuple(json.loads(sys.argv[6]))
|
||||
# Enforce the "every public module declares __all__" rule only for the CURRENT
|
||||
# checkout. Historical baselines (e.g. v0.4.1) predate the rule and legitimately
|
||||
# lack __all__ on some public modules; raising there would abort the baseline
|
||||
# collection before any diff runs (issue #1493 review).
|
||||
ENFORCE_ALL = (sys.argv[7] == "1") if len(sys.argv) > 7 else True
|
||||
|
||||
|
||||
def discover_modules() -> list[str]:
|
||||
package_dir = ROOT / "src" / PKG
|
||||
modules = {PKG}
|
||||
if package_dir.is_dir():
|
||||
for path in package_dir.glob("*.py"):
|
||||
stem = path.stem
|
||||
if stem.startswith("_") or stem in EXCLUDED:
|
||||
continue
|
||||
modules.add(f"{PKG}.{stem}")
|
||||
for name in EXTRA_PACKAGES:
|
||||
if (package_dir / name / "__init__.py").is_file():
|
||||
modules.add(f"{PKG}.{name}")
|
||||
return sorted(modules)
|
||||
|
||||
|
||||
class _ReturnProbe:
|
||||
# Tiny carrier so typing.get_type_hints can resolve a lone return string.
|
||||
def __init__(self, annotation):
|
||||
self.__annotations__ = {"return": annotation}
|
||||
|
||||
|
||||
def annotation_repr(annotation, obj=None):
|
||||
if annotation is inspect.Signature.empty:
|
||||
return None
|
||||
# `from __future__ import annotations` (PEP 563) yields string annotations
|
||||
# already; non-postponed modules yield live objects. Resolve string
|
||||
# annotations against the owning module's globals so the captured form is
|
||||
# canonical regardless of a module's PEP 563 status (a transition would
|
||||
# otherwise flip e.g. 'MindMap' <-> 'notebooklm.types.MindMap' and surface a
|
||||
# spurious changed-return). Fall back to the raw string when resolution
|
||||
# fails (e.g. TYPE_CHECKING-only names).
|
||||
if isinstance(annotation, str):
|
||||
module_name = getattr(obj, "__module__", None)
|
||||
module = sys.modules.get(module_name) if module_name else None
|
||||
if module is None:
|
||||
return annotation
|
||||
try:
|
||||
annotation = typing.get_type_hints(
|
||||
_ReturnProbe(annotation), globalns=vars(module)
|
||||
)["return"]
|
||||
except Exception:
|
||||
return annotation
|
||||
return inspect.formatannotation(annotation)
|
||||
|
||||
|
||||
def signature_payload(obj):
|
||||
try:
|
||||
sig = inspect.signature(obj)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
params = []
|
||||
for param in sig.parameters.values():
|
||||
params.append(
|
||||
{
|
||||
"name": param.name,
|
||||
"kind": param.kind.name,
|
||||
"has_default": param.default is not inspect.Parameter.empty,
|
||||
"default_repr": None
|
||||
if param.default is inspect.Parameter.empty
|
||||
else repr(param.default),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"text": str(sig),
|
||||
"parameters": params,
|
||||
"return_annotation": annotation_repr(sig.return_annotation, obj),
|
||||
}
|
||||
|
||||
|
||||
def kind_of(obj) -> str:
|
||||
if inspect.isclass(obj):
|
||||
if issubclass(obj, enum.Enum):
|
||||
return "enum"
|
||||
return "class"
|
||||
if inspect.isfunction(obj) or inspect.ismethod(obj) or inspect.iscoroutinefunction(obj):
|
||||
return "function"
|
||||
if inspect.ismodule(obj):
|
||||
return "module"
|
||||
return type(obj).__name__
|
||||
|
||||
|
||||
def unwrap_member(obj):
|
||||
if isinstance(obj, (staticmethod, classmethod)):
|
||||
return obj.__func__
|
||||
if isinstance(obj, property):
|
||||
return obj.fget
|
||||
return obj
|
||||
|
||||
|
||||
def member_kind(obj) -> str:
|
||||
if isinstance(obj, property):
|
||||
return "property"
|
||||
unwrapped = unwrap_member(obj)
|
||||
if inspect.isfunction(unwrapped) or inspect.ismethod(unwrapped):
|
||||
return "method"
|
||||
if inspect.isclass(unwrapped):
|
||||
return "class"
|
||||
return type(obj).__name__
|
||||
|
||||
|
||||
def collect_class(cls) -> dict:
|
||||
payload = {
|
||||
"kind": kind_of(cls),
|
||||
"signature": signature_payload(cls),
|
||||
"members": {},
|
||||
"enum_members": {},
|
||||
}
|
||||
enum_member_names = set(cls.__members__) if issubclass(cls, enum.Enum) else set()
|
||||
if issubclass(cls, enum.Enum):
|
||||
payload["enum_members"] = {name: member.value for name, member in cls.__members__.items()}
|
||||
if dataclasses.is_dataclass(cls):
|
||||
for field in dataclasses.fields(cls):
|
||||
payload["members"][field.name] = {
|
||||
"kind": "dataclass-field",
|
||||
"signature": None,
|
||||
}
|
||||
for base in reversed(cls.__mro__):
|
||||
if base is object:
|
||||
continue
|
||||
if not getattr(base, "__module__", "").startswith(PKG):
|
||||
continue
|
||||
for name, raw in vars(base).items():
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if name in enum_member_names:
|
||||
continue
|
||||
if name in payload["members"]:
|
||||
continue
|
||||
target = unwrap_member(raw)
|
||||
payload["members"][name] = {
|
||||
"kind": member_kind(raw),
|
||||
"signature": signature_payload(target),
|
||||
}
|
||||
if cls.__module__ == f"{PKG}.client" and cls.__name__ == "NotebookLMClient":
|
||||
from notebooklm.auth import AuthTokens
|
||||
|
||||
instance = cls(
|
||||
AuthTokens(
|
||||
cookies={"SID": "compat-audit"},
|
||||
csrf_token="compat-audit",
|
||||
session_id="compat-audit",
|
||||
)
|
||||
)
|
||||
for name in vars(instance):
|
||||
if not name.startswith("_"):
|
||||
payload["members"].setdefault(
|
||||
name,
|
||||
{
|
||||
"kind": "instance-attribute",
|
||||
"signature": None,
|
||||
},
|
||||
)
|
||||
if name not in CLIENT_NAMESPACE_ATTRIBUTES:
|
||||
continue
|
||||
subclient = getattr(instance, name)
|
||||
for base in reversed(type(subclient).__mro__):
|
||||
if base is object:
|
||||
continue
|
||||
if not getattr(base, "__module__", "").startswith(PKG):
|
||||
continue
|
||||
for child_name, raw in vars(base).items():
|
||||
if child_name.startswith("_"):
|
||||
continue
|
||||
target = unwrap_member(raw)
|
||||
payload["members"][f"{name}.{child_name}"] = {
|
||||
"kind": member_kind(raw),
|
||||
"signature": signature_payload(target),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def collect_module(module_name: str) -> dict:
|
||||
module = importlib.import_module(module_name)
|
||||
has_all = hasattr(module, "__all__")
|
||||
if not has_all and ENFORCE_ALL:
|
||||
# Every discovered public top-level module MUST declare ``__all__`` so a
|
||||
# brand-new public module cannot ship un-baselined (its surface would
|
||||
# otherwise be invisible to this audit). The presence flag was captured
|
||||
# historically but never enforced; enforce it now (issue #1493) — but
|
||||
# only for the current checkout (ENFORCE_ALL), never for an older
|
||||
# baseline that predates the rule.
|
||||
raise RuntimeError(
|
||||
f"public module {module_name!r} must declare __all__ "
|
||||
"(every public top-level module defines its exported surface so "
|
||||
"the compat audit can baseline it)"
|
||||
)
|
||||
all_names = list(getattr(module, "__all__", []))
|
||||
extra_names = list(EXTRA_PUBLIC_NAMES.get(module_name, []))
|
||||
names = []
|
||||
for name in [*all_names, *extra_names]:
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
payload = {"exports": {}, "has_all": has_all}
|
||||
for name in names:
|
||||
try:
|
||||
value = getattr(module, name)
|
||||
except AttributeError:
|
||||
if name in extra_names and name not in all_names:
|
||||
continue
|
||||
raise
|
||||
entry = {
|
||||
"kind": kind_of(value),
|
||||
"signature": signature_payload(value)
|
||||
if (
|
||||
inspect.isfunction(value)
|
||||
or inspect.ismethod(value)
|
||||
or inspect.iscoroutinefunction(value)
|
||||
)
|
||||
else None,
|
||||
}
|
||||
if inspect.isclass(value):
|
||||
entry.update(collect_class(value))
|
||||
payload["exports"][name] = entry
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
modules = discover_modules()
|
||||
manifest = {"modules": {}}
|
||||
errors = []
|
||||
for module_name in modules:
|
||||
try:
|
||||
manifest["modules"][module_name] = collect_module(module_name)
|
||||
except Exception as exc:
|
||||
errors.append(f"{module_name}: {type(exc).__name__}: {exc}")
|
||||
if errors:
|
||||
print(json.dumps({"errors": errors}, sort_keys=True))
|
||||
raise SystemExit(2)
|
||||
print(json.dumps(manifest, sort_keys=True))
|
||||
|
||||
|
||||
main()
|
||||
"""
|
||||
|
||||
|
||||
_OBJECT_SENTINEL_REPR_RE = re.compile(r"<object object at 0x[0-9a-fA-F]+>")
|
||||
|
||||
|
||||
def normalize_default_repr(default_repr: str | None) -> str | None:
|
||||
"""Collapse a bare object() sentinel default repr to an address-free form.
|
||||
|
||||
A bare object() sentinel default (e.g. the wait_for_completion
|
||||
initial_interval sentinel) reprs as <object object at 0xADDR>; the hex
|
||||
address differs between the baseline collector process and the current one,
|
||||
so identical code would otherwise read as a changed default. Only the bare
|
||||
object() sentinel (matching the whole repr) is normalized, so two
|
||||
same-identity sentinels compare equal while every other default — including
|
||||
an address-bearing instance or function repr — is left intact and a genuine
|
||||
change is still caught.
|
||||
"""
|
||||
if default_repr is None:
|
||||
return None
|
||||
if _OBJECT_SENTINEL_REPR_RE.fullmatch(default_repr):
|
||||
return "<object object at 0x...>"
|
||||
return default_repr
|
||||
|
||||
|
||||
def collect_manifest(
|
||||
source_root: Path,
|
||||
extra_public_names: dict[str, list[str]] | None = None,
|
||||
*,
|
||||
enforce_all: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the collector in a clean Python process for ``source_root``.
|
||||
|
||||
``enforce_all`` gates the "every public module declares ``__all__``" rule
|
||||
(issue #1493): pass ``True`` for the current checkout and ``False`` for a
|
||||
historical baseline that predates the rule, so baseline collection never
|
||||
aborts before the diff.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
existing_pythonpath = env.get("PYTHONPATH")
|
||||
pythonpath = str(source_root / "src")
|
||||
if existing_pythonpath:
|
||||
pythonpath = pythonpath + os.pathsep + existing_pythonpath
|
||||
env["PYTHONPATH"] = pythonpath
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
_COLLECTOR,
|
||||
str(source_root),
|
||||
json.dumps(extra_public_names or {}, sort_keys=True),
|
||||
json.dumps(CLIENT_NAMESPACE_ATTRIBUTES),
|
||||
PUBLIC_PACKAGE,
|
||||
json.dumps(sorted(EXCLUDED_TOP_LEVEL_MODULES)),
|
||||
json.dumps(EXTRA_PUBLIC_PACKAGES),
|
||||
"1" if enforce_all else "0",
|
||||
],
|
||||
cwd=source_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
message = result.stderr.strip() or result.stdout.strip()
|
||||
if result.stdout.strip():
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
errors = payload.get("errors") if isinstance(payload, dict) else None
|
||||
if isinstance(errors, list):
|
||||
message = "; ".join(str(error) for error in errors)
|
||||
raise RuntimeError(f"public API collection failed for {source_root}: {message}")
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"public API collection returned invalid JSON for {source_root}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _has_kind(params: list[dict[str, Any]], kind: str) -> bool:
|
||||
return any(param["kind"] == kind for param in params)
|
||||
|
||||
|
||||
def _accepts_keyword(param: dict[str, Any]) -> bool:
|
||||
return param["kind"] in {"POSITIONAL_OR_KEYWORD", "KEYWORD_ONLY", "VAR_KEYWORD"}
|
||||
|
||||
|
||||
def _accepts_positional(param: dict[str, Any]) -> bool:
|
||||
return param["kind"] in {
|
||||
"POSITIONAL_ONLY",
|
||||
"POSITIONAL_OR_KEYWORD",
|
||||
"VAR_POSITIONAL",
|
||||
}
|
||||
|
||||
|
||||
def _signature_breakage(old: dict[str, Any] | None, new: dict[str, Any] | None) -> str | None:
|
||||
"""Return a short incompatibility reason, or ``None`` when old calls still fit."""
|
||||
if old is None or new is None:
|
||||
if old != new:
|
||||
return f"signature changed from {old!r} to {new!r}"
|
||||
return None
|
||||
|
||||
old_params = old["parameters"]
|
||||
new_params = new["parameters"]
|
||||
old_by_name = {param["name"]: param for param in old_params}
|
||||
new_by_name = {param["name"]: param for param in new_params}
|
||||
new_has_var_keyword = _has_kind(new_params, "VAR_KEYWORD")
|
||||
new_has_var_positional = _has_kind(new_params, "VAR_POSITIONAL")
|
||||
|
||||
for old_param in old_params:
|
||||
kind = old_param["kind"]
|
||||
name = old_param["name"]
|
||||
if kind == "VAR_POSITIONAL" and not new_has_var_positional:
|
||||
return f"old signature accepted *{name}, new signature does not"
|
||||
if kind == "VAR_KEYWORD" and not new_has_var_keyword:
|
||||
return f"old signature accepted **{name}, new signature does not"
|
||||
if _accepts_keyword(old_param):
|
||||
new_param = new_by_name.get(name)
|
||||
if new_param is None:
|
||||
if not new_has_var_keyword:
|
||||
return f"keyword parameter {name!r} was removed"
|
||||
continue
|
||||
if not _accepts_keyword(new_param):
|
||||
return f"parameter {name!r} no longer accepts keyword calls"
|
||||
if old_param["has_default"] and not new_param["has_default"]:
|
||||
return f"optional parameter {name!r} became required"
|
||||
old_default = normalize_default_repr(old_param.get("default_repr"))
|
||||
new_default = normalize_default_repr(new_param.get("default_repr"))
|
||||
if old_param["has_default"] and new_param["has_default"] and old_default != new_default:
|
||||
return f"default for parameter {name!r} changed from {old_default} to {new_default}"
|
||||
|
||||
old_positional = [param for param in old_params if _accepts_positional(param)]
|
||||
new_positional = [param for param in new_params if _accepts_positional(param)]
|
||||
if not new_has_var_positional and len(new_positional) < len(old_positional):
|
||||
return (
|
||||
f"new signature accepts only {len(new_positional)} positional argument(s); "
|
||||
f"old accepted {len(old_positional)}"
|
||||
)
|
||||
|
||||
old_fixed_positional = [param for param in old_positional if param["kind"] != "VAR_POSITIONAL"]
|
||||
new_fixed_positional = [param for param in new_positional if param["kind"] != "VAR_POSITIONAL"]
|
||||
new_fixed_names = [param["name"] for param in new_fixed_positional]
|
||||
for index, old_param in enumerate(old_fixed_positional):
|
||||
if index >= len(new_fixed_positional):
|
||||
break
|
||||
old_name = old_param["name"]
|
||||
new_name = new_fixed_positional[index]["name"]
|
||||
if old_name == new_name:
|
||||
continue
|
||||
if old_name in new_fixed_names:
|
||||
new_index = new_fixed_names.index(old_name)
|
||||
return (
|
||||
f"positional parameter {old_name!r} moved from position "
|
||||
f"{index + 1} to {new_index + 1}"
|
||||
)
|
||||
return (
|
||||
f"positional parameter {old_name!r} was replaced at position "
|
||||
f"{index + 1} by {new_name!r}"
|
||||
)
|
||||
|
||||
for new_param in new_params:
|
||||
if new_param["kind"] in {"VAR_POSITIONAL", "VAR_KEYWORD"}:
|
||||
continue
|
||||
if new_param["has_default"]:
|
||||
continue
|
||||
if new_param["name"] not in old_by_name:
|
||||
return f"new required parameter {new_param['name']!r} was added"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _return_breakage(old: dict[str, Any] | None, new: dict[str, Any] | None) -> str | None:
|
||||
"""Return a reason when the return annotation changed, else ``None``.
|
||||
|
||||
Older baselines predate return-annotation capture, so a missing
|
||||
``return_annotation`` key is treated as "unknown" and never reported — only
|
||||
an observed value-to-value change counts as a break. An annotation appearing
|
||||
where there was none before is additive and also ignored. The mirror case —
|
||||
an annotation disappearing (annotated -> unannotated) — *is* reported;
|
||||
acknowledge it via the allowlist if intentional.
|
||||
"""
|
||||
if old is None or new is None:
|
||||
return None
|
||||
if "return_annotation" not in old or "return_annotation" not in new:
|
||||
return None
|
||||
old_return = old["return_annotation"]
|
||||
new_return = new["return_annotation"]
|
||||
if old_return is None or old_return == new_return:
|
||||
return None
|
||||
return f"return annotation changed from {old_return!r} to {new_return!r}"
|
||||
|
||||
|
||||
def _compare_export(
|
||||
module_name: str,
|
||||
export_name: str,
|
||||
old: dict[str, Any],
|
||||
new: dict[str, Any],
|
||||
) -> list[ApiBreak]:
|
||||
path = f"{module_name}.{export_name}"
|
||||
breaks: list[ApiBreak] = []
|
||||
if old["kind"] != new["kind"]:
|
||||
breaks.append(
|
||||
ApiBreak(
|
||||
"changed-kind",
|
||||
path,
|
||||
f"kind changed from {old['kind']!r} to {new['kind']!r}",
|
||||
)
|
||||
)
|
||||
return breaks
|
||||
|
||||
if old["kind"] in {"function", "class", "enum"}:
|
||||
reason = _signature_breakage(old.get("signature"), new.get("signature"))
|
||||
if reason:
|
||||
breaks.append(ApiBreak("changed-signature", path, reason))
|
||||
return_reason = _return_breakage(old.get("signature"), new.get("signature"))
|
||||
if return_reason:
|
||||
breaks.append(ApiBreak("changed-return", path, return_reason))
|
||||
|
||||
for member_name, old_member in old.get("members", {}).items():
|
||||
new_member = new.get("members", {}).get(member_name)
|
||||
member_path = f"{path}.{member_name}"
|
||||
if new_member is None:
|
||||
breaks.append(
|
||||
ApiBreak(
|
||||
"removed-member",
|
||||
member_path,
|
||||
f"{member_path} existed in the baseline and is missing now",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if old_member["kind"] != new_member["kind"]:
|
||||
breaks.append(
|
||||
ApiBreak(
|
||||
"changed-member-kind",
|
||||
member_path,
|
||||
f"kind changed from {old_member['kind']!r} to {new_member['kind']!r}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
reason = _signature_breakage(old_member.get("signature"), new_member.get("signature"))
|
||||
if reason:
|
||||
breaks.append(ApiBreak("changed-signature", member_path, reason))
|
||||
return_reason = _return_breakage(old_member.get("signature"), new_member.get("signature"))
|
||||
if return_reason:
|
||||
breaks.append(ApiBreak("changed-return", member_path, return_reason))
|
||||
|
||||
for enum_name, old_value in old.get("enum_members", {}).items():
|
||||
enum_members = new.get("enum_members", {})
|
||||
enum_path = f"{path}.{enum_name}"
|
||||
if enum_name not in enum_members:
|
||||
breaks.append(
|
||||
ApiBreak(
|
||||
"removed-enum-member",
|
||||
enum_path,
|
||||
f"{enum_path} existed in the baseline and is missing now",
|
||||
)
|
||||
)
|
||||
elif enum_members[enum_name] != old_value:
|
||||
breaks.append(
|
||||
ApiBreak(
|
||||
"changed-enum-value",
|
||||
enum_path,
|
||||
f"value changed from {old_value!r} to {enum_members[enum_name]!r}",
|
||||
)
|
||||
)
|
||||
|
||||
return breaks
|
||||
|
||||
|
||||
def compare_manifests(baseline: dict[str, Any], current: dict[str, Any]) -> list[ApiBreak]:
|
||||
"""Return public API breaks from ``baseline`` to ``current``."""
|
||||
breaks: list[ApiBreak] = []
|
||||
current_modules = current.get("modules", {})
|
||||
for module_name, old_module in sorted(baseline.get("modules", {}).items()):
|
||||
new_module = current_modules.get(module_name)
|
||||
if new_module is None:
|
||||
breaks.append(
|
||||
ApiBreak(
|
||||
"removed-module",
|
||||
module_name,
|
||||
f"public module {module_name} existed in the baseline and is missing now",
|
||||
)
|
||||
)
|
||||
continue
|
||||
for export_name, old_export in sorted(old_module.get("exports", {}).items()):
|
||||
new_export = new_module.get("exports", {}).get(export_name)
|
||||
export_path = f"{module_name}.{export_name}"
|
||||
if new_export is None:
|
||||
breaks.append(
|
||||
ApiBreak(
|
||||
"removed-export",
|
||||
export_path,
|
||||
f"{export_path} existed in the baseline public surface and is missing now",
|
||||
)
|
||||
)
|
||||
continue
|
||||
breaks.extend(_compare_export(module_name, export_name, old_export, new_export))
|
||||
return sorted(breaks, key=lambda item: (item.code, item.object, item.detail))
|
||||
|
||||
|
||||
def load_policy(path: Path | None) -> tuple[list[Allowance], dict[str, list[str]]]:
|
||||
if path is None or str(path) == "":
|
||||
return [], {}
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"allowlist file not found: {path}")
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"invalid JSON in {path}: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"{path} must contain a JSON object")
|
||||
schema_version = payload.get("schema_version", 1)
|
||||
if schema_version != 1:
|
||||
raise RuntimeError(f"{path}: unsupported schema_version {schema_version!r} (expected 1)")
|
||||
entries = payload.get("allowed_breaks", [])
|
||||
if not isinstance(entries, list):
|
||||
raise RuntimeError(f"{path} must contain an 'allowed_breaks' list")
|
||||
extra_public_names = payload.get("extra_public_names", {})
|
||||
if not isinstance(extra_public_names, dict):
|
||||
raise RuntimeError(f"{path} extra_public_names must be an object")
|
||||
normalized_extra_names: dict[str, list[str]] = {}
|
||||
for module_name, names in extra_public_names.items():
|
||||
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
|
||||
raise RuntimeError(
|
||||
f"{path} extra_public_names[{module_name!r}] must be a list of strings"
|
||||
)
|
||||
normalized_extra_names[str(module_name)] = sorted(set(names), key=str.lower)
|
||||
|
||||
allowances: list[Allowance] = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
raise RuntimeError(f"{path}: allowed_breaks[{index}] must be an object")
|
||||
try:
|
||||
code = str(entry["code"])
|
||||
obj = str(entry["object"])
|
||||
reason = str(entry["reason"])
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(
|
||||
f"{path}: allowed_breaks[{index}] is missing required key {exc.args[0]!r}"
|
||||
) from exc
|
||||
allowances.append(Allowance(code=code, object=obj, reason=reason))
|
||||
return allowances, normalized_extra_names
|
||||
|
||||
|
||||
def partition_allowed(
|
||||
breakages: list[ApiBreak],
|
||||
allowances: list[Allowance],
|
||||
) -> tuple[list[ApiBreak], list[tuple[ApiBreak, Allowance]]]:
|
||||
unapproved: list[ApiBreak] = []
|
||||
approved: list[tuple[ApiBreak, Allowance]] = []
|
||||
for breakage in breakages:
|
||||
allowance = next((item for item in allowances if item.matches(breakage)), None)
|
||||
if allowance is None:
|
||||
unapproved.append(breakage)
|
||||
else:
|
||||
approved.append((breakage, allowance))
|
||||
return unapproved, approved
|
||||
|
||||
|
||||
def _sibling_object(obj: str) -> str | None:
|
||||
"""Return the other path-view of an exported object, or ``None``.
|
||||
|
||||
The audit records the same client-namespace callable under two dotted
|
||||
paths: the re-export view ``notebooklm.X`` and the defining-module view
|
||||
``notebooklm.client.X``. An allowance is written against one view; this maps
|
||||
between them so the staleness check can treat the pair as a single unit. A
|
||||
glob object (containing ``*``) is left for the caller to handle — the
|
||||
returned sibling is a literal string and is only consulted via an exact
|
||||
lookup, so a glob's sibling never spuriously matches.
|
||||
"""
|
||||
client_prefix = f"{PUBLIC_PACKAGE}.client."
|
||||
bare_prefix = f"{PUBLIC_PACKAGE}."
|
||||
if obj.startswith(client_prefix):
|
||||
return bare_prefix + obj[len(client_prefix) :]
|
||||
if obj.startswith(bare_prefix):
|
||||
return client_prefix + obj[len(bare_prefix) :]
|
||||
return None
|
||||
|
||||
|
||||
def stale_allowances(
|
||||
breakages: list[ApiBreak],
|
||||
allowances: list[Allowance],
|
||||
) -> list[Allowance]:
|
||||
"""Return allowances that match no current break against the baseline.
|
||||
|
||||
An allowance is *stale* when it describes a break already baked into the
|
||||
baseline (so it no longer surfaces as a break against it). Such entries are
|
||||
dead weight: harmless to the gate, but the set only ever grows. Pruning them
|
||||
at each release boundary keeps the allowlist scoped to the breaks pending
|
||||
the *next* release (see ``docs/releasing.md`` → prune-allowlist-at-release).
|
||||
|
||||
Pair-aware rule: the two path-views ``notebooklm.X`` and
|
||||
``notebooklm.client.X`` of the same callable are treated as one unit — a
|
||||
unit is live (kept) if *either* view matches a break. So a non-stale
|
||||
allowance is one that itself matches a break, or whose sibling path-view has
|
||||
*any* matching allowance. Today both views always match together, but a
|
||||
future change that only one view detects must not flag its still load-bearing
|
||||
sibling.
|
||||
"""
|
||||
# Per-allowance self-match, keyed by (code, object) so two allowances on the
|
||||
# same object but different codes never collapse onto one another.
|
||||
self_matched: dict[tuple[str, str], bool] = {
|
||||
(allowance.code, allowance.object): any(
|
||||
allowance.matches(breakage) for breakage in breakages
|
||||
)
|
||||
for allowance in allowances
|
||||
}
|
||||
# Per-object aggregate for the sibling lookup: an object is "kept" if *any*
|
||||
# of its allowances (any code) matches a break. The pair stays live as long
|
||||
# as the sibling object has a live allowance, regardless of code.
|
||||
object_kept: dict[str, bool] = {}
|
||||
for (_code, obj), is_match in self_matched.items():
|
||||
object_kept[obj] = object_kept.get(obj, False) or is_match
|
||||
|
||||
def _is_live(allowance: Allowance) -> bool:
|
||||
if self_matched[(allowance.code, allowance.object)]:
|
||||
return True
|
||||
sibling = _sibling_object(allowance.object)
|
||||
return sibling is not None and object_kept.get(sibling, False)
|
||||
|
||||
return [allowance for allowance in allowances if not _is_live(allowance)]
|
||||
|
||||
|
||||
def _render_stale(stale: list[Allowance], baseline_ref: str, allowlist_path: Path | str) -> str:
|
||||
lines = [
|
||||
f"Stale allowlist entries — they match no break against the {baseline_ref} "
|
||||
"baseline, so they are already in the baseline:",
|
||||
]
|
||||
for allowance in stale:
|
||||
lines.append(f" - [{allowance.code}] {allowance.object}")
|
||||
lines.append(
|
||||
f"Prune them from {allowlist_path} (see docs/releasing.md → prune-allowlist-at-release)."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_breakages(title: str, breakages: list[ApiBreak]) -> str:
|
||||
if not breakages:
|
||||
return ""
|
||||
lines = [title]
|
||||
for breakage in breakages:
|
||||
lines.append(f" - [{breakage.code}] {breakage.object}: {breakage.detail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_approved(approved: list[tuple[ApiBreak, Allowance]]) -> str:
|
||||
if not approved:
|
||||
return ""
|
||||
lines = ["Allowlisted compatibility breaks:"]
|
||||
for breakage, allowance in approved:
|
||||
lines.append(f" - [{breakage.code}] {breakage.object}: {allowance.reason}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare the public Python API against a previous release tag."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline-ref",
|
||||
default=None,
|
||||
help=(
|
||||
"Git ref to compare against. Defaults to the latest reachable stable "
|
||||
"release tag (pre-release aN/bN/rcN and non-release tags are skipped)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowlist",
|
||||
default=str(repo_root / DEFAULT_ALLOWLIST),
|
||||
help=(
|
||||
"JSON file containing reviewed compatibility breaks. Use an empty string to disable."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable output.")
|
||||
parser.add_argument(
|
||||
"--check-stale",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Also fail when an allowlist entry matches no break against the "
|
||||
"baseline (it is already in the baseline — prune it)."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
baseline_ref = args.baseline_ref or latest_release_tag(repo_root)
|
||||
allowlist_path = Path(args.allowlist) if args.allowlist else None
|
||||
allowances, extra_public_names = load_policy(allowlist_path)
|
||||
with tempfile.TemporaryDirectory(prefix="notebooklm-api-compat-") as tmp:
|
||||
baseline_root = export_git_ref(repo_root, baseline_ref, Path(tmp))
|
||||
# Enforce the __all__ rule only for the current checkout; an older
|
||||
# baseline may legitimately predate it (issue #1493 review).
|
||||
baseline_manifest = collect_manifest(
|
||||
baseline_root, extra_public_names, enforce_all=False
|
||||
)
|
||||
current_manifest = collect_manifest(repo_root, extra_public_names, enforce_all=True)
|
||||
|
||||
breakages = compare_manifests(baseline_manifest, current_manifest)
|
||||
unapproved, approved = partition_allowed(breakages, allowances)
|
||||
stale = stale_allowances(breakages, allowances)
|
||||
except RuntimeError as exc:
|
||||
if args.json:
|
||||
print(json.dumps({"error": str(exc)}, sort_keys=True))
|
||||
else:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
allowlist_display = allowlist_path or DEFAULT_ALLOWLIST
|
||||
# ``--check-stale`` promotes stale entries from informational to a gate.
|
||||
stale_blocks = args.check_stale and bool(stale)
|
||||
failed = bool(unapproved) or stale_blocks
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"baseline_ref": baseline_ref,
|
||||
"approved": [
|
||||
{"break": asdict(breakage), "reason": allowance.reason}
|
||||
for breakage, allowance in approved
|
||||
],
|
||||
"unapproved": [asdict(item) for item in unapproved],
|
||||
"stale_allowances": [asdict(allowance) for allowance in stale],
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 1 if failed else 0
|
||||
|
||||
if unapproved:
|
||||
print(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
Public API compatibility audit failed.
|
||||
Baseline: {baseline_ref}
|
||||
|
||||
{_render_breakages("Unapproved compatibility breaks:", unapproved)}
|
||||
|
||||
Add back-compat shims, or document an intentional break in
|
||||
{allowlist_display} with a reviewer-readable reason.
|
||||
"""
|
||||
).strip(),
|
||||
file=sys.stderr,
|
||||
)
|
||||
approved_text = _render_approved(approved)
|
||||
if approved_text:
|
||||
print("\n" + approved_text, file=sys.stderr)
|
||||
elif stale_blocks:
|
||||
# Compat surface is clean, but stale allowlist entries fail the gate
|
||||
# under --check-stale. Don't print an "OK:" line that contradicts the
|
||||
# non-zero exit; the stale report below carries the actionable message.
|
||||
print(
|
||||
f"Public API is compatible with {baseline_ref} "
|
||||
f"({len(approved)} reviewed break(s) allowlisted), "
|
||||
"but the allowlist has stale entries.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"OK: public API is compatible with {baseline_ref} "
|
||||
f"({len(approved)} reviewed break(s) allowlisted)."
|
||||
)
|
||||
approved_text = _render_approved(approved)
|
||||
if approved_text:
|
||||
print(approved_text)
|
||||
|
||||
if stale_blocks:
|
||||
print("\n" + _render_stale(stale, baseline_ref, allowlist_display), file=sys.stderr)
|
||||
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate a snapshot of the test-suite inventory: counts, buckets,
|
||||
residual ``_core``/`rpc_call` migration debt, skip/xfail tallies, biggest
|
||||
files.
|
||||
|
||||
Run from a contributor checkout::
|
||||
|
||||
uv run python scripts/audit_test_suite.py
|
||||
|
||||
Designed to be re-run after every PR so reviewers can compare numbers
|
||||
against the previous run without rebuilding the analysis machinery in
|
||||
ad-hoc grep snippets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TESTS = REPO_ROOT / "tests"
|
||||
|
||||
|
||||
def banner(title: str) -> None:
|
||||
print(f"\n=== {title} ===")
|
||||
|
||||
|
||||
def collect_total() -> tuple[int, int]:
|
||||
# Invoke pytest via the CURRENT interpreter (``sys.executable -m pytest``)
|
||||
# rather than ``uv run pytest``. The latter re-enters uv, which requires
|
||||
# uv being on PATH and a writable uv cache dir — neither is guaranteed
|
||||
# when the script is run from a vendored venv, a sandboxed CI runner, or
|
||||
# ``python -m scripts.audit_test_suite``. ``-m pytest`` stays in-process
|
||||
# with the venv that imported us, so the collection number is always
|
||||
# consistent with the interpreter actually running this script.
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "--collect-only", "-q", "--no-header"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
# ``tests?`` covers the singular form pytest emits when the suite has
|
||||
# exactly one collected test.
|
||||
match = re.search(r"(\d+)\s+tests?\s+collected", result.stdout)
|
||||
if not match:
|
||||
raise RuntimeError(
|
||||
"pytest --collect-only failed or produced unexpected output.\n"
|
||||
f" returncode: {result.returncode}\n"
|
||||
f" stdout (tail): {result.stdout[-500:]}\n"
|
||||
f" stderr (tail): {result.stderr[-500:]}"
|
||||
)
|
||||
test_count = int(match.group(1))
|
||||
file_count = sum(1 for _ in TESTS.rglob("test_*.py"))
|
||||
return test_count, file_count
|
||||
|
||||
|
||||
def bucket_counts() -> list[tuple[str, int]]:
|
||||
buckets = [
|
||||
("tests/unit/ (root)", TESTS / "unit", False),
|
||||
("tests/unit/cli/", TESTS / "unit" / "cli", True),
|
||||
("tests/unit/concurrency/", TESTS / "unit" / "concurrency", True),
|
||||
("tests/integration/ (root)", TESTS / "integration", False),
|
||||
("tests/integration/concurrency/", TESTS / "integration" / "concurrency", True),
|
||||
("tests/integration/cli_vcr/", TESTS / "integration" / "cli_vcr", True),
|
||||
("tests/e2e/", TESTS / "e2e", True),
|
||||
("tests/_guardrails/", TESTS / "_guardrails", True),
|
||||
]
|
||||
out = []
|
||||
for label, path, recursive in buckets:
|
||||
if recursive:
|
||||
count = sum(1 for _ in path.rglob("test_*.py")) if path.exists() else 0
|
||||
else:
|
||||
count = sum(1 for p in path.glob("test_*.py") if p.is_file()) if path.exists() else 0
|
||||
out.append((label, count))
|
||||
return out
|
||||
|
||||
|
||||
def cassettes() -> tuple[int, int]:
|
||||
root = TESTS / "cassettes"
|
||||
if not root.exists():
|
||||
return 0, 0
|
||||
files = [p for p in root.rglob("*") if p.is_file()]
|
||||
return len(files), sum(p.stat().st_size for p in files)
|
||||
|
||||
|
||||
def fmt_bytes(n: float) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
def _core_module_aliases(tree: ast.AST) -> set[str]:
|
||||
aliases: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name == "notebooklm._core":
|
||||
aliases.add(alias.asname or alias.name.split(".")[-1])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module == "notebooklm":
|
||||
for alias in node.names:
|
||||
if alias.name == "_core":
|
||||
aliases.add(alias.asname or "_core")
|
||||
return aliases
|
||||
|
||||
|
||||
def scan_core_monkeypatches() -> tuple[list[tuple[Path, int, str]], list[tuple[Path, int, str]]]:
|
||||
"""Both string-target AND object-target monkeypatches of ``notebooklm._core``."""
|
||||
string_hits: list[tuple[Path, int, str]] = []
|
||||
object_hits: list[tuple[Path, int, str]] = []
|
||||
|
||||
for path in TESTS.rglob("*.py"):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
aliases = _core_module_aliases(tree)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "setattr"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id == "monkeypatch"
|
||||
):
|
||||
continue
|
||||
if not node.args:
|
||||
continue
|
||||
first = node.args[0]
|
||||
|
||||
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
||||
if first.value.startswith("notebooklm._core."):
|
||||
string_hits.append((path, node.lineno, first.value))
|
||||
continue
|
||||
|
||||
if isinstance(first, ast.Name) and first.id in aliases:
|
||||
attr = "?"
|
||||
if len(node.args) > 1 and isinstance(node.args[1], ast.Constant):
|
||||
attr = str(node.args[1].value)
|
||||
object_hits.append((path, node.lineno, f"{first.id}.{attr}"))
|
||||
|
||||
return string_hits, object_hits
|
||||
|
||||
|
||||
def _is_comment_line(lines: list[str], lineno: int) -> bool:
|
||||
"""True when ``lineno`` (1-based) is a line whose first non-whitespace
|
||||
character is ``#``. Used to filter false-positive matches sitting inside
|
||||
commented-out code samples without rejecting matches whose surrounding
|
||||
code happens to wrap across lines."""
|
||||
if 1 <= lineno <= len(lines):
|
||||
return lines[lineno - 1].lstrip().startswith("#")
|
||||
return False
|
||||
|
||||
|
||||
def _lineno_at(content: str, offset: int) -> int:
|
||||
"""1-based line number for a character offset inside ``content``."""
|
||||
return content.count("\n", 0, offset) + 1
|
||||
|
||||
|
||||
def scan_rpc_call_axis() -> tuple[list[tuple[Path, int]], set[Path]]:
|
||||
"""``rpc_call = AsyncMock`` / ``monkeypatch.setattr(..., "rpc_call", ...)``.
|
||||
|
||||
Uses ``re.finditer`` on the full file content rather than line-by-line
|
||||
matching so that a formatter wrapping a long monkeypatch call across
|
||||
multiple lines is still counted. ``AsyncMock`` is matched via a
|
||||
dotted-prefix prefix (``mock.AsyncMock``, ``unittest.mock.AsyncMock``,
|
||||
bare ``AsyncMock``) to track all canonical assignment shapes.
|
||||
"""
|
||||
hits: list[tuple[Path, int]] = []
|
||||
assign_pattern = re.compile(r"\S+\.rpc_call\s*=\s*(?:\w+\.)*AsyncMock")
|
||||
mp_pattern = re.compile(r'monkeypatch\.setattr\(\s*[^,]+,\s*["\']rpc_call["\']')
|
||||
|
||||
for path in TESTS.rglob("test_*.py"):
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
lines = content.splitlines()
|
||||
for pattern in (assign_pattern, mp_pattern):
|
||||
for m in pattern.finditer(content):
|
||||
lineno = _lineno_at(content, m.start())
|
||||
if _is_comment_line(lines, lineno):
|
||||
continue
|
||||
hits.append((path, lineno))
|
||||
|
||||
files = {p for p, _ in hits}
|
||||
return hits, files
|
||||
|
||||
|
||||
def scan_skipped() -> list[tuple[Path, int, str]]:
|
||||
"""``pytest.mark.{skip,skipif,xfail}`` references.
|
||||
|
||||
Drops the leading ``@`` so module-level ``pytestmark = pytest.mark.skip
|
||||
(...)`` and ``pytestmark = [pytest.mark.skipif(...), ...]`` shapes are
|
||||
counted alongside the decorator form. Commented-out markers
|
||||
(``# @pytest.mark.skip``) are filtered out via line-prefix check.
|
||||
"""
|
||||
hits: list[tuple[Path, int, str]] = []
|
||||
pattern = re.compile(r"pytest\.mark\.(skip|skipif|xfail)\b")
|
||||
for path in TESTS.rglob("test_*.py"):
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
lines = content.splitlines()
|
||||
for m in pattern.finditer(content):
|
||||
lineno = _lineno_at(content, m.start())
|
||||
if _is_comment_line(lines, lineno):
|
||||
continue
|
||||
hits.append((path, lineno, m.group(1)))
|
||||
return hits
|
||||
|
||||
|
||||
def big_files(top_n: int = 8) -> list[tuple[Path, int, int]]:
|
||||
rows = []
|
||||
test_def = re.compile(r"^\s*(async\s+)?def\s+test_")
|
||||
for path in TESTS.rglob("test_*.py"):
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
n_tests = sum(1 for line in lines if test_def.match(line))
|
||||
rows.append((path, len(lines), n_tests))
|
||||
rows.sort(key=lambda r: -r[1])
|
||||
return rows[:top_n]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
banner("Suite shape")
|
||||
total, files = collect_total()
|
||||
print(f"Total tests collected (pytest): {total:>6}")
|
||||
print(f"Total test_*.py files (find): {files:>6}")
|
||||
cas_n, cas_b = cassettes()
|
||||
print(f"Cassettes: {cas_n:>6} files {fmt_bytes(cas_b)}")
|
||||
|
||||
banner("Files per bucket")
|
||||
bucket_total = 0
|
||||
for label, n in bucket_counts():
|
||||
print(f" {label:<38} {n:>4}")
|
||||
bucket_total += n
|
||||
print(f" {'sum':<38} {bucket_total:>4}")
|
||||
if bucket_total != files:
|
||||
print(f" ! mismatch: bucket-sum {bucket_total} vs find total {files}")
|
||||
|
||||
banner("_core.X monkeypatch migration debt")
|
||||
string_hits, object_hits = scan_core_monkeypatches()
|
||||
print(f"String-target monkeypatch.setattr('notebooklm._core.X', ...): {len(string_hits)}")
|
||||
for path, lineno, tgt in string_hits:
|
||||
print(f" {path.relative_to(REPO_ROOT)}:{lineno} -> {tgt}")
|
||||
print(f"Object-target monkeypatch.setattr(_core_alias, 'X', ...): {len(object_hits)}")
|
||||
for path, lineno, tgt in object_hits:
|
||||
print(f" {path.relative_to(REPO_ROOT)}:{lineno} -> {tgt}")
|
||||
if not string_hits and not object_hits:
|
||||
print(" (none — _core.py demolition complete)")
|
||||
|
||||
banner("rpc_call = AsyncMock axis (plan PR 9 cohort)")
|
||||
rpc_hits, rpc_files = scan_rpc_call_axis()
|
||||
print(f"Assignment / monkeypatch sites: {len(rpc_hits)}")
|
||||
print(f"Files touched: {len(rpc_files)}")
|
||||
|
||||
banner("Skipped / xfailed / skipif")
|
||||
skip_hits = scan_skipped()
|
||||
by_kind = Counter(kind for _, _, kind in skip_hits)
|
||||
for kind, n in sorted(by_kind.items()):
|
||||
print(f" pytest.mark.{kind:<10} {n}")
|
||||
print(f" total {len(skip_hits)}")
|
||||
|
||||
banner("Big files (top 8 by line count)")
|
||||
print(f" {'lines':>6} {'def test_':>10} path")
|
||||
for path, n_lines, n_tests in big_files():
|
||||
print(f" {n_lines:>6} {n_tests:>10} {path.relative_to(REPO_ROOT)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture NotebookLM's live RPC id registry from the web bundle and diff it
|
||||
against ``src/notebooklm/rpc/types.py``.
|
||||
|
||||
NotebookLM declares every ``batchexecute`` RPC in its (public, gstatic-served) JS
|
||||
bundle as::
|
||||
|
||||
_.fD("<rpc_id>", <ReqCtor>, <RespCtor>, [<flags>, "/<Service>.<Method>"])
|
||||
|
||||
(The registration helper is currently minified to ``_.fD``; it was ``_.uD`` in an
|
||||
earlier bundle. The scraper does **not** depend on the helper name — it anchors on
|
||||
the quoted ``"/<Service>.<Method>"`` path — so a future rename of this helper does
|
||||
not blank the diff.)
|
||||
|
||||
The obfuscated ``<rpc_id>`` values are this project's #1 breakage class — they
|
||||
rotate without notice and a stale id silently breaks the affected operation. This
|
||||
script extracts the live ``id -> /Service.Method`` map and diffs it against the
|
||||
ids we hardcode, surfacing four classes:
|
||||
|
||||
* CONFIRMED — our id is still registered (shown with its decoded method name)
|
||||
* ABSENT — our id no longer appears in the bundle at all (rotation/stale — the alarm)
|
||||
* PRESENT-UNPARSED— our id string is in the bundle but its registration form wasn't
|
||||
parsed (not a rotation; a parser gap to widen, not an alert)
|
||||
* UNMAPPED — a live RPC the bundle declares that we don't expose, grouped by
|
||||
service family: **current** (old `LabsTailwind*` consumer backend
|
||||
— callable on our cohort now, just unexposed), **enterprise** (the
|
||||
Discovery-Engine domain services — the NotebookLM Enterprise /
|
||||
Agentspace surface on `discoveryengine.googleapis.com`, behind a
|
||||
server-side VPC Service Controls perimeter; not consumer-callable,
|
||||
not a consumer migration target), or **other**
|
||||
|
||||
Beyond the rpc-id registry, the same bundle carries the studio-feature **enum
|
||||
maps** (``switch(code){case N:return "Label"}`` blocks for VideoFormat /
|
||||
AudioFormat / app-variants), the ``Yp`` **quota-code** map (a feature-rollout
|
||||
early-warning surface), and proto **required-field assertions** (schema-shape
|
||||
drift). ``--check-enums`` extracts and diffs the switch enums against the int
|
||||
enums in ``rpc/types.py`` with the same four-class spirit as the id diff, but a
|
||||
distinct taxonomy — see :func:`diff_enums` for why ``NEW`` is report-only.
|
||||
|
||||
Auth: discovering the bundle URL needs **one authenticated homepage read** (an
|
||||
unauthenticated request only returns the login app); fetching the bundle itself is
|
||||
unauthenticated (public CDN). Run ``notebooklm login`` first, or pass
|
||||
``--bundle-file`` to analyse a pre-saved bundle offline (no auth/network).
|
||||
|
||||
Cohort note: the bundle is shared between the consumer NotebookLM app and the
|
||||
enterprise (Agentspace / Vertex AI Search) surface, so it registers BOTH RPC
|
||||
generations. The Discovery-Engine ids (e.g. ``AzXHBd``/``NotebookService.*``) are
|
||||
the *enterprise* surface — gated off for consumer accounts by a server-side VPC
|
||||
Service Controls perimeter (live-probed 2026-06-16: grpc 7 ``VPC_SERVICE_CONTROLS``
|
||||
/ ``CONSUMER_INVALID`` on ``discoveryengine.googleapis.com``), not a consumer
|
||||
cohort that is "about to migrate".
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/capture_rpc_registry.py # human-readable diff
|
||||
python scripts/capture_rpc_registry.py --json # machine-readable snapshot
|
||||
python scripts/capture_rpc_registry.py --check # exit 1 if any of our ids are ABSENT
|
||||
python scripts/capture_rpc_registry.py --check-enums # exit 1 on CHANGED/STALE studio enums
|
||||
python scripts/capture_rpc_registry.py --check --check-enums # both gates (combine freely)
|
||||
python scripts/capture_rpc_registry.py --bundle-file bundle.js # offline, no auth
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# The NotebookLM web app's gstatic JS namespace. If Google renames the app this
|
||||
# pattern must be updated (the script will then report "no bundle URL").
|
||||
_APP = "boq-labs-tailwind"
|
||||
_BUNDLE_URL_RE = re.compile(rf'https://www\.gstatic\.com/_/mss/{_APP}/_/js/[^"\\\s<>]+')
|
||||
|
||||
# A registration's two stable, quoted anchors: the ``/Service.Method`` path and
|
||||
# the rpc id. We anchor on the path and scan *backward* for the nearest id, which
|
||||
# is robust to nested ``[...]`` in the options array (a single forward regex
|
||||
# spanning to the path breaks on the inner ``]``). Quote-agnostic (``"`` or
|
||||
# ``'``) so a change in the bundle minifier's quote style doesn't blank the diff.
|
||||
_METHOD_PATH_RE = re.compile(r"""["'](/[A-Za-z][\w]*\.[A-Za-z][\w]*)["']""")
|
||||
_ID_TOKEN_RE = re.compile(r"""["']([A-Za-z0-9]{5,8})["']""")
|
||||
# How far back from a path string to scan for its registration id. The
|
||||
# ``_.uD(id, ReqCtor, RespCtor, [flags, path])`` form fits well within ~100 chars;
|
||||
# 160 leaves headroom for longer minified constructor names.
|
||||
_ID_LOOKBACK = 160
|
||||
|
||||
# Real obfuscated rpc ids are short alphanumerics; this filter keeps non-id enum
|
||||
# constants (e.g. ``blog_post``) out of the diff.
|
||||
_RPC_ID_RE = re.compile(r"[A-Za-z0-9]{5,8}")
|
||||
|
||||
_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138 Safari/537.36"
|
||||
|
||||
# Resolved relative to this file (scripts/ -> repo root) so the script runs from
|
||||
# any working directory, not just the repo root.
|
||||
_DEFAULT_TYPES = Path(__file__).resolve().parent.parent / "src" / "notebooklm" / "rpc" / "types.py"
|
||||
|
||||
# --- Service-family classification: consumer backend vs enterprise (Discovery Engine) ---
|
||||
# "Current" (the consumer backend serving our cohort now) is detected *empirically*:
|
||||
# any service one of our CONFIRMED ids resolves to is, by definition, working for us.
|
||||
# Past that, the known Discovery-Engine domain services are tagged "enterprise" — they
|
||||
# are the NotebookLM Enterprise / Agentspace surface (discoveryengine.googleapis.com),
|
||||
# gated off for consumer accounts by a server-side VPC Service Controls perimeter, NOT a
|
||||
# pre-migration consumer cohort. The old NotebookLM family shares the ``LabsTailwind``
|
||||
# prefix (same consumer backend, callable on our cohort even where we don't expose it);
|
||||
# anything else is "other" — itself a useful drift signal (a new, unclassified service).
|
||||
_DISCOVERY_ENGINE_SERVICES = frozenset(
|
||||
{
|
||||
"NotebookService",
|
||||
"SourceService",
|
||||
"NoteService",
|
||||
"ArtifactService",
|
||||
"AudioOverviewService",
|
||||
"AccountService",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _service_of(method_path: str) -> str:
|
||||
"""``/LabsTailwindOrchestrationService.AddSources`` -> ``LabsTailwindOrchestrationService``."""
|
||||
return method_path.lstrip("/").split(".", 1)[0]
|
||||
|
||||
|
||||
def classify_service(service: str, current_services: set[str]) -> str:
|
||||
"""Tag a service ``current`` / ``enterprise`` / ``other``.
|
||||
|
||||
``current`` = the consumer backend, works on our cohort today; ``enterprise`` =
|
||||
a Discovery-Engine domain service — the NotebookLM Enterprise / Agentspace
|
||||
surface, gated off for consumer accounts by a VPC Service Controls perimeter
|
||||
(NOT a consumer migration target); ``other`` = unclassified (investigate —
|
||||
possibly a new service). Empirical first (a service our CONFIRMED ids use is
|
||||
``current``), then the known Discovery-Engine domain services, then the old
|
||||
``LabsTailwind*`` consumer family.
|
||||
"""
|
||||
if service in current_services:
|
||||
return "current"
|
||||
if service in _DISCOVERY_ENGINE_SERVICES:
|
||||
return "enterprise"
|
||||
if service.startswith("LabsTailwind"):
|
||||
return "current"
|
||||
return "other"
|
||||
|
||||
|
||||
def parse_ids_from_text(types_text: str) -> dict[str, str]:
|
||||
"""Return ``{rpc_id: ENUM_NAME}`` for the ``RPCMethod`` enum members."""
|
||||
match = re.search(r"class RPCMethod\b.*?(?=\nclass |\Z)", types_text, re.DOTALL)
|
||||
body = match.group(0) if match else types_text
|
||||
out: dict[str, str] = {}
|
||||
for name, value in re.findall(
|
||||
r"""^\s+([A-Z][A-Z0-9_]*)\s*=\s*["']([^"']+)["']""", body, re.MULTILINE
|
||||
):
|
||||
if _RPC_ID_RE.fullmatch(value):
|
||||
out[value] = name
|
||||
return out
|
||||
|
||||
|
||||
def extract_registry(bundle: str) -> dict[str, str]:
|
||||
"""Return ``{rpc_id: /Service.Method}`` for every registration in the bundle.
|
||||
|
||||
Anchored on each ``"/Service.Method"`` path: the rpc id is the nearest
|
||||
preceding quoted short token (the registration's first argument). Scanning
|
||||
backward from the path tolerates nested brackets in the options array that a
|
||||
single forward regex cannot span.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
for match in _METHOD_PATH_RE.finditer(bundle):
|
||||
window = bundle[max(0, match.start() - _ID_LOOKBACK) : match.start()]
|
||||
ids = _ID_TOKEN_RE.findall(window)
|
||||
if ids:
|
||||
out[ids[-1]] = match.group(1)
|
||||
return out
|
||||
|
||||
|
||||
def diff(ours: dict[str, str], live: dict[str, str], bundle: str) -> dict[str, dict[str, str]]:
|
||||
"""Classify our ids vs the live registry into the four reporting buckets."""
|
||||
|
||||
def _in_bundle(rpc_id: str) -> bool:
|
||||
return f'"{rpc_id}"' in bundle or f"'{rpc_id}'" in bundle
|
||||
|
||||
confirmed = {i: live[i] for i in ours if i in live}
|
||||
present_unparsed = {i: ours[i] for i in ours if i not in live and _in_bundle(i)}
|
||||
absent = {i: ours[i] for i in ours if i not in live and not _in_bundle(i)}
|
||||
unmapped = {i: live[i] for i in live if i not in ours}
|
||||
return {
|
||||
"confirmed": confirmed,
|
||||
"present_unparsed": present_unparsed,
|
||||
"absent": absent,
|
||||
"unmapped": unmapped,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Studio enum drift (switch(code){case N:return "Label"} maps)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Beyond the rpc-id registry the bundle inlines the studio-feature enums as
|
||||
# minified ``switch`` statements mapping an integer code to a display label,
|
||||
# e.g. ``switch(a){case 1:return"Explainer";case 3:return"Cinematic";...}``.
|
||||
# These are the human-facing labels for VideoFormat / AudioFormat / the
|
||||
# app-variant picker — the same integers we hardcode in ``rpc/types.py`` and
|
||||
# send on the wire. If Google ever renumbers a *selectable* format (the #1597
|
||||
# alarm: the VideoStyle/format code an existing label maps to changes) every
|
||||
# generate-* call silently produces the wrong artifact, so we diff them.
|
||||
|
||||
# A switch block: ``switch(<scrutinee>){ <case 1:return"X";case 2:return"Y";> }``.
|
||||
# ``<scrutinee>`` is a short minified expr (kept <=30 chars so we don't span a
|
||||
# huge unrelated ``switch``); the body is one-or-more ``case N:return"Label"``.
|
||||
# Whitespace-tolerant so a minor minifier/pretty-printer change (spaces after
|
||||
# ``return``, around ``:``, between arms) doesn't yield a false UNPARSED.
|
||||
_SWITCH_BLOCK_RE = re.compile(
|
||||
r'switch\([^)]{1,30}\)\{\s*((?:case\s+\d+\s*:\s*return\s*"[^"]*"\s*;?\s*)+)'
|
||||
)
|
||||
# A single ``case N:return "Label"`` arm inside a matched block.
|
||||
_SWITCH_CASE_RE = re.compile(r'case\s+(\d+)\s*:\s*return\s*"([^"]*)"')
|
||||
|
||||
# Label-anchoring registry: a recognizable *subset* of labels identifies which
|
||||
# of our enums a switch block is. A block whose label set is a SUPERSET of an
|
||||
# anchor set is attributed to that enum (so a block that gained an unreleased
|
||||
# label still matches). Anchors are deliberately a handful of stable,
|
||||
# distinctive labels — not the full set — so a NEW label never breaks
|
||||
# attribution. The keys are our ``rpc/types.py`` enum class names.
|
||||
_ENUM_LABEL_ANCHORS: dict[str, frozenset[str]] = {
|
||||
"VideoFormat": frozenset({"Explainer", "Cinematic"}),
|
||||
"AudioFormat": frozenset({"Deep Dive", "Critique", "Debate"}),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_label(label: str) -> str:
|
||||
"""``"Deep Dive"`` -> ``"DEEP_DIVE"`` so a bundle label can be matched to an
|
||||
enum *member* name (our members are ``UPPER_SNAKE``, the bundle labels are
|
||||
``Title Case`` with spaces). Used to pair a live ``code -> label`` with our
|
||||
``MEMBER_NAME -> value`` mapping when diffing.
|
||||
"""
|
||||
return re.sub(r"[^A-Z0-9]+", "_", label.upper()).strip("_")
|
||||
|
||||
|
||||
def extract_switch_enums(bundle: str) -> dict[str, dict[int, str]]:
|
||||
"""Return ``{our_enum_name: {code: label}}`` for every switch block that a
|
||||
label anchor attributes to one of our enums.
|
||||
|
||||
Each ``switch(code){case N:return "Label"}`` block is parsed into a
|
||||
``{code -> label}`` map, then attributed to one of our enums by
|
||||
*label-anchoring*: if the block's label set is a superset of an anchor set
|
||||
in :data:`_ENUM_LABEL_ANCHORS`, it is that enum. Unattributed blocks (the
|
||||
bundle has many switches we don't care about) are dropped. If two blocks
|
||||
attribute to the same enum their maps are merged (later wins), which is
|
||||
harmless because the anchor guarantees they are the same logical enum.
|
||||
"""
|
||||
out: dict[str, dict[int, str]] = {}
|
||||
for block_match in _SWITCH_BLOCK_RE.finditer(bundle):
|
||||
cases = {int(code): label for code, label in _SWITCH_CASE_RE.findall(block_match.group(1))}
|
||||
if not cases:
|
||||
continue
|
||||
labels = set(cases.values())
|
||||
for enum_name, anchor in _ENUM_LABEL_ANCHORS.items():
|
||||
if anchor <= labels:
|
||||
out.setdefault(enum_name, {}).update(cases)
|
||||
return out
|
||||
|
||||
|
||||
def parse_enum_members_from_text(types_text: str, enum_name: str) -> dict[str, int]:
|
||||
"""Return ``{MEMBER_NAME: int_value}`` for an ``(int, Enum)`` class in types.py.
|
||||
|
||||
Mirrors :func:`parse_ids_from_text` but for the integer studio enums. Scoped
|
||||
to the named class body so members of other enums don't bleed in. Aliases
|
||||
(two names, same value — e.g. ``QUIZ_FLASHCARD = 4``) are all retained.
|
||||
"""
|
||||
match = re.search(rf"class {re.escape(enum_name)}\b.*?(?=\nclass |\Z)", types_text, re.DOTALL)
|
||||
if not match:
|
||||
return {}
|
||||
out: dict[str, int] = {}
|
||||
for name, value in re.findall(
|
||||
r"""^\s+([A-Z][A-Z0-9_]*)\s*=\s*(\d+)""", match.group(0), re.MULTILINE
|
||||
):
|
||||
out[name] = int(value)
|
||||
return out
|
||||
|
||||
|
||||
def diff_enums(
|
||||
types_text: str, live_switch: dict[str, dict[int, str]]
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
"""Diff our int enums against the bundle's switch maps into a FOUR-class taxonomy.
|
||||
|
||||
Mirroring the id ``diff`` but for the studio enums. For each enum the bundle
|
||||
attributed (via label-anchoring), each of our members is paired to a live
|
||||
``code -> label`` by normalizing the label to ``UPPER_SNAKE`` and matching it
|
||||
to a member name. The four buckets:
|
||||
|
||||
* ``CHANGED`` — a label present in BOTH our enum and the bundle but mapped to
|
||||
a DIFFERENT integer (our ``EXPLAINER = 1`` but the bundle now returns
|
||||
"Explainer" for ``case 2``). **This is the #1597 alarm**: an existing,
|
||||
selectable format silently renumbered. Fails ``--check-enums``.
|
||||
* ``STALE`` — our member's integer is not present in the bundle's code set
|
||||
for that enum at all (the format we still send was retired). Also fails
|
||||
``--check-enums``.
|
||||
* ``NEW`` — the bundle declares a code our enum lacks (a new display label).
|
||||
**REPORT-ONLY, never an alarm**: a switch arm is only a *display label*; a
|
||||
label can ship in the bundle long before the format is selectable on any
|
||||
cohort (proven live — the bundle listed Short / Whiteboard Animation /
|
||||
Lecture while they were not yet selectable). Adding the member eagerly off
|
||||
a bundle label would encode an unreleased/non-functional code.
|
||||
* ``UNPARSED`` — an enum we have a label anchor for but found no switch block
|
||||
to attribute (a recognizable region didn't parse). "Widen the regex", NOT
|
||||
an alarm — same posture as PRESENT-UNPARSED in the id diff.
|
||||
|
||||
Returns ``{class -> [records]}`` where each record carries the enum name and
|
||||
the specifics needed to report and to drive the ``--check-enums`` exit code.
|
||||
"""
|
||||
buckets: dict[str, list[dict[str, object]]] = {
|
||||
"changed": [],
|
||||
"stale": [],
|
||||
"new": [],
|
||||
"unparsed": [],
|
||||
}
|
||||
for enum_name in _ENUM_LABEL_ANCHORS:
|
||||
live_map = live_switch.get(enum_name)
|
||||
if not live_map:
|
||||
# We know this enum (we hold an anchor) but no block parsed for it.
|
||||
buckets["unparsed"].append({"enum": enum_name})
|
||||
continue
|
||||
|
||||
ours = parse_enum_members_from_text(types_text, enum_name)
|
||||
# live label (normalized) -> code, for matching our members by name.
|
||||
live_by_norm_label = {_normalize_label(label): code for code, label in live_map.items()}
|
||||
our_codes = set(ours.values())
|
||||
|
||||
for member, value in ours.items():
|
||||
live_code = live_by_norm_label.get(member)
|
||||
if live_code is None:
|
||||
# The label our member name corresponds to is not in the bundle.
|
||||
live_label = live_map.get(value)
|
||||
norm_live_label = _normalize_label(live_label) if live_label else None
|
||||
if value not in live_map or (norm_live_label in ours and norm_live_label != member):
|
||||
# STALE either because our integer code vanished from the
|
||||
# bundle entirely, OR it was repurposed: the code now maps to
|
||||
# a label that normalizes to a DIFFERENT member already in our
|
||||
# enum, so our member name still pointing at it is wrong.
|
||||
buckets["stale"].append(
|
||||
{"enum": enum_name, "member": member, "our_value": value}
|
||||
)
|
||||
# else: our value is still a live code under a label that didn't
|
||||
# normalize back to any of our member names — neither CHANGED nor
|
||||
# STALE (likely a renamed/aliased label we don't track yet).
|
||||
elif live_code != value:
|
||||
buckets["changed"].append(
|
||||
{
|
||||
"enum": enum_name,
|
||||
"member": member,
|
||||
"label": live_map[live_code],
|
||||
"our_value": value,
|
||||
"live_value": live_code,
|
||||
}
|
||||
)
|
||||
|
||||
for code, label in sorted(live_map.items()):
|
||||
if code not in our_codes:
|
||||
buckets["new"].append({"enum": enum_name, "code": code, "label": label})
|
||||
|
||||
return buckets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quota codes (Yp map) and proto required-field assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Two more drift surfaces the same bundle carries — extracted for *visibility*
|
||||
# (a report line + JSON), not gated. They are leading indicators, not contract
|
||||
# breaks: a new quota code means a feature is rolling out server-side (an
|
||||
# early-warning for "build support soon"), and a changed proto assertion means a
|
||||
# request shape we encode may have grown a newly-required field.
|
||||
|
||||
# The ``Yp`` quota map: ``[<code>,{status:"...",result:{message:"...limits..."}}]``.
|
||||
# The ``...limits...`` anchor in the message keeps this off unrelated result
|
||||
# objects. Codes map to features (1 chat, 3 audio, 6 video, 7 reports, ...).
|
||||
_QUOTA_CODE_RE = re.compile(r'\[(\d+),\{status:"[^"]*",result:\{message:"([^"]*limits[^"]*)"')
|
||||
|
||||
# Proto required-field assertions: ``"<Message> is missing field '<field>'"``.
|
||||
# A drift in this set means a request message grew/lost a required field.
|
||||
_PROTO_ASSERTION_RE = re.compile(r"\"(\w+) is missing field '(\w+)'\"")
|
||||
|
||||
|
||||
def extract_quota_codes(bundle: str) -> dict[int, str]:
|
||||
"""Return ``{quota_code: message}`` from the bundle's ``Yp`` quota map.
|
||||
|
||||
A feature-rollout early-warning surface: a code we have not seen before means
|
||||
Google is provisioning quota for a feature that is rolling out. Report-only.
|
||||
"""
|
||||
return {int(code): message for code, message in _QUOTA_CODE_RE.findall(bundle)}
|
||||
|
||||
|
||||
def extract_proto_assertions(bundle: str) -> set[tuple[str, str]]:
|
||||
"""Return ``{(message, field)}`` proto required-field assertions from the bundle.
|
||||
|
||||
A schema-shape drift surface: ``"ExplainerVideoArtifact is missing field
|
||||
'generation_options'"`` means that proto requires ``generation_options``. A
|
||||
new assertion can mean a request we build needs a field we don't send.
|
||||
Report-only.
|
||||
"""
|
||||
return set(_PROTO_ASSERTION_RE.findall(bundle))
|
||||
|
||||
|
||||
def fetch_bundle() -> str:
|
||||
"""Fetch and concatenate the gstatic app-bundle chunks (which carry the registry).
|
||||
|
||||
One authenticated homepage read discovers the bundle URLs; the chunks are then
|
||||
fetched unauthenticated from the public CDN, **sequentially** (to avoid rate
|
||||
limiting) and **concatenated**, so the scan covers the whole frontend surface
|
||||
regardless of how Google splits the registry across chunks.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from notebooklm._env import get_base_url
|
||||
from notebooklm.auth import authuser_query, load_auth_from_storage
|
||||
|
||||
def _fetch(
|
||||
url: str,
|
||||
*,
|
||||
cookies: dict[str, str] | None = None,
|
||||
follow_redirects: bool = False,
|
||||
timeout: float = 60.0,
|
||||
) -> httpx.Response:
|
||||
response = httpx.get(
|
||||
url,
|
||||
headers={"User-Agent": _UA},
|
||||
cookies=cookies,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
cookies = load_auth_from_storage()
|
||||
html = _fetch(
|
||||
f"{get_base_url()}/?{authuser_query(0)}",
|
||||
cookies=cookies,
|
||||
follow_redirects=True,
|
||||
timeout=30.0,
|
||||
).text
|
||||
urls = sorted(set(_BUNDLE_URL_RE.findall(html)))
|
||||
if not urls:
|
||||
raise SystemExit(
|
||||
f"No {_APP} bundle URL found in the homepage — not authenticated for "
|
||||
"NotebookLM? Run `notebooklm login` (or pass --bundle-file)."
|
||||
)
|
||||
# Keep only genuine JS responses: raise_for_status rejects non-200, and this
|
||||
# rejects a 200 served with the wrong content-type (e.g. an HTML login/error
|
||||
# page), which would otherwise be parsed as a bundle and make every id ABSENT.
|
||||
bodies: list[str] = []
|
||||
for url in urls:
|
||||
response = _fetch(url)
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "javascript" in content_type or "text/plain" in content_type:
|
||||
bodies.append(response.text)
|
||||
if not bodies:
|
||||
raise SystemExit(f"No readable JS bundle content fetched from the {_APP} URLs.")
|
||||
return "\n".join(bodies)
|
||||
|
||||
|
||||
def _print_report(
|
||||
ours: dict[str, str],
|
||||
live: dict[str, str],
|
||||
buckets: dict[str, dict[str, str]],
|
||||
current_services: set[str],
|
||||
) -> None:
|
||||
"""Print the human-readable diff (counts + per-bucket id listings) to stdout.
|
||||
|
||||
``current_services`` is the empirically-derived set of services our CONFIRMED
|
||||
ids resolve to; it drives the UNMAPPED service-family grouping
|
||||
(``current`` / ``enterprise`` / ``other``) via :func:`classify_service`.
|
||||
"""
|
||||
confirmed, present, absent, unmapped = (
|
||||
buckets["confirmed"],
|
||||
buckets["present_unparsed"],
|
||||
buckets["absent"],
|
||||
buckets["unmapped"],
|
||||
)
|
||||
print(f"our ids: {len(ours)} | live registrations parsed: {len(live)}")
|
||||
print(
|
||||
f"CONFIRMED: {len(confirmed)} ABSENT: {len(absent)} "
|
||||
f"PRESENT-UNPARSED: {len(present)} UNMAPPED: {len(unmapped)}\n"
|
||||
)
|
||||
print("CONFIRMED (our id -> live /Service.Method):")
|
||||
for rpc_id in sorted(confirmed, key=lambda i: ours[i]):
|
||||
print(f" {rpc_id:<8} {ours[rpc_id]:<26} {confirmed[rpc_id]}")
|
||||
if absent:
|
||||
print("\nABSENT — id no longer in the bundle (rotation/stale; investigate):")
|
||||
for rpc_id in sorted(absent, key=lambda i: absent[i]):
|
||||
print(f" {rpc_id:<8} {absent[rpc_id]}")
|
||||
if present:
|
||||
print("\nPRESENT-UNPARSED — id is in the bundle but registration not parsed (widen regex):")
|
||||
for rpc_id in sorted(present, key=lambda i: present[i]):
|
||||
print(f" {rpc_id:<8} {present[rpc_id]}")
|
||||
# Group the unexposed RPCs by service family so "callable on our cohort now"
|
||||
# (current) is visually separated from the gated Discovery-Engine surface.
|
||||
fam_groups: dict[str, list[tuple[str, str]]] = {
|
||||
"current": [],
|
||||
"enterprise": [],
|
||||
"other": [],
|
||||
}
|
||||
for rpc_id, method in unmapped.items():
|
||||
fam_groups[classify_service(_service_of(method), current_services)].append((rpc_id, method))
|
||||
fam_labels = {
|
||||
"current": "UNMAPPED · consumer backend — callable on our cohort now, just not exposed",
|
||||
"enterprise": (
|
||||
"UNMAPPED · enterprise (Discovery Engine / Agentspace) — VPC-SC-gated, "
|
||||
"not consumer-callable, not a migration target"
|
||||
),
|
||||
"other": "UNMAPPED · other / unclassified services (investigate)",
|
||||
}
|
||||
print(f"\nUNMAPPED — live RPCs we do not expose ({len(unmapped)}), by service family:")
|
||||
for fam in ("current", "enterprise", "other"):
|
||||
items = fam_groups[fam]
|
||||
if not items:
|
||||
continue
|
||||
print(f"\n {fam_labels[fam]} ({len(items)}):")
|
||||
for rpc_id, method in sorted(items, key=lambda x: x[1]):
|
||||
print(f" {rpc_id:<8} {method}")
|
||||
|
||||
|
||||
def _print_enum_report(
|
||||
enum_buckets: dict[str, list[dict[str, object]]],
|
||||
quota: dict[int, str],
|
||||
proto: set[tuple[str, str]],
|
||||
) -> None:
|
||||
"""Print the studio-enum / quota / proto drift report (same style as the id diff).
|
||||
|
||||
``CHANGED``/``STALE`` are the alarms (a selectable format renumbered or
|
||||
retired); ``NEW`` and ``UNPARSED`` print but never alarm. Quota codes and
|
||||
proto assertions are report-only visibility surfaces.
|
||||
"""
|
||||
changed, stale, new, unparsed = (
|
||||
enum_buckets["changed"],
|
||||
enum_buckets["stale"],
|
||||
enum_buckets["new"],
|
||||
enum_buckets["unparsed"],
|
||||
)
|
||||
print("\n" + "=" * 60)
|
||||
print("STUDIO ENUM DRIFT (switch code->label maps)")
|
||||
print(
|
||||
f"CHANGED: {len(changed)} STALE: {len(stale)} NEW: {len(new)} UNPARSED: {len(unparsed)}"
|
||||
)
|
||||
if changed:
|
||||
print("\nCHANGED — a label's integer differs from ours (the #1597 alarm):")
|
||||
for r in changed:
|
||||
print(
|
||||
f" {r['enum']}.{r['member']} ({r['label']!r}): "
|
||||
f"ours={r['our_value']} live={r['live_value']}"
|
||||
)
|
||||
if stale:
|
||||
print("\nSTALE — our member's value is no longer a live code (format retired):")
|
||||
for r in stale:
|
||||
print(f" {r['enum']}.{r['member']} = {r['our_value']}")
|
||||
if new:
|
||||
print("\nNEW — bundle code we lack (REPORT-ONLY; a display label may be unreleased):")
|
||||
for r in new:
|
||||
print(f" {r['enum']} case {r['code']} -> {r['label']!r}")
|
||||
if unparsed:
|
||||
print("\nUNPARSED — known enum with no switch block parsed (widen regex; not an alarm):")
|
||||
for r in unparsed:
|
||||
print(f" {r['enum']}")
|
||||
|
||||
print("\nQUOTA CODES (Yp map — feature-rollout early-warning; report-only):")
|
||||
if quota:
|
||||
for code, message in sorted(quota.items()):
|
||||
print(f" {code:<3} {message}")
|
||||
else:
|
||||
print(" (none parsed)")
|
||||
|
||||
print("\nPROTO REQUIRED-FIELD ASSERTIONS (schema-shape; report-only):")
|
||||
if proto:
|
||||
for message, field in sorted(proto):
|
||||
print(f" {message} -> {field}")
|
||||
else:
|
||||
print(" (none parsed)")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point: load/fetch the bundle, diff vs rpc/types.py, report.
|
||||
|
||||
Returns the process exit code: ``1`` when a gate fires — ``--check`` with any
|
||||
ABSENT id (id rotation), and/or ``--check-enums`` with any CHANGED/STALE
|
||||
studio enum (a selectable format renumbered or retired). The two gates
|
||||
combine (either firing exits ``1``); ``NEW``/``UNPARSED`` enum classes,
|
||||
quota codes and proto assertions are report-only and never affect the exit
|
||||
code. Without a gate flag the exit is always ``0`` (report mode).
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
|
||||
parser.add_argument(
|
||||
"--json", action="store_true", help="emit a JSON snapshot instead of a report"
|
||||
)
|
||||
parser.add_argument("--check", action="store_true", help="exit 1 if any of our ids are ABSENT")
|
||||
parser.add_argument(
|
||||
"--check-enums",
|
||||
action="store_true",
|
||||
help="exit 1 if any studio enum is CHANGED or STALE (NEW/UNPARSED never fail)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bundle-file", type=Path, help="analyse a saved bundle file (no auth/network)"
|
||||
)
|
||||
parser.add_argument("--types", type=Path, default=_DEFAULT_TYPES, help="path to rpc/types.py")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
types_text = args.types.read_text(encoding="utf-8")
|
||||
ours = parse_ids_from_text(types_text)
|
||||
bundle = args.bundle_file.read_text(encoding="utf-8") if args.bundle_file else fetch_bundle()
|
||||
live = extract_registry(bundle)
|
||||
buckets = diff(ours, live, bundle)
|
||||
# Services any CONFIRMED id resolves to are, empirically, serving our cohort.
|
||||
current_services = {_service_of(m) for m in buckets["confirmed"].values()}
|
||||
|
||||
live_switch = extract_switch_enums(bundle)
|
||||
enum_buckets = diff_enums(types_text, live_switch)
|
||||
quota = extract_quota_codes(bundle)
|
||||
proto = extract_proto_assertions(bundle)
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"confirmed": {
|
||||
i: {"name": ours[i], "method": m} for i, m in buckets["confirmed"].items()
|
||||
},
|
||||
"absent": buckets["absent"],
|
||||
"present_unparsed": buckets["present_unparsed"],
|
||||
"unmapped": {
|
||||
i: {
|
||||
"method": m,
|
||||
"family": classify_service(_service_of(m), current_services),
|
||||
}
|
||||
for i, m in buckets["unmapped"].items()
|
||||
},
|
||||
"enums": enum_buckets,
|
||||
"quota_codes": {str(code): message for code, message in quota.items()},
|
||||
"proto_assertions": sorted(f"{m}.{f}" for m, f in proto),
|
||||
"counts": {k: len(v) for k, v in buckets.items()}
|
||||
| {"ours": len(ours)}
|
||||
| {f"enum_{k}": len(v) for k, v in enum_buckets.items()},
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
_print_report(ours, live, buckets, current_services)
|
||||
_print_enum_report(enum_buckets, quota, proto)
|
||||
|
||||
exit_code = 0
|
||||
if args.check and buckets["absent"]:
|
||||
print(
|
||||
f"\nFAIL: {len(buckets['absent'])} of our RPC ids are no longer registered.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
exit_code = 1
|
||||
if args.check_enums and (enum_buckets["changed"] or enum_buckets["stale"]):
|
||||
print(
|
||||
f"\nFAIL: {len(enum_buckets['changed'])} CHANGED and "
|
||||
f"{len(enum_buckets['stale'])} STALE studio enum value(s) — a selectable "
|
||||
"format was renumbered or retired; re-capture rpc/types.py enums.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
exit_code = 1
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Assert third-party actions in privileged workflows are SHA-pinned.
|
||||
|
||||
Pinning third-party GitHub Actions to a 40-character commit SHA (instead
|
||||
of a floating tag like ``@v1`` or ``@release/v1``) is a defence against
|
||||
upstream tag-hijacking and silent supply-chain compromise: a malicious
|
||||
push to a tag we trust would otherwise execute under our secrets on the
|
||||
next workflow run. This check is the static gate that keeps the
|
||||
SHA-pinning invariant from regressing.
|
||||
|
||||
Scope:
|
||||
|
||||
* **Privileged workflows** — the hardcoded list below — run jobs that
|
||||
carry deploy keys, OIDC tokens, repo write scopes, or maintainer
|
||||
credentials. Every ``uses:`` referencing a third-party repo in those
|
||||
workflows must be SHA-pinned.
|
||||
* **First-party ``actions/*`` actions** (e.g. ``actions/checkout``,
|
||||
``actions/setup-python``) are owned by GitHub and may stay on floating
|
||||
tags. Dependabot still bumps them on a weekly cadence (see
|
||||
``.github/dependabot.yml``).
|
||||
* Non-privileged workflows are out of scope; they don't see secrets and
|
||||
can churn at floating-tag speed.
|
||||
|
||||
Comment convention: when a SHA is pinned, the line should be followed by
|
||||
a human-readable tag comment so reviewers can read the intent without
|
||||
visiting GitHub. Example::
|
||||
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # @ v1.14.0
|
||||
|
||||
The comment is advisory — this script does not enforce its presence
|
||||
(Dependabot rewrites only the SHA, and a missing comment is a polish
|
||||
finding, not a security gap). We enforce only the SHA itself.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/check_action_pinning.py
|
||||
python scripts/check_action_pinning.py --workflow-dir custom/path
|
||||
|
||||
Exit codes:
|
||||
0 Every third-party action in every privileged workflow is SHA-pinned.
|
||||
1 One or more violations found (printed to stderr with file:line).
|
||||
2 Argument error / privileged workflow file missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
# Privileged workflows. Each one carries secrets, OIDC, or maintainer-only
|
||||
# triggers; a hijacked third-party action in any of these can exfiltrate
|
||||
# credentials or push malicious artifacts under our identity. Keep this
|
||||
# list synced with the workflows that declare an ``environment:`` gate or
|
||||
# reference ``${{ secrets.* }}`` — see ``check_workflow_secret_gates.py``
|
||||
# for the runtime companion to this static check.
|
||||
PRIVILEGED_WORKFLOWS: tuple[str, ...] = (
|
||||
"publish.yml",
|
||||
"publish-docker.yml",
|
||||
"publish-mcpb.yml",
|
||||
"testpypi-publish.yml",
|
||||
"claude.yml",
|
||||
"rpc-health.yml",
|
||||
"nightly.yml",
|
||||
"verify-artifacts.yml",
|
||||
"verify-package.yml",
|
||||
)
|
||||
|
||||
# ``uses:`` lines we'll inspect. The ref (everything after ``@``) is the
|
||||
# focus of the check; we only need owner/repo to decide whether the
|
||||
# action is first-party. Composite-action paths (``./.github/...``) and
|
||||
# Docker image refs (``docker://...``) are intentionally NOT matched —
|
||||
# they're a different trust model and aren't used in this repo today.
|
||||
_USES_RE = re.compile(
|
||||
r"""
|
||||
^\s*-?\s* # optional list dash + leading indent
|
||||
uses:\s+ # the ``uses:`` key
|
||||
(?P<owner>[A-Za-z0-9._-]+)
|
||||
/
|
||||
(?P<repo>[A-Za-z0-9._/-]+?) # repo may include path-into-monorepo
|
||||
@
|
||||
(?P<ref>\S+) # whatever is after ``@`` up to next whitespace
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# A SHA is a 40-character lowercase hex string. Anything else — including
|
||||
# short SHAs (``@cef221``), tags (``@v1``, ``@release/v1``), branches
|
||||
# (``@main``) — is treated as a floating ref.
|
||||
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
# Owners we treat as first-party (GitHub-owned, no SHA pin required).
|
||||
# Mirrors the ``_ALLOWED_READ_SCOPES`` frozenset convention used by
|
||||
# ``check_workflow_permissions.py`` for similar small lookup tables.
|
||||
# ``github/*`` (e.g. ``github/codeql-action``) is GitHub-owned too but
|
||||
# lives in a separate org; expand this set deliberately if a ``github/*``
|
||||
# action ever lands in a privileged workflow.
|
||||
_FIRST_PARTY_OWNERS = frozenset({"actions"})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--workflow-dir",
|
||||
default=".github/workflows",
|
||||
help="Directory containing workflow YAML files (default: .github/workflows)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
workflow_dir = Path(args.workflow_dir)
|
||||
if not workflow_dir.is_dir():
|
||||
print(f"Not a directory: {workflow_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
missing: list[str] = []
|
||||
for name in PRIVILEGED_WORKFLOWS:
|
||||
if not (workflow_dir / name).is_file():
|
||||
missing.append(name)
|
||||
if missing:
|
||||
# A privileged workflow disappearing is itself worth a hard fail:
|
||||
# either the list is stale or someone deleted security infra.
|
||||
# Surface so a maintainer either restores the file or updates
|
||||
# PRIVILEGED_WORKFLOWS deliberately.
|
||||
for name in missing:
|
||||
print(
|
||||
f"{workflow_dir / name}: privileged workflow file missing",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
violations: list[str] = []
|
||||
for name in PRIVILEGED_WORKFLOWS:
|
||||
path = workflow_dir / name
|
||||
for lineno, owner, repo, ref in _iter_uses(path):
|
||||
if owner in _FIRST_PARTY_OWNERS:
|
||||
# actions/* is GitHub-owned; floating tag is acceptable.
|
||||
continue
|
||||
if _SHA_RE.match(ref):
|
||||
# Third-party + 40-char SHA = pinned. OK.
|
||||
continue
|
||||
violations.append(
|
||||
f"{path}:{lineno}: third-party action not SHA-pinned: "
|
||||
f"{owner}/{repo}@{ref} (expected 40-char commit SHA)"
|
||||
)
|
||||
|
||||
if violations:
|
||||
for v in violations:
|
||||
print(v, file=sys.stderr)
|
||||
print(
|
||||
f"\n{len(violations)} violation(s). "
|
||||
"Replace floating tag with the resolved commit SHA, e.g.:\n"
|
||||
" gh api repos/<owner>/<repo>/commits/<tag> --jq .sha\n"
|
||||
"Then add a comment with the human-readable tag for review "
|
||||
"readability, e.g. ``# @ v1.2.3``.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"OK: all third-party actions in {len(PRIVILEGED_WORKFLOWS)} privileged "
|
||||
f"workflows are SHA-pinned"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _iter_uses(path: Path) -> Iterator[tuple[int, str, str, str]]:
|
||||
"""Yield ``(lineno, owner, repo, ref)`` for each ``uses:`` line in ``path``.
|
||||
|
||||
``lineno`` is 1-based to match editor / grep / GitHub line numbering.
|
||||
"""
|
||||
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
|
||||
m = _USES_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
yield lineno, m.group("owner"), m.group("repo"), m.group("ref")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Assert install-doc parity.
|
||||
|
||||
Two checks:
|
||||
|
||||
1. **Canonical install presence:** the canonical install command —
|
||||
``uv sync --frozen --extra browser --extra dev --extra markdown`` — must
|
||||
appear verbatim in both ``.github/workflows/test.yml`` and
|
||||
``CONTRIBUTING.md``. The exact wording is deliberate (per
|
||||
``docs/installation.md``): the broader ``--all-extras`` form installs every
|
||||
optional group, including ``cookies`` plus ``mcp``/``server``; ``cookies``
|
||||
fails on Python 3.13/3.14.
|
||||
|
||||
2. **Block-mirror policy:** every fenced ``bash`` code block
|
||||
in ``docs/installation.md`` (the canonical install guide) must EITHER
|
||||
appear verbatim in ``CONTRIBUTING.md``, OR be marked with
|
||||
``<!-- not mirrored: <reason> -->`` on the line directly before the
|
||||
opening fence in ``installation.md``. This forces a reviewer to *think*
|
||||
about parity each time they edit the install docs — a stale block
|
||||
silently drifting into ``installation.md`` without a corresponding
|
||||
contributor-doc update is the failure mode this guards.
|
||||
|
||||
Usage:
|
||||
python scripts/check_ci_install_parity.py
|
||||
python scripts/check_ci_install_parity.py --workflow X --contributing Y --installation Z
|
||||
python scripts/check_ci_install_parity.py --skip-block-mirror # original check only
|
||||
|
||||
Exit codes:
|
||||
0 All checks pass.
|
||||
1 Drift detected.
|
||||
2 Argument error / file not found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL_INSTALL_CMD = "uv sync --frozen --extra browser --extra dev --extra markdown"
|
||||
|
||||
# Regex to find ``<!-- not mirrored: ... -->`` markers. The reason text is
|
||||
# stripped — it's documentation for humans, not a key.
|
||||
_MARKER_RE = re.compile(r"<!--\s*not\s+mirrored\s*:\s*(.+?)\s*-->", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _BashBlock:
|
||||
"""A fenced ``bash`` code block extracted from a markdown source."""
|
||||
|
||||
start_line: int # 1-based line number of the opening ``` fence
|
||||
body: str # block contents (no fences), exact characters
|
||||
|
||||
|
||||
def _extract_bash_blocks(text: str) -> list[_BashBlock]:
|
||||
"""Extract every fenced ``bash`` block from a markdown document.
|
||||
|
||||
Indented blocks (e.g. inside numbered lists) are recognized: the script
|
||||
looks for a fence opener that begins with ``bash`` after any indentation.
|
||||
The block body is captured with the leading indentation stripped from
|
||||
each line so a verbatim search against another doc isn't foiled by
|
||||
purely-cosmetic indent differences.
|
||||
"""
|
||||
blocks: list[_BashBlock] = []
|
||||
lines = text.splitlines(keepends=False)
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.lstrip()
|
||||
indent = len(line) - len(stripped)
|
||||
if stripped.rstrip() == "```bash":
|
||||
start = i + 1 # 1-based for human-friendly error messages
|
||||
body_lines: list[str] = []
|
||||
j = i + 1
|
||||
while j < len(lines):
|
||||
ln = lines[j]
|
||||
ln_stripped = ln.lstrip()
|
||||
ln_indent = len(ln) - len(ln_stripped)
|
||||
if ln_stripped.rstrip() == "```" and ln_indent == indent:
|
||||
# Tolerate trailing whitespace on the closing fence so a
|
||||
# file with stray spaces after ``` doesn't silently
|
||||
# capture the rest of the document into one giant block.
|
||||
break
|
||||
# Strip the opener's indent so the captured body is comparable
|
||||
# against blocks elsewhere that may use different indentation.
|
||||
if ln.startswith(" " * indent):
|
||||
body_lines.append(ln[indent:])
|
||||
else:
|
||||
body_lines.append(ln)
|
||||
j += 1
|
||||
blocks.append(_BashBlock(start_line=start, body="\n".join(body_lines)))
|
||||
i = j + 1
|
||||
continue
|
||||
i += 1
|
||||
return blocks
|
||||
|
||||
|
||||
def _has_not_mirrored_marker(text: str, block: _BashBlock) -> bool:
|
||||
"""Return True if a ``<!-- not mirrored: ... -->`` marker precedes ``block``.
|
||||
|
||||
The marker must appear on the line directly above the opening fence
|
||||
(blank lines between are tolerated so the marker can sit above a
|
||||
heading or short paragraph without being forced inline with the fence).
|
||||
"""
|
||||
lines = text.splitlines(keepends=False)
|
||||
fence_idx = block.start_line - 1
|
||||
k = fence_idx - 1
|
||||
while k >= 0 and lines[k].strip() == "":
|
||||
k -= 1
|
||||
if k < 0:
|
||||
return False
|
||||
return _MARKER_RE.search(lines[k]) is not None
|
||||
|
||||
|
||||
def _check_canonical_install_presence(workflow_path: Path, contributing_path: Path) -> int:
|
||||
if not workflow_path.is_file():
|
||||
print(f"File not found: {workflow_path}", file=sys.stderr)
|
||||
return 2
|
||||
if not contributing_path.is_file():
|
||||
print(f"File not found: {contributing_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
workflow_text = workflow_path.read_text(encoding="utf-8")
|
||||
contributing_text = contributing_path.read_text(encoding="utf-8")
|
||||
|
||||
if CANONICAL_INSTALL_CMD not in workflow_text:
|
||||
print(
|
||||
f"DRIFT: {workflow_path} is missing the canonical install command:\n"
|
||||
f" '{CANONICAL_INSTALL_CMD}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if CANONICAL_INSTALL_CMD not in contributing_text:
|
||||
print(
|
||||
f"DRIFT: {contributing_path} is missing the canonical install command:\n"
|
||||
f" '{CANONICAL_INSTALL_CMD}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"OK: both files use '{CANONICAL_INSTALL_CMD}'")
|
||||
return 0
|
||||
|
||||
|
||||
def _check_block_mirror_policy(installation_path: Path, contributing_path: Path) -> int:
|
||||
"""Every bash block in installation.md must be mirrored or explicitly marked."""
|
||||
if not installation_path.is_file():
|
||||
print(f"File not found: {installation_path}", file=sys.stderr)
|
||||
return 2
|
||||
if not contributing_path.is_file():
|
||||
print(f"File not found: {contributing_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
installation_text = installation_path.read_text(encoding="utf-8")
|
||||
contributing_text = contributing_path.read_text(encoding="utf-8")
|
||||
|
||||
blocks = _extract_bash_blocks(installation_text)
|
||||
if not blocks:
|
||||
print(f"WARN: no fenced ``bash`` blocks found in {installation_path}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Compare against the SET of fenced bash blocks in CONTRIBUTING.md, not
|
||||
# the raw text. A naive substring check would let an installation block
|
||||
# pass when its body coincidentally appears inside prose, an unrelated
|
||||
# block, or a longer command — exactly the false-positive failure mode
|
||||
# that lets stale install docs slip through unnoticed.
|
||||
contributing_block_bodies = {b.body for b in _extract_bash_blocks(contributing_text)}
|
||||
|
||||
failures: list[str] = []
|
||||
for block in blocks:
|
||||
if block.body in contributing_block_bodies:
|
||||
continue
|
||||
if _has_not_mirrored_marker(installation_text, block):
|
||||
continue
|
||||
first_line = block.body.splitlines()[0] if block.body else "(empty)"
|
||||
failures.append(
|
||||
f" {installation_path}:{block.start_line} — block not mirrored "
|
||||
f"in {contributing_path.name} and missing "
|
||||
f"'<!-- not mirrored: <reason> -->' marker.\n"
|
||||
f" first line: {first_line!r}"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print(
|
||||
"BLOCK-MIRROR DRIFT in install docs:\n"
|
||||
+ "\n".join(failures)
|
||||
+ "\n Fix: either copy the block verbatim into "
|
||||
+ str(contributing_path)
|
||||
+ ", or add a '<!-- not mirrored: <reason> -->' line directly above "
|
||||
"the opening fence in " + str(installation_path) + ".",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"OK: all {len(blocks)} bash block(s) in {installation_path.name} "
|
||||
"are mirrored or explicitly marked"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--workflow", default=str(repo_root / ".github/workflows/test.yml"))
|
||||
ap.add_argument("--contributing", default=str(repo_root / "CONTRIBUTING.md"))
|
||||
ap.add_argument("--installation", default=str(repo_root / "docs/installation.md"))
|
||||
ap.add_argument(
|
||||
"--skip-block-mirror",
|
||||
action="store_true",
|
||||
help="Run only the original canonical-install presence check.",
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
workflow = Path(args.workflow)
|
||||
contributing = Path(args.contributing)
|
||||
installation = Path(args.installation)
|
||||
|
||||
rc = _check_canonical_install_presence(workflow, contributing)
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
if args.skip_block_mirror:
|
||||
return 0
|
||||
|
||||
return _check_block_mirror_policy(installation, contributing)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Assert the repo-structure file map is fresh in both directions.
|
||||
|
||||
The hand-maintained module map (the ``### Repository Structure`` tree) lives in
|
||||
``docs/architecture.md`` — it was moved there from CLAUDE.md to keep CLAUDE.md
|
||||
slim; the ``--claude-md`` flag is kept (name unchanged for back-compat) but now
|
||||
defaults to the architecture doc. This gate prevents silent drift:
|
||||
|
||||
* documented paths must still exist; and
|
||||
* every ``src/notebooklm`` module/package (including subpackage members) must
|
||||
be documented or explicitly omitted with a reason.
|
||||
|
||||
Usage:
|
||||
python scripts/check_claude_md_freshness.py
|
||||
python scripts/check_claude_md_freshness.py --claude-md path/to/doc.md
|
||||
|
||||
Exit codes:
|
||||
0 The structure doc (docs/architecture.md) is fresh.
|
||||
1 One or more paths are stale or missing from the map.
|
||||
2 Argument error or the structure doc not found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_DOCUMENTED_ROOTS = ("src/notebooklm", "tests")
|
||||
_OMISSIONS_HEADING = "### Repository Structure Intentional Omissions"
|
||||
_OMISSION_BULLET_RE = re.compile(r"^\s*[-*]\s+")
|
||||
_OMISSION_PATH_RE = re.compile(r"^\s*[-*]\s+`(?P<path>src/notebooklm/[^`]+)`")
|
||||
_IGNORED_PATH_RE = re.compile(
|
||||
r"^\s*[-*]\s+`(?P<path>src/notebooklm/[^`]+)`\s+(?:--|-|:)\s+(?P<reason>.+?)\s*$"
|
||||
)
|
||||
|
||||
|
||||
def _extract_paths(text: str) -> list[str]:
|
||||
paths: list[str] = []
|
||||
stack: list[tuple[int, str]] = []
|
||||
|
||||
for line in text.splitlines():
|
||||
# Do not strip yet, we need leading spaces for indent calculation
|
||||
trimmed = line.strip()
|
||||
if not trimmed or any(trimmed.startswith(p) for p in ("|", "#", "```")):
|
||||
continue
|
||||
|
||||
# Determine indentation level and clean the line
|
||||
indent = 0
|
||||
clean_line = trimmed
|
||||
found_marker = False
|
||||
for marker in ("├── ", "└── ", "│ "):
|
||||
if marker in line:
|
||||
# Calculate depth based on the position of the marker
|
||||
# We expect 4 spaces per level (or equivalent tree chars)
|
||||
pos = line.find(marker)
|
||||
indent = (pos // 4) + 1
|
||||
clean_line = line.split(marker, 1)[1]
|
||||
found_marker = True
|
||||
break
|
||||
|
||||
if not found_marker:
|
||||
if trimmed.startswith(("src/notebooklm", "tests")):
|
||||
indent = 0
|
||||
clean_line = trimmed
|
||||
else:
|
||||
continue
|
||||
|
||||
# Remove comments
|
||||
if " # " in clean_line:
|
||||
clean_line = clean_line.split(" # ", 1)[0]
|
||||
clean_line = clean_line.strip().rstrip("/")
|
||||
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
# Manage the stack for tree traversal
|
||||
while stack and stack[-1][0] >= indent:
|
||||
stack.pop()
|
||||
|
||||
stack.append((indent, clean_line))
|
||||
|
||||
full_path = "/".join(segment for _, segment in stack)
|
||||
if full_path.startswith(_DOCUMENTED_ROOTS):
|
||||
paths.append(full_path)
|
||||
|
||||
return sorted(set(paths))
|
||||
|
||||
|
||||
def _repository_structure_section(text: str) -> str:
|
||||
section = _section_after_heading(text, "### Repository Structure")
|
||||
next_heading = re.search(r"^\s*##\s+", section, flags=re.MULTILINE)
|
||||
if next_heading is not None:
|
||||
section = section[: next_heading.start()]
|
||||
return section
|
||||
|
||||
|
||||
def _extract_intentional_omissions(text: str) -> dict[str, str]:
|
||||
"""Return intentionally omitted ``src/notebooklm`` paths and their reasons."""
|
||||
omissions: dict[str, str] = {}
|
||||
for line in _intentional_omissions_section(text).splitlines():
|
||||
if "`src/notebooklm/" not in line:
|
||||
continue
|
||||
match = _IGNORED_PATH_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
path = match.group("path").rstrip("/")
|
||||
reason = match.group("reason").strip()
|
||||
if reason:
|
||||
omissions[path] = reason
|
||||
return omissions
|
||||
|
||||
|
||||
def _extract_unreasoned_omissions(text: str) -> list[str]:
|
||||
"""Return omission bullets that mention a path but do not provide a reason."""
|
||||
unreasoned: list[str] = []
|
||||
for line in _intentional_omissions_section(text).splitlines():
|
||||
if _OMISSION_PATH_RE.match(line) and _IGNORED_PATH_RE.match(line) is None:
|
||||
unreasoned.append(line.strip())
|
||||
return unreasoned
|
||||
|
||||
|
||||
def _extract_omission_bullet_path(line: str) -> str | None:
|
||||
match = _OMISSION_PATH_RE.match(line)
|
||||
if match is None:
|
||||
return None
|
||||
return match.group("path").rstrip("/")
|
||||
|
||||
|
||||
def _intentional_omissions_section(text: str) -> str:
|
||||
section = _section_after_heading(text, _OMISSIONS_HEADING)
|
||||
next_heading = re.search(r"^\s*###\s+", section, flags=re.MULTILINE)
|
||||
if next_heading is not None:
|
||||
section = section[: next_heading.start()]
|
||||
return section
|
||||
|
||||
|
||||
def _section_after_heading(text: str, heading: str) -> str:
|
||||
match = re.search(rf"^\s*{re.escape(heading)}\s*$", text, flags=re.MULTILINE)
|
||||
if match is None:
|
||||
return ""
|
||||
return text[match.end() :]
|
||||
|
||||
|
||||
def _top_level_notebooklm_modules(repo_root: Path) -> list[str]:
|
||||
"""Return every ``src/notebooklm`` Python module and subpackage.
|
||||
|
||||
Recurses through subpackages so that subpackage members (e.g.
|
||||
``_auth/tokens.py``, ``rpc/overrides.py``) must be documented too — not
|
||||
just direct top-level modules. ``__init__.py`` package markers are
|
||||
excluded from the required set; the enclosing package directory stands in
|
||||
for them.
|
||||
"""
|
||||
package_root = repo_root / "src" / "notebooklm"
|
||||
if not package_root.is_dir():
|
||||
return []
|
||||
|
||||
paths: list[str] = []
|
||||
for child in package_root.rglob("*"):
|
||||
if "__pycache__" in child.parts:
|
||||
continue
|
||||
is_package = child.is_dir() and (child / "__init__.py").is_file()
|
||||
is_module = child.is_file() and child.suffix == ".py" and child.name != "__init__.py"
|
||||
if is_package or is_module:
|
||||
paths.append(child.relative_to(repo_root).as_posix())
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--claude-md", default="docs/architecture.md")
|
||||
ap.add_argument("--repo-root", default=".")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
claude = Path(args.claude_md)
|
||||
if not claude.is_file():
|
||||
print(f"Structure doc not found: {claude}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
text = _repository_structure_section(claude.read_text(encoding="utf-8"))
|
||||
|
||||
paths = _extract_paths(text)
|
||||
omissions = _extract_intentional_omissions(text)
|
||||
unreasoned_omissions = _extract_unreasoned_omissions(text)
|
||||
unreasoned_paths = {
|
||||
path for line in unreasoned_omissions if (path := _extract_omission_bullet_path(line))
|
||||
}
|
||||
missing = [p for p in paths if not (repo_root / p).exists()]
|
||||
top_level_modules = _top_level_notebooklm_modules(repo_root)
|
||||
undocumented = [
|
||||
p
|
||||
for p in top_level_modules
|
||||
if p not in paths and p not in omissions and p not in unreasoned_paths
|
||||
]
|
||||
stale_omissions = [p for p in omissions if not (repo_root / p).exists()]
|
||||
|
||||
if missing or undocumented or stale_omissions or unreasoned_omissions:
|
||||
if missing:
|
||||
print("Stale structure-doc path references:", file=sys.stderr)
|
||||
for p in missing:
|
||||
print(f" {p}", file=sys.stderr)
|
||||
if undocumented:
|
||||
print("Undocumented src/notebooklm modules/packages:", file=sys.stderr)
|
||||
for p in undocumented:
|
||||
print(f" {p}", file=sys.stderr)
|
||||
if stale_omissions:
|
||||
print("Stale structure-doc intentional omissions:", file=sys.stderr)
|
||||
for p in stale_omissions:
|
||||
print(f" {p}", file=sys.stderr)
|
||||
if unreasoned_omissions:
|
||||
print("Intentional omissions without parseable reasons:", file=sys.stderr)
|
||||
for line in unreasoned_omissions:
|
||||
print(f" {line}", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
f"OK: {len(paths)} documented paths resolve; "
|
||||
f"{len(top_level_modules)} modules/packages "
|
||||
f"are documented or intentionally omitted"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Assert pyproject.toml `fail_under` matches `.github/workflows/test.yml` `--cov-fail-under`.
|
||||
|
||||
Prevents the two values from drifting (e.g. CI passing at 70% while pyproject
|
||||
demands 90%, or vice versa) by failing CI whenever they disagree.
|
||||
|
||||
When ``--coverage-json`` is provided, additionally enforces per-file floors
|
||||
declared in ``[tool.notebooklm.per_file_coverage_floors]``. Coverage.py's
|
||||
``[tool.coverage.report]`` only supports a global ``fail_under``, so individual
|
||||
files that lag the project-wide 90% are guarded by this script.
|
||||
|
||||
Usage:
|
||||
python scripts/check_coverage_thresholds.py
|
||||
python scripts/check_coverage_thresholds.py --pyproject custom/pyproject.toml --workflow custom/test.yml
|
||||
python scripts/check_coverage_thresholds.py --coverage-json coverage.json
|
||||
|
||||
Exit codes:
|
||||
0 Thresholds match (and any per-file floors are met).
|
||||
1 Drift detected, OR a per-file floor breached.
|
||||
2 Argument error / missing field / coverage.json missing or malformed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
||||
except ImportError:
|
||||
print(
|
||||
"tomli is required on Python 3.10. Install with: uv pip install tomli",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _check_global_drift(pyproject_path: str, workflow_path: str) -> int:
|
||||
"""Compare pyproject ``fail_under`` against CI ``--cov-fail-under``."""
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
pp = tomllib.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"pyproject.toml not found: {pyproject_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
pyproject_threshold = pp["tool"]["coverage"]["report"]["fail_under"]
|
||||
except KeyError:
|
||||
print(
|
||||
f"No [tool.coverage.report] fail_under in {pyproject_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
try:
|
||||
with open(workflow_path) as f:
|
||||
yml = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Workflow not found: {workflow_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Scan line-by-line and ignore commented YAML lines so a stale
|
||||
# `# --cov-fail-under=90` doesn't shadow a real drift in the executed
|
||||
# command. Collect ALL occurrences so a workflow with multiple jobs
|
||||
# cannot smuggle a divergent threshold past the check.
|
||||
thresholds: list[int] = []
|
||||
pattern = re.compile(r"(?<!\S)--cov-fail-under(?:=|\s+)(\d+)(?!\S)")
|
||||
for line in yml.splitlines():
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
for m in pattern.finditer(stripped):
|
||||
thresholds.append(int(m.group(1)))
|
||||
|
||||
if not thresholds:
|
||||
print(f"No --cov-fail-under in {workflow_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
for ci_threshold in thresholds:
|
||||
if pyproject_threshold != ci_threshold:
|
||||
print(
|
||||
f"DRIFT: pyproject.toml fail_under={pyproject_threshold} but "
|
||||
f"{workflow_path} --cov-fail-under={ci_threshold}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"OK: {len(thresholds)} occurrence(s), all at {pyproject_threshold}%")
|
||||
return 0
|
||||
|
||||
|
||||
def _check_per_file_floors(pyproject_path: str, coverage_json_path: str) -> int:
|
||||
"""Enforce per-file floors from ``[tool.notebooklm.per_file_coverage_floors]``.
|
||||
|
||||
Floors live in pyproject.toml so they're checked into the repo and bumped
|
||||
via PR (the commit message documents the new minimum). The script reads
|
||||
``coverage.json`` produced by ``pytest --cov ... --cov-report=json:...``
|
||||
and fails if any guarded file's ``percent_covered`` is below its floor.
|
||||
"""
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
pp = tomllib.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"pyproject.toml not found: {pyproject_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
floors = pp.get("tool", {}).get("notebooklm", {}).get("per_file_coverage_floors")
|
||||
if floors is None:
|
||||
# Missing table is fine — nothing to enforce.
|
||||
print(f"OK: no [tool.notebooklm.per_file_coverage_floors] in {pyproject_path}")
|
||||
return 0
|
||||
if not isinstance(floors, dict):
|
||||
# Misconfiguration (e.g. someone wrote a string or list instead of a
|
||||
# table). Fail fast rather than silently returning OK.
|
||||
print(
|
||||
f"[tool.notebooklm.per_file_coverage_floors] must be a TOML table in "
|
||||
f"{pyproject_path}, got {type(floors).__name__}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
if not floors:
|
||||
# Empty table — explicitly opted into "enforce nothing right now".
|
||||
print(f"OK: no [tool.notebooklm.per_file_coverage_floors] in {pyproject_path}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(coverage_json_path) as f:
|
||||
cov = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"coverage.json not found: {coverage_json_path}", file=sys.stderr)
|
||||
return 2
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"coverage.json malformed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files = cov.get("files")
|
||||
if not isinstance(files, dict):
|
||||
# Includes both the missing-key case (``cov.get`` returns None) and
|
||||
# the wrong-shape case (e.g. accidentally a list); both indicate a
|
||||
# malformed ``coverage.json`` and should fail before any per-file
|
||||
# comparison runs.
|
||||
print(
|
||||
f"coverage.json 'files' must be an object map in {coverage_json_path}, "
|
||||
f"got {type(files).__name__}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
failures: list[str] = []
|
||||
missing: list[str] = []
|
||||
for path, floor in sorted(floors.items()):
|
||||
entry = files.get(path)
|
||||
if entry is None:
|
||||
# A guarded file with no measurement is itself a CI failure —
|
||||
# otherwise a rename or accidental deletion would silently drop
|
||||
# the floor and any later regression would slip through.
|
||||
missing.append(path)
|
||||
continue
|
||||
try:
|
||||
actual = float(entry["summary"]["percent_covered"])
|
||||
target = float(floor)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
print(
|
||||
f"coverage.json entry for {path!r} could not be compared "
|
||||
f"(actual={entry.get('summary', {}).get('percent_covered')!r}, "
|
||||
f"floor={floor!r}): {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
if actual < target:
|
||||
failures.append(f" {path}: {actual:.2f}% < floor {floor}%")
|
||||
|
||||
if missing:
|
||||
print(
|
||||
"MISSING from coverage.json (renamed/deleted? update "
|
||||
"[tool.notebooklm.per_file_coverage_floors] or restore the file):\n "
|
||||
+ "\n ".join(missing),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if failures:
|
||||
print(
|
||||
"PER-FILE COVERAGE FLOOR BREACH:\n" + "\n".join(failures),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"OK: {len(floors)} per-file floor(s) all met")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--pyproject", default="pyproject.toml")
|
||||
ap.add_argument("--workflow", default=".github/workflows/test.yml")
|
||||
ap.add_argument(
|
||||
"--coverage-json",
|
||||
default=None,
|
||||
help=(
|
||||
"Optional path to coverage.json (produced by `pytest --cov "
|
||||
"--cov-report=json:coverage.json`). When present, also enforces "
|
||||
"[tool.notebooklm.per_file_coverage_floors] from pyproject.toml."
|
||||
),
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
drift_rc = _check_global_drift(args.pyproject, args.workflow)
|
||||
if drift_rc != 0:
|
||||
return drift_rc
|
||||
|
||||
if args.coverage_json:
|
||||
return _check_per_file_floors(args.pyproject, args.coverage_json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Release gate: a deprecation must never name the version shipping it.
|
||||
|
||||
The recurring defect (issue #1214): a ``DeprecationWarning`` message says the
|
||||
feature "will be removed in vX.Y.Z" while ``pyproject.toml`` is *currently at*
|
||||
vX.Y.Z. Shipping that release publishes a deprecation whose stated removal
|
||||
target is the release itself — the shim is simultaneously "still here" and
|
||||
"already past its removal version", which is incoherent and means the shim
|
||||
should have been deleted (or the target bumped) before the release.
|
||||
|
||||
This gate scans every ``warnings.warn(...)`` / ``DeprecationWarning(...)``
|
||||
message string under ``src/notebooklm/`` and fails if any names the version
|
||||
in ``pyproject.toml`` as a *removal target* (``removed in vX.Y.Z`` /
|
||||
``will be removed in vX.Y.Z`` / ``removal in vX.Y.Z``). It is wired alongside
|
||||
the other ``scripts/check_*.py`` static gates and is also exercised by
|
||||
``tests/unit/test_check_deprecation_targets.py``.
|
||||
|
||||
Allowlist
|
||||
---------
|
||||
``LAPSED_ALLOWLIST`` holds deprecation sites whose stated removal target has
|
||||
*already lapsed* and whose shim removal is tracked separately. They are
|
||||
allowlisted (keyed by file + the offending version) with the tracking issue so
|
||||
this gate does not block the current release; once the tracking PR deletes the
|
||||
shim, the matching site disappears and the entry should be dropped (a stale
|
||||
allowlist entry is reported, keeping the list tightening).
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/check_deprecation_targets.py
|
||||
python scripts/check_deprecation_targets.py --pyproject path/to/pyproject.toml
|
||||
|
||||
Exit codes:
|
||||
0 No deprecation message names the current release as its removal target
|
||||
(modulo documented allowlist entries).
|
||||
1 One or more offending deprecation messages found (printed with file:line).
|
||||
2 Argument / parse error (missing pyproject, unreadable version, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else: # pragma: no cover - 3.10 path
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
||||
except ImportError:
|
||||
print(
|
||||
"tomli is required on Python 3.10. Install with: uv pip install tomli",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_ROOT = REPO_ROOT / "src" / "notebooklm"
|
||||
|
||||
# Calls whose first string argument is treated as a user-facing deprecation
|
||||
# message:
|
||||
# * ``warnings.warn`` — the canonical attribute-access form.
|
||||
# * bare ``warn`` — the ``from warnings import warn; warn(...)`` form.
|
||||
# * ``DeprecationWarning(...)`` — a constructed/raised warning instance.
|
||||
# The bare ``warn`` is broad on purpose (a release gate prefers a false
|
||||
# positive over a missed deprecation); the version-naming regex in
|
||||
# ``_removal_pattern`` keeps incidental ``warn()`` calls from matching unless
|
||||
# they actually name the shipping version as a removal target.
|
||||
_DEPRECATION_CALL_NAMES = frozenset({"warnings.warn", "warn", "DeprecationWarning"})
|
||||
|
||||
|
||||
class _Offender:
|
||||
__slots__ = ("path", "lineno", "version", "snippet")
|
||||
|
||||
def __init__(self, path: str, lineno: int, version: str, snippet: str) -> None:
|
||||
self.path = path
|
||||
self.lineno = lineno
|
||||
self.version = version
|
||||
self.snippet = snippet
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, str]:
|
||||
return (self.path, self.version)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist — deprecation sites whose stated removal target has already lapsed.
|
||||
#
|
||||
# These are removed by a separate tracking PR; allowlisting them (with the
|
||||
# issue reference) keeps this gate from blocking the current release while the
|
||||
# shim deletion lands. Keyed by (relative-posix-path, offending-version).
|
||||
# ---------------------------------------------------------------------------
|
||||
class _LapsedEntry:
|
||||
__slots__ = ("path", "version", "issue", "reason")
|
||||
|
||||
def __init__(self, path: str, version: str, issue: int, reason: str) -> None:
|
||||
self.path = path
|
||||
self.version = version
|
||||
self.issue = issue
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, str]:
|
||||
return (self.path, self.version)
|
||||
|
||||
|
||||
LAPSED_ALLOWLIST: tuple[_LapsedEntry, ...] = ()
|
||||
|
||||
_ALLOWLIST_BY_KEY = {entry.key: entry for entry in LAPSED_ALLOWLIST}
|
||||
|
||||
|
||||
def _read_version(pyproject: Path) -> str:
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
try:
|
||||
version = data["project"]["version"]
|
||||
except (KeyError, TypeError) as exc: # pragma: no cover - malformed pyproject
|
||||
raise KeyError("project.version not found in pyproject.toml") from exc
|
||||
if not isinstance(version, str): # pragma: no cover - defensive
|
||||
raise TypeError(f"project.version is not a string: {version!r}")
|
||||
return version
|
||||
|
||||
|
||||
def _removal_pattern(version: str) -> re.Pattern[str]:
|
||||
"""Match removal language naming *version* (with or without a ``v`` prefix).
|
||||
|
||||
Examples matched (version == ``0.6.0``)::
|
||||
|
||||
will be removed in v0.6.0
|
||||
removed in 0.6.0
|
||||
removal in v0.6.0
|
||||
scheduled for removal in 0.6.0
|
||||
"""
|
||||
escaped = re.escape(version)
|
||||
return re.compile(
|
||||
r"(?:will\s+be\s+)?remov(?:ed|al)\s+in\s+v?" + escaped + r"\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _flatten_message(node: ast.AST) -> str:
|
||||
"""Best-effort flatten of a (possibly f-string / concatenated) message arg.
|
||||
|
||||
Concatenated string literals (``"a" "b"``) parse as a single ``Constant``;
|
||||
implicit ``+`` concatenation and f-strings need walking. We collect every
|
||||
string constant reachable from the first argument so split removal phrases
|
||||
like ``"... removed in " f"v{X}"`` are not the concern here (the version is
|
||||
a literal in the offending sites), but multi-literal messages still match.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, ast.Constant) and isinstance(child.value, str):
|
||||
parts.append(child.value)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _call_name(node: ast.AST) -> str:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
base = _call_name(node.value)
|
||||
return f"{base}.{node.attr}" if base else node.attr
|
||||
return ""
|
||||
|
||||
|
||||
def _scan(version: str) -> list[_Offender]:
|
||||
pattern = _removal_pattern(version)
|
||||
offenders: list[_Offender] = []
|
||||
for path in sorted(SRC_ROOT.rglob("*.py")):
|
||||
module = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
for node in ast.walk(module):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _call_name(node.func) not in _DEPRECATION_CALL_NAMES:
|
||||
continue
|
||||
# The message is the first positional arg, or the ``message=``
|
||||
# keyword (``warnings.warn(message=...)``). Checking both keeps a
|
||||
# keyword-form deprecation from bypassing the gate.
|
||||
message_node: ast.AST | None = node.args[0] if node.args else None
|
||||
if message_node is None:
|
||||
for kw in node.keywords:
|
||||
if kw.arg == "message":
|
||||
message_node = kw.value
|
||||
break
|
||||
if message_node is None:
|
||||
continue
|
||||
message = _flatten_message(message_node)
|
||||
if pattern.search(message):
|
||||
snippet = message.strip()
|
||||
if len(snippet) > 100:
|
||||
snippet = snippet[:97] + "..."
|
||||
offenders.append(_Offender(rel, node.lineno, version, snippet))
|
||||
return offenders
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--pyproject",
|
||||
default=str(REPO_ROOT / "pyproject.toml"),
|
||||
help="Path to pyproject.toml (default: repo root)",
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
pyproject = Path(args.pyproject)
|
||||
if not pyproject.is_file():
|
||||
print(f"pyproject.toml not found: {pyproject}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
version = _read_version(pyproject)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
print(f"Could not read project.version: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
offenders = _scan(version)
|
||||
|
||||
blocking: list[_Offender] = []
|
||||
matched_allowlist_keys: set[tuple[str, str]] = set()
|
||||
for offender in offenders:
|
||||
if offender.key in _ALLOWLIST_BY_KEY:
|
||||
matched_allowlist_keys.add(offender.key)
|
||||
continue
|
||||
blocking.append(offender)
|
||||
|
||||
stale = sorted(
|
||||
f" {entry.path} (v{entry.version}, #{entry.issue})"
|
||||
for entry in LAPSED_ALLOWLIST
|
||||
if entry.key not in matched_allowlist_keys
|
||||
)
|
||||
|
||||
# Report BOTH problems in one pass: a developer fixing a blocking offender
|
||||
# should also see any stale allowlist entry without needing a second run.
|
||||
if blocking:
|
||||
print(
|
||||
f"Deprecation message(s) name the current release version "
|
||||
f"v{version} as their removal target:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for offender in blocking:
|
||||
print(f" {offender.path}:{offender.lineno}: {offender.snippet}", file=sys.stderr)
|
||||
print(
|
||||
"\nA deprecation must never point at the version shipping it. Either "
|
||||
"delete the shim before this release, or bump the stated removal "
|
||||
"target to a future version. If the removal is tracked separately, "
|
||||
"add the site to LAPSED_ALLOWLIST with its tracking issue.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if stale:
|
||||
print(
|
||||
"Stale deprecation-target allowlist entries (no matching deprecation "
|
||||
"message found — remove from LAPSED_ALLOWLIST):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for line in stale:
|
||||
print(line, file=sys.stderr)
|
||||
|
||||
if blocking or stale:
|
||||
return 1
|
||||
|
||||
allowlisted = len(matched_allowlist_keys)
|
||||
suffix = f" ({allowlisted} allowlisted, lapsed)" if allowlisted else ""
|
||||
print(
|
||||
f"OK: no deprecation message names the current release v{version} as a "
|
||||
f"removal target{suffix}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Assert doc references into ``src/notebooklm`` stay fresh.
|
||||
|
||||
Sibling to ``scripts/check_claude_md_freshness.py`` (which guards the
|
||||
``### Repository Structure`` map in ``docs/architecture.md``). This gate turns
|
||||
the repo's "enforce, don't document" principle onto the *rest* of the docs:
|
||||
after the #1328 refactor promoted flat ``_*.py`` modules into subpackages
|
||||
(``_chat.py`` -> ``_chat/api.py``, ``_runtime_lifecycle.py`` ->
|
||||
``_runtime/lifecycle.py``, ...), ~25 stale flat references survived across the
|
||||
live docs because a hand audit and a scoped doc-sync PR both missed them. A gate
|
||||
is the only thing that makes that class of drift un-recurrable.
|
||||
|
||||
Two checks, both read the docs and resolve targets against the repo:
|
||||
|
||||
**(1) Broken local-link check (strict, no allowlist).** Across ALL
|
||||
``docs/**/*.md`` + root ``*.md``, every markdown link ``[text](target)`` whose
|
||||
``target`` is a *relative path into* ``src/notebooklm/`` MUST resolve to an
|
||||
existing file. A broken link into the package is never intentional, even in an
|
||||
ADR or refactor-history doc.
|
||||
|
||||
**(2) Inline module-ref check (LIVE docs only, allowlisted).** In the *live*
|
||||
docs (``docs/**/*.md`` + root ``*.md`` MINUS the historical-prose docs —
|
||||
``docs/adr/**``, ``docs/refactor-history.md``, and ``CHANGELOG.md`` — which
|
||||
intentionally name historical modules in prose), every inline code span
|
||||
```` `<ref>` ```` whose ``<ref>`` matches a ``src/notebooklm`` module shape MUST
|
||||
resolve to ``src/notebooklm/<ref>``. The rare intentional historical mention in
|
||||
a live doc is carried in :data:`_ALLOWLIST` (shrink-only). CLAUDE.md is excluded
|
||||
because it is an agent-instruction file, not part of the live docs set.
|
||||
|
||||
The detector core (:func:`find_violations`) is pure and IO-free — it takes the
|
||||
already-read doc text plus a ``resolver(ref) -> bool`` — so the public test and
|
||||
these CLI self-checks exercise the same logic, exactly like
|
||||
``tests/_guardrails/test_v080_deprecation_coverage.py``.
|
||||
|
||||
Usage:
|
||||
python scripts/check_docs_module_refs.py
|
||||
python scripts/check_docs_module_refs.py --repo-root path/to/repo
|
||||
|
||||
Exit codes:
|
||||
0 All doc module references are fresh.
|
||||
1 One or more broken links or dead inline refs were found.
|
||||
2 Argument error / repo root or docs tree not found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# The package every reference resolves against.
|
||||
_PACKAGE_RELDIR = "src/notebooklm"
|
||||
|
||||
# A ``src/notebooklm`` module shape: a lowercase python module (top-level name is
|
||||
# either a private ``_foo`` name or one of the known public module names), with
|
||||
# optional subdirectories, ending in ``.py``. ``test_*.py`` / ``conftest.py`` and
|
||||
# anything under ``tests/`` / ``scripts/`` are excluded by the caller, not here.
|
||||
#
|
||||
# Scope: covers the ``_*`` private modules, the known top-level public modules,
|
||||
# the ``notebooklm_cli`` entry point, and the ``rpc/`` + ``cli/`` subpackages
|
||||
# (broadened per review so an inline ``rpc/types.py`` / ``cli/session_cmd.py``
|
||||
# ref is resolved, not silently skipped). The test
|
||||
# ``test_test_and_script_refs_are_not_module_shaped`` pins this scope.
|
||||
_MODULE_REF_RE = re.compile(
|
||||
r"^(_[a-z0-9_]+|client|auth|exceptions|config|io|log|migration|paths|research"
|
||||
r"|types|urls|utils|artifacts|notebooklm_cli|rpc|cli)([/][a-z0-9_]+)*\.py$"
|
||||
)
|
||||
|
||||
# Inline code spans: ``\`...\```. Non-greedy so adjacent spans on one line are
|
||||
# matched separately.
|
||||
_INLINE_SPAN_RE = re.compile(r"`([^`]+)`")
|
||||
|
||||
# Markdown links: ``](target)``. The target may carry a ``#anchor`` we strip.
|
||||
_LINK_TARGET_RE = re.compile(r"\]\(([^)]+)\)")
|
||||
|
||||
|
||||
# Allowlist for the inline-ref check (check 2): live-doc inline mentions of a
|
||||
# module that no longer exists at that path but is named intentionally (a
|
||||
# historical / deliberately-deleted module, or a placeholder in a how-to). Keyed
|
||||
# by ``"<doc-relpath>:<ref>"`` -> reason. This set is SHRINK-ONLY: a test asserts
|
||||
# every entry is still genuinely needed (the doc still mentions it AND the ref
|
||||
# still does not resolve), so a stale allowlist entry fails the gate. Do NOT add
|
||||
# an entry to silence a *real* stale path — fix the path instead.
|
||||
_ALLOWLIST: dict[str, str] = {
|
||||
# `_core.py` was the runtime compatibility shim deleted in v0.5.0. These live
|
||||
# docs intentionally name it to explain why `CORE_LOGGER_NAME` still reads
|
||||
# "notebooklm._core" (a logging compatibility contract), NOT to point at a
|
||||
# live module. See docs/development.md "Logger namespace compatibility".
|
||||
"docs/architecture.md:_core.py": "historical: deleted-in-v0.5.0 compat shim, named to explain CORE_LOGGER_NAME",
|
||||
"docs/configuration.md:_core.py": "historical: deleted-in-v0.5.0 compat shim, named to explain CORE_LOGGER_NAME",
|
||||
"docs/development.md:_core.py": "historical: deleted-in-v0.5.0 compat shim, named to explain CORE_LOGGER_NAME",
|
||||
# `_newfeature.py` is a placeholder in the "adding a new API class" how-to
|
||||
# ("Create `_newfeature.py` ..."), not a real module reference.
|
||||
"docs/development.md:_newfeature.py": "placeholder: example module name in the add-an-API-class how-to",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Violation:
|
||||
"""One dead doc reference, ``kind`` is ``"link"`` or ``"inline"``."""
|
||||
|
||||
kind: str
|
||||
doc: str # POSIX-relative path of the doc, for stable messages
|
||||
line: int
|
||||
target: str # the dead link target or inline ref
|
||||
|
||||
|
||||
def _is_local_package_link(target: str) -> bool:
|
||||
"""True for a *relative* markdown link target into ``src/notebooklm/``.
|
||||
|
||||
Absolute URLs (``http://...``), in-page anchors (``#...``), and links that do
|
||||
not descend into the package are out of scope — only relative paths whose
|
||||
resolved form lands inside ``src/notebooklm/`` are checked. The substring test
|
||||
is intentional: doc-relative targets reach the package via ``../`` prefixes
|
||||
(``../src/notebooklm/...``, ``../../src/notebooklm/...``).
|
||||
"""
|
||||
if target.startswith(("http://", "https://", "mailto:", "#")):
|
||||
return False
|
||||
return f"{_PACKAGE_RELDIR}/" in target
|
||||
|
||||
|
||||
def _is_module_shaped(ref: str) -> bool:
|
||||
"""True for an inline ref that looks like an in-package module path.
|
||||
|
||||
Excludes ``test_*.py`` / ``conftest.py`` and anything under ``tests/`` or
|
||||
``scripts/`` (those are not ``src/notebooklm`` modules even though they match
|
||||
the ``.py`` shape).
|
||||
"""
|
||||
if "/" in ref:
|
||||
head = ref.split("/", 1)[0]
|
||||
if head in {"tests", "scripts"}:
|
||||
return False
|
||||
leaf = ref.rsplit("/", 1)[-1]
|
||||
if leaf.startswith("test_") or leaf == "conftest.py":
|
||||
return False
|
||||
return bool(_MODULE_REF_RE.match(ref))
|
||||
|
||||
|
||||
def find_violations(
|
||||
doc_relpath: str,
|
||||
text: str,
|
||||
*,
|
||||
resolver: Callable[[str], bool],
|
||||
is_live: bool,
|
||||
allowlist: dict[str, str],
|
||||
) -> list[Violation]:
|
||||
"""Return every dead reference in one doc. Pure: no filesystem access.
|
||||
|
||||
``resolver(ref) -> bool`` answers "does ``src/notebooklm/<ref>`` exist?" for
|
||||
the inline check, and ``resolver(target) -> bool`` answers "does this
|
||||
doc-relative link target resolve?" for the link check — the CLI passes a
|
||||
filesystem-backed resolver, the tests pass a dict-backed stub.
|
||||
|
||||
* Link check (always, every doc): a relative link into ``src/notebooklm/``
|
||||
that does not resolve is a violation.
|
||||
* Inline check (live docs only): a module-shaped inline span that does not
|
||||
resolve to ``src/notebooklm/<ref>`` is a violation, unless an
|
||||
``"<doc>:<ref>"`` allowlist entry covers it.
|
||||
"""
|
||||
violations: list[Violation] = []
|
||||
for lineno, line in enumerate(text.splitlines(), start=1):
|
||||
for match in _LINK_TARGET_RE.finditer(line):
|
||||
target = match.group(1).split("#", 1)[0].strip()
|
||||
if not target or not _is_local_package_link(target):
|
||||
continue
|
||||
if not resolver(target):
|
||||
violations.append(Violation("link", doc_relpath, lineno, target))
|
||||
|
||||
if not is_live:
|
||||
continue
|
||||
for match in _INLINE_SPAN_RE.finditer(line):
|
||||
ref = match.group(1)
|
||||
if not _is_module_shaped(ref):
|
||||
continue
|
||||
if resolver(ref):
|
||||
continue
|
||||
if f"{doc_relpath}:{ref}" in allowlist:
|
||||
continue
|
||||
violations.append(Violation("inline", doc_relpath, lineno, ref))
|
||||
return violations
|
||||
|
||||
|
||||
# --- Filesystem helpers (I/O at the edge) -------------------------------------
|
||||
|
||||
|
||||
def _is_historical_prose(rel: str) -> bool:
|
||||
"""True for docs that intentionally name historical/old module paths in prose.
|
||||
|
||||
These are frozen-or-by-design historical records — ADRs, the refactor history,
|
||||
and the CHANGELOG (whose entries describe edits to modules *as they were named
|
||||
at the time*, e.g. ``cli/note.py`` for a fix that predates the ``_cmd`` rename).
|
||||
The inline module-ref check skips them; the broken-link check still applies (a
|
||||
dead *link* into the package is never intentional, even in history).
|
||||
"""
|
||||
return rel.startswith("docs/adr/") or rel == "docs/refactor-history.md" or rel == "CHANGELOG.md"
|
||||
|
||||
|
||||
def _iter_docs(repo_root: Path):
|
||||
"""Yield ``(path, relpath, is_live)`` for every doc the gate inspects.
|
||||
|
||||
Docs = every ``docs/**/*.md`` plus every root-level ``*.md``. CLAUDE.md is
|
||||
excluded because it is an agent-instruction file, not part of the live docs
|
||||
set. ``is_live`` is False for the historical-prose docs (see
|
||||
:func:`_is_historical_prose`) so the inline check skips them while the link
|
||||
check still applies.
|
||||
"""
|
||||
docs_dir = repo_root / "docs"
|
||||
md_paths: list[Path] = []
|
||||
if docs_dir.is_dir():
|
||||
md_paths.extend(sorted(docs_dir.rglob("*.md")))
|
||||
md_paths.extend(sorted(repo_root.glob("*.md")))
|
||||
|
||||
for path in md_paths:
|
||||
rel = path.relative_to(repo_root).as_posix()
|
||||
if rel == "CLAUDE.md":
|
||||
continue
|
||||
yield path, rel, not _is_historical_prose(rel)
|
||||
|
||||
|
||||
def _make_resolver(repo_root: Path, doc_path: Path) -> Callable[[str], bool]:
|
||||
"""Resolver closure for one doc.
|
||||
|
||||
For a module-shaped inline ref it checks ``src/notebooklm/<ref>``; for a
|
||||
doc-relative link target it resolves the target against the doc's directory.
|
||||
Both resolve through the same callable because the link target always
|
||||
contains ``src/notebooklm/`` (so it is never mistaken for a bare ref) and the
|
||||
inline ref never contains a path separator prefix like ``../``.
|
||||
"""
|
||||
package_root = repo_root / _PACKAGE_RELDIR
|
||||
|
||||
def resolver(ref_or_target: str) -> bool:
|
||||
if _is_local_package_link(ref_or_target):
|
||||
return (doc_path.parent / ref_or_target).resolve().exists()
|
||||
return (package_root / ref_or_target).exists()
|
||||
|
||||
return resolver
|
||||
|
||||
|
||||
def _unused_allowlist_entries(
|
||||
repo_root: Path, allowlist: dict[str, str], *, strict_missing: bool = False
|
||||
) -> list[str]:
|
||||
"""Return allowlist keys that are no longer justified (shrink-only guard).
|
||||
|
||||
An entry ``"<doc>:<ref>"`` is justified iff the doc still exists, still
|
||||
mentions ``<ref>`` as a module-shaped inline span, and that ``<ref>`` still
|
||||
does NOT resolve under ``src/notebooklm/``. The moment any of those stops
|
||||
being true the entry is dead weight and must be removed.
|
||||
|
||||
``strict_missing`` controls how a *missing* doc is treated. The default
|
||||
(False) skips an entry whose doc does not exist under ``repo_root`` — this
|
||||
keeps :func:`main` repo-root-agnostic, since the module-level allowlist keys
|
||||
the *real* repo's docs and a caller may point ``--repo-root`` at a synthetic
|
||||
tree. With ``strict_missing=True`` a missing doc IS flagged as stale; the
|
||||
real-repo allowlist test uses this so a renamed/deleted doc can't leave a
|
||||
dangling entry behind.
|
||||
"""
|
||||
package_root = repo_root / _PACKAGE_RELDIR
|
||||
unused: list[str] = []
|
||||
for key in allowlist:
|
||||
doc_rel, _, ref = key.partition(":")
|
||||
doc_path = repo_root / doc_rel
|
||||
if not doc_path.is_file():
|
||||
if strict_missing:
|
||||
unused.append(key)
|
||||
continue
|
||||
text = doc_path.read_text(encoding="utf-8")
|
||||
mentioned = any(
|
||||
ref == span and _is_module_shaped(span)
|
||||
for line in text.splitlines()
|
||||
for span in _INLINE_SPAN_RE.findall(line)
|
||||
)
|
||||
resolves = (package_root / ref).exists()
|
||||
if not mentioned or resolves:
|
||||
unused.append(key)
|
||||
return unused
|
||||
|
||||
|
||||
def collect_violations(repo_root: Path) -> list[Violation]:
|
||||
"""Read every doc and return all violations (filesystem-backed)."""
|
||||
violations: list[Violation] = []
|
||||
for path, rel, is_live in _iter_docs(repo_root):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
resolver = _make_resolver(repo_root, path)
|
||||
violations.extend(
|
||||
find_violations(
|
||||
rel,
|
||||
text,
|
||||
resolver=resolver,
|
||||
is_live=is_live,
|
||||
allowlist=_ALLOWLIST,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--repo-root", default=".")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
if not (repo_root / "docs").is_dir():
|
||||
print(f"docs/ directory not found under repo root: {repo_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
unused = _unused_allowlist_entries(repo_root, _ALLOWLIST)
|
||||
violations = collect_violations(repo_root)
|
||||
|
||||
if violations or unused:
|
||||
broken_links = [v for v in violations if v.kind == "link"]
|
||||
dead_inline = [v for v in violations if v.kind == "inline"]
|
||||
if broken_links:
|
||||
print("Broken links into src/notebooklm/:", file=sys.stderr)
|
||||
for v in broken_links:
|
||||
print(f" {v.doc}:{v.line} -> {v.target}", file=sys.stderr)
|
||||
if dead_inline:
|
||||
print("Dead inline module refs in live docs:", file=sys.stderr)
|
||||
for v in dead_inline:
|
||||
print(f" {v.doc}:{v.line} -> `{v.target}`", file=sys.stderr)
|
||||
if unused:
|
||||
print("Stale _ALLOWLIST entries (shrink-only; remove them):", file=sys.stderr)
|
||||
for key in unused:
|
||||
print(f" {key}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"OK: all doc links into src/notebooklm resolve; "
|
||||
"all live-doc inline module refs are fresh or allowlisted"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
"""Assert non-publish workflows have a top-level `permissions:` block.
|
||||
|
||||
Prevents supply-chain blast radius from default-permissive ``GITHUB_TOKEN``
|
||||
scopes on workflows that don't need them.
|
||||
|
||||
Usage:
|
||||
python scripts/check_workflow_permissions.py
|
||||
python scripts/check_workflow_permissions.py --workflow-dir custom/path
|
||||
|
||||
Exit codes:
|
||||
0 All non-allowlisted workflows have a top-level permissions block.
|
||||
1 One or more workflows are missing the block.
|
||||
2 Argument error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Workflows that intentionally rely on job-level permissions or default scopes.
|
||||
# codeql.yml: needs `security-events: write` (job-scoped is the standard).
|
||||
# publish.yml / testpypi-publish.yml: write to PyPI; permissions live at job level.
|
||||
ALLOWLIST = {
|
||||
"codeql.yml",
|
||||
"publish.yml",
|
||||
"testpypi-publish.yml",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--workflow-dir",
|
||||
default=".github/workflows",
|
||||
help="Directory containing workflow YAML files",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
workflow_dir = Path(args.workflow_dir)
|
||||
if not workflow_dir.is_dir():
|
||||
print(f"Not a directory: {workflow_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Assert each non-allowlisted workflow has a top-level `permissions:`
|
||||
# block whose body declares only read scopes. Reject inline strings
|
||||
# like `permissions: write-all`, `permissions: read-all`, or
|
||||
# `permissions: {}` — anything that isn't an explicit, scoped block
|
||||
# of read-only keys.
|
||||
#
|
||||
# We avoid a yaml dependency by parsing the small structural shape we
|
||||
# care about: header line `permissions:` (top-level, nothing else
|
||||
# on the line after the colon except optional comment) followed by
|
||||
# indented `<scope>: read` or `<scope>: none` lines.
|
||||
bad: list[str] = []
|
||||
issues: dict[str, str] = {}
|
||||
workflow_files = sorted(list(workflow_dir.glob("*.yml")) + list(workflow_dir.glob("*.yaml")))
|
||||
for path in workflow_files:
|
||||
if path.name in ALLOWLIST:
|
||||
continue
|
||||
lines = path.read_text().splitlines()
|
||||
header_idx = _find_top_level_permissions_header(lines)
|
||||
if header_idx is None:
|
||||
bad.append(str(path))
|
||||
issues[str(path)] = "no top-level `permissions:` block"
|
||||
continue
|
||||
ok, reason = _validate_block_body(lines, header_idx)
|
||||
if not ok:
|
||||
bad.append(str(path))
|
||||
issues[str(path)] = reason
|
||||
|
||||
if bad:
|
||||
for p in bad:
|
||||
print(f"{p}: {issues[p]}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("OK: all non-allowlisted workflows have a scoped permissions block")
|
||||
return 0
|
||||
|
||||
|
||||
# Scopes that may appear with `read` or `none` (or `write` only on allowlisted
|
||||
# workflows — but allowlisted ones never reach this validator). Sourced from
|
||||
# GitHub Actions workflow-syntax + GITHUB_TOKEN reference docs.
|
||||
_ALLOWED_READ_SCOPES = frozenset(
|
||||
{
|
||||
"actions",
|
||||
"artifact-metadata",
|
||||
"attestations",
|
||||
"checks",
|
||||
"code-scanning",
|
||||
"contents",
|
||||
"deployments",
|
||||
"discussions",
|
||||
"environments",
|
||||
"id-token",
|
||||
"issues",
|
||||
"labels",
|
||||
"merge-queues",
|
||||
"metadata",
|
||||
"migrations",
|
||||
"models",
|
||||
"packages",
|
||||
"pages",
|
||||
"pull-requests",
|
||||
"repository-projects",
|
||||
"security-contacts",
|
||||
"security-events",
|
||||
"statuses",
|
||||
"vulnerability-alerts",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _find_top_level_permissions_header(lines: list[str]) -> int | None:
|
||||
"""Return the index of a top-level `permissions:` header line, or None.
|
||||
|
||||
"Top-level" = column 0 (no indent). The value after the colon must be
|
||||
empty (or whitespace + comment): an inline value like `write-all` is
|
||||
not a scoped block.
|
||||
"""
|
||||
import re
|
||||
|
||||
header_re = re.compile(r"^permissions:\s*(#.*)?$")
|
||||
for i, line in enumerate(lines):
|
||||
if header_re.match(line):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _validate_block_body(lines: list[str], header_idx: int) -> tuple[bool, str]:
|
||||
"""Validate the indented body under `permissions:` allows only read scopes.
|
||||
|
||||
Returns (ok, reason). The body terminates at the next non-indented,
|
||||
non-blank line.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Accept optional single/double quotes around the value (valid YAML).
|
||||
item_re = re.compile(r"^( +)([a-z][a-z0-9-]*):\s*['\"]?([a-z-]+)['\"]?\s*(#.*)?$")
|
||||
|
||||
body_started = False
|
||||
for line in lines[header_idx + 1 :]:
|
||||
stripped = line.rstrip()
|
||||
if not stripped or stripped.lstrip().startswith("#"):
|
||||
continue
|
||||
if not line.startswith((" ", "\t")):
|
||||
break # end of block
|
||||
body_started = True
|
||||
m = item_re.match(line)
|
||||
if not m:
|
||||
return False, f"unparseable body line under permissions: {stripped!r}"
|
||||
scope = m.group(2)
|
||||
value = m.group(3)
|
||||
if scope not in _ALLOWED_READ_SCOPES:
|
||||
return False, f"unknown scope under permissions: {scope!r}"
|
||||
if value not in ("read", "none"):
|
||||
return False, f"scope {scope!r} has non-read value {value!r}"
|
||||
|
||||
if not body_started:
|
||||
return False, "permissions: header has no indented body (likely inline value)"
|
||||
return True, ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Assert workflow jobs that consume secrets are gated.
|
||||
|
||||
Companion to ``check_workflow_permissions.py``. That script proves the
|
||||
``GITHUB_TOKEN`` blast radius is scoped; this one proves that any *user-
|
||||
provided* secret (``secrets.NOTEBOOKLM_AUTH_JSON`` etc.) only unlocks on
|
||||
runs that have at least one of:
|
||||
|
||||
* a job-level ``environment:`` declaration (which can require maintainer
|
||||
approval before secrets resolve), OR
|
||||
* a job-level ``if:`` guard that pins the run to a trusted condition — we
|
||||
recognise ``sender.login``, ``github.actor``, and ``is_standard`` as the
|
||||
three conventions in use across this repo (manual-trigger actor pin,
|
||||
webhook actor pin, and branch-class pin respectively), OR
|
||||
* a step-level ``if:`` guard whose expression references an ``is_standard``
|
||||
output (the convention from ``nightly.yml`` where the ``resolve-branch``
|
||||
job sets ``outputs.is_standard`` to ``true`` only for ``main`` /
|
||||
``release/*`` / scheduled cron triggers).
|
||||
|
||||
``secrets.GITHUB_TOKEN`` is *not* counted as a user-provided secret — it's
|
||||
the auto-provisioned token whose scopes are bounded by the top-level
|
||||
``permissions:`` block (asserted independently by
|
||||
``check_workflow_permissions.py``). Anything else under ``secrets.*`` is
|
||||
in scope.
|
||||
|
||||
The check exists to prevent silent regressions where a workflow grows a
|
||||
new ``env: FOO: ${{ secrets.SOMETHING }}`` block without picking up an approved
|
||||
environment or trusted actor/branch ``if:`` gate. CI rejects the change.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/check_workflow_secret_gates.py
|
||||
python scripts/check_workflow_secret_gates.py --workflow-dir custom/path
|
||||
|
||||
Exit codes::
|
||||
|
||||
0 All secret-consuming jobs are gated.
|
||||
1 One or more jobs use ``secrets.*`` without an approved environment or
|
||||
trusted actor/branch guard. Offending file/line/job is printed to stderr.
|
||||
2 Argument error (missing directory, etc.).
|
||||
|
||||
Implementation notes
|
||||
--------------------
|
||||
|
||||
We intentionally avoid pulling in ``PyYAML`` for parity with
|
||||
``check_workflow_permissions.py`` and to keep the quality job's install
|
||||
footprint small. Workflow YAML in this repo uses a regular indentation
|
||||
shape (2 spaces for top-level keys, 2 per nest), so a small line-oriented
|
||||
state machine is sufficient to attribute each ``secrets.X`` reference to
|
||||
its enclosing job + step and to detect the gates we care about. The
|
||||
existing sibling checker uses the same pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Tokens that count as a non-bypassable secret. ``GITHUB_TOKEN`` is
|
||||
# auto-provisioned per-run and its scopes are constrained separately by
|
||||
# the ``permissions:`` checker, so it does not need an environment gate.
|
||||
_BENIGN_SECRETS = frozenset({"GITHUB_TOKEN"})
|
||||
|
||||
# Environments that we have actually configured with maintainer-approval
|
||||
# reviewers in the GitHub UI. GitHub Actions silently auto-creates a
|
||||
# referenced environment that doesn't exist — with NO protection rules —
|
||||
# which would make a typo (``environment: protectd-readonly``) or a
|
||||
# never-configured environment pass CI AND run without approval. Pin the
|
||||
# checker to this allow-list so any new environment name requires (a)
|
||||
# extending this set deliberately and (b) configuring the corresponding
|
||||
# GitHub Environment first. See docs/development.md → "Workflow secret
|
||||
# gates".
|
||||
_APPROVED_ENVIRONMENTS = frozenset({"protected-readonly"})
|
||||
|
||||
# Secret reference shapes:
|
||||
# * Dot notation: ``${{ secrets.MY_SECRET }}`` — the canonical form
|
||||
# we see everywhere in this repo.
|
||||
# * Bracket notation: ``${{ secrets['MY_SECRET'] }}`` /
|
||||
# ``${{ secrets["MY_SECRET"] }}`` — also legal.
|
||||
# * Dynamic indexing: ``${{ secrets[matrix.name] }}`` — caught by the
|
||||
# open-bracket sentinel ``secrets[`` even though
|
||||
# we can't statically resolve the secret name.
|
||||
# All three pass through this regex; ``_extract_secret_names`` resolves
|
||||
# the captured group(s) and returns concrete names where available,
|
||||
# falling back to a sentinel (``<dynamic>``) for dynamic indexing so the
|
||||
# gating check still runs.
|
||||
_SECRET_REF_RE = re.compile(
|
||||
r"\bsecrets"
|
||||
r"(?:"
|
||||
r"\.([A-Za-z_][A-Za-z0-9_]*)" # .NAME
|
||||
r"|\[\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\s*\]" # ['NAME']
|
||||
r"|\[\s*[^'\"\s\]][^\]]*\]" # [<dynamic>]
|
||||
r")"
|
||||
)
|
||||
|
||||
# Reusable-workflow ``secrets: inherit`` (passes ALL caller secrets to
|
||||
# the called workflow). Always treat as a secret consumer.
|
||||
_SECRETS_INHERIT_RE = re.compile(r"^\s*secrets:\s*inherit\s*(#.*)?$")
|
||||
|
||||
# Job header: two-space indent, then `<name>:` with nothing else on the
|
||||
# line. We pick up the line number to point users at the source.
|
||||
_JOB_HEADER_RE = re.compile(r"^ ([A-Za-z_][A-Za-z0-9_-]*):\s*(#.*)?$")
|
||||
|
||||
# Step start: four-space indent then `- ` then either `name:` or `uses:`
|
||||
# (or any other step-attribute key on the same line — pytest-style YAML
|
||||
# never puts an inline scalar there).
|
||||
_STEP_HEADER_RE = re.compile(r"^ - ([a-z][a-z0-9_-]*):")
|
||||
|
||||
# ``environment:`` declaration at job level (indent 4). May be a bare value
|
||||
# (``environment: foo``), a quoted value, or an expression (``${{ ... }}``).
|
||||
# Any non-empty value counts as "an environment is declared on this job".
|
||||
_JOB_ENVIRONMENT_RE = re.compile(r"^ environment:\s*(\S.*?)\s*(#.*)?$")
|
||||
|
||||
# Job-level ``if:`` guard (indent 4). Mirrors ``_STEP_IF_RE`` for the
|
||||
# enclosing job scope.
|
||||
_JOB_IF_RE = re.compile(r"^ if:\s*(.*?)\s*(#.*)?$")
|
||||
|
||||
# Step-level ``if:`` guard. May be a single-line scalar or the start of a
|
||||
# YAML block scalar (``if: |``). For the block case we collect subsequent
|
||||
# more-indented lines.
|
||||
_STEP_IF_RE = re.compile(r"^ if:\s*(.*?)\s*(#.*)?$")
|
||||
|
||||
# Comment line — entirely a YAML comment (optional leading whitespace
|
||||
# then ``#``). We skip these for secret detection so example references
|
||||
# in step comments (``secrets.NAME`` documentation strings) don't fail
|
||||
# the gate. The ``${{ ... }}`` expansion itself is YAML-significant only
|
||||
# in non-comment positions, so this is sound.
|
||||
_COMMENT_RE = re.compile(r"^\s*#")
|
||||
|
||||
# A guard expression is considered "trusted" if it matches one of these
|
||||
# POSITIVE-EQUALITY patterns — substring matching is unsafe because
|
||||
# ``if: github.actor != 'dependabot[bot]'`` and
|
||||
# ``if: needs.foo.outputs.is_standard != 'true'`` would both pass a
|
||||
# substring check while NOT actually gating to the trusted condition.
|
||||
# Each pattern requires an explicit ``==`` comparison to a quoted
|
||||
# literal value so an inverted guard is rejected (C-CODEX-3 fix).
|
||||
#
|
||||
# Tokens:
|
||||
# * ``needs.<job>.outputs.is_standard == 'true'`` — branch-class pin
|
||||
# (nightly.yml's ``resolve-branch`` job sets this for main/release/* +
|
||||
# scheduled triggers).
|
||||
# * ``github.event.sender.login == '<actor>'`` /
|
||||
# ``github.actor == '<actor>'`` — actor pin (claude.yml's
|
||||
# load-bearing security check).
|
||||
_TRUSTED_GUARD_PATTERNS = (
|
||||
# is_standard == 'true' / "true" (quoting may be single or double)
|
||||
re.compile(
|
||||
r"is_standard\s*==\s*['\"]true['\"]",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# sender.login == '<name>' / github.event.sender.login == '<name>'
|
||||
re.compile(
|
||||
r"sender\.login\s*==\s*['\"][A-Za-z0-9_.\-\[\]]+['\"]",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# github.actor == '<name>'
|
||||
re.compile(
|
||||
r"github\.actor\s*==\s*['\"][A-Za-z0-9_.\-\[\]]+['\"]",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _expression_is_trusted_guard(expr: str) -> bool:
|
||||
return any(p.search(expr) for p in _TRUSTED_GUARD_PATTERNS)
|
||||
|
||||
|
||||
def _environment_value_is_approved(value: str) -> bool:
|
||||
"""Return True iff the ``environment:`` value names an approved env.
|
||||
|
||||
Accepts:
|
||||
* a bare name in ``_APPROVED_ENVIRONMENTS`` (e.g. ``protected-readonly``)
|
||||
* a quoted name (``"protected-readonly"`` / ``'protected-readonly'``)
|
||||
|
||||
Rejects everything else, including:
|
||||
* empty strings, unknown names
|
||||
* **expression form** (``${{ ... }}``). The historical conditional
|
||||
shape
|
||||
``${{ event == 'workflow_dispatch' && 'protected-readonly' || '' }}``
|
||||
silently broke scheduled runs once the referenced secret was
|
||||
env-only (issue #1009): the empty branch resolves to "no
|
||||
environment", and env-only secrets resolve to empty under that
|
||||
branch. An expression without an empty fallback offers nothing
|
||||
over the bare ``environment: protected-readonly`` form, so we
|
||||
force the bare form — legible at a glance and impossible to
|
||||
falsy-branch around. Expression values never strip-match an
|
||||
approved name, so the check below rejects them as a side-effect
|
||||
of the literal-only match.
|
||||
"""
|
||||
if not value or value in ("''", '""'):
|
||||
return False
|
||||
return value.strip().strip("'\"") in _APPROVED_ENVIRONMENTS
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--workflow-dir",
|
||||
default=".github/workflows",
|
||||
help="Directory containing workflow YAML files",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
workflow_dir = Path(args.workflow_dir)
|
||||
if not workflow_dir.is_dir():
|
||||
print(f"Not a directory: {workflow_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
violations: list[str] = []
|
||||
workflow_files = sorted(list(workflow_dir.glob("*.yml")) + list(workflow_dir.glob("*.yaml")))
|
||||
for path in workflow_files:
|
||||
violations.extend(_scan_workflow(path))
|
||||
|
||||
if violations:
|
||||
for v in violations:
|
||||
print(v, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"OK: every workflow job that references user-provided secrets is "
|
||||
"gated by an approved `environment:` or trusted actor/branch `if:` guard."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _scan_workflow(path: Path) -> list[str]:
|
||||
"""Return a list of violation messages for ``path``.
|
||||
|
||||
A violation is a ``secrets.X`` reference (X != GITHUB_TOKEN) whose
|
||||
enclosing job has neither an ``environment:`` declaration nor a job-
|
||||
or step-level ``if:`` guard referencing a trusted condition.
|
||||
|
||||
The scan is two-pass per job: we first walk every line in the job to
|
||||
collect (line_no, secret_name, step_if_or_None) hits AND the job-
|
||||
final ``environment:`` + job-level ``if:`` state, then evaluate hits
|
||||
against the now-final job state. This avoids a class of key-order
|
||||
false-positives where the secret reference appears before the
|
||||
``environment:`` declaration in the same job (e.g. inside a
|
||||
``strategy: matrix`` block at the top of the job body).
|
||||
"""
|
||||
lines = path.read_text().splitlines()
|
||||
|
||||
# Workflow-wide state.
|
||||
jobs_section_started = False
|
||||
violations: list[str] = []
|
||||
|
||||
# Per-job state — reset on every job header.
|
||||
current_job: str | None = None
|
||||
current_job_has_environment = False
|
||||
current_job_if = ""
|
||||
# Step state inside the current job. Steps are addressed by ordinal
|
||||
# index (-1 = not in any step yet) so secret hits tagged with the
|
||||
# step index can be matched against the final step ``if:`` value at
|
||||
# flush time, even when ``if:`` appears AFTER the ``env:`` block in
|
||||
# the step (step-scope key-order safety).
|
||||
in_step = False
|
||||
current_step_index = -1
|
||||
step_ifs: list[str] = []
|
||||
# Block-scalar tracking applies to both job- and step-level ``if:``.
|
||||
in_if_block_scalar = False
|
||||
if_block_scalar_indent = 0
|
||||
if_block_target = "step"
|
||||
# Pending hits: (line_no, secret_name, step_index_or_-1). Evaluated
|
||||
# against ``step_ifs[step_index]`` at end-of-job. -1 means the hit
|
||||
# was at job scope (outside any step).
|
||||
pending_hits: list[tuple[int, str, int]] = []
|
||||
saw_any_job = False
|
||||
|
||||
def flush_job() -> None:
|
||||
"""Evaluate pending hits for the closing job, append violations."""
|
||||
if current_job is None:
|
||||
return
|
||||
if current_job_has_environment:
|
||||
return
|
||||
if _expression_is_trusted_guard(current_job_if):
|
||||
return
|
||||
for line_no, secret_name, step_index in pending_hits:
|
||||
if step_index >= 0:
|
||||
step_if = step_ifs[step_index]
|
||||
if step_if and _expression_is_trusted_guard(step_if):
|
||||
continue
|
||||
violations.append(
|
||||
f"{path}:{line_no}: secrets.{secret_name} used in job "
|
||||
f"{current_job!r} without an `environment:` declaration "
|
||||
"and without a trusted actor/branch `if:` guard on the job or step."
|
||||
)
|
||||
|
||||
def reset_for_new_job(job_name: str) -> None:
|
||||
nonlocal current_job, current_job_has_environment, current_job_if
|
||||
nonlocal in_step, current_step_index, step_ifs, pending_hits
|
||||
nonlocal in_if_block_scalar, saw_any_job
|
||||
current_job = job_name
|
||||
current_job_has_environment = False
|
||||
current_job_if = ""
|
||||
in_step = False
|
||||
current_step_index = -1
|
||||
step_ifs = []
|
||||
pending_hits = []
|
||||
in_if_block_scalar = False
|
||||
saw_any_job = True
|
||||
|
||||
for i, raw_line in enumerate(lines, start=1):
|
||||
line = raw_line.rstrip("\r")
|
||||
|
||||
# Detect entry into the top-level ``jobs:`` section.
|
||||
if not jobs_section_started:
|
||||
if line.startswith("jobs:"):
|
||||
jobs_section_started = True
|
||||
continue
|
||||
|
||||
# If we're inside a block-scalar ``if:`` expression, accumulate
|
||||
# continuation lines until we exit the block. The block ends when
|
||||
# we see a non-empty line whose indent is <= the ``if:`` key's
|
||||
# indent (4 for job-level, 6 for step-level). Strip per-line YAML
|
||||
# comments so a ``# is_standard`` decoy inside the block scalar
|
||||
# cannot satisfy the trusted-guard check.
|
||||
if in_if_block_scalar:
|
||||
stripped_rstrip = line.rstrip()
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
# An empty continuation line is still part of the block scalar.
|
||||
if stripped_rstrip and indent <= if_block_scalar_indent:
|
||||
in_if_block_scalar = False
|
||||
# Fall through to normal line handling below.
|
||||
else:
|
||||
fragment = _strip_yaml_trailing_comment(stripped_rstrip.strip())
|
||||
if if_block_target == "job":
|
||||
current_job_if += " " + fragment
|
||||
else:
|
||||
step_ifs[current_step_index] += " " + fragment
|
||||
continue
|
||||
|
||||
# Detect new job header. A job header at indent 2 closes the
|
||||
# previous job's pending-hit window.
|
||||
m_job = _JOB_HEADER_RE.match(line)
|
||||
if m_job:
|
||||
flush_job()
|
||||
reset_for_new_job(m_job.group(1))
|
||||
continue
|
||||
|
||||
if current_job is None:
|
||||
continue
|
||||
|
||||
# Track job-level ``environment:`` declaration. The value must name an
|
||||
# environment from ``_APPROVED_ENVIRONMENTS`` as a bare or quoted
|
||||
# literal. Expression-valued environments are rejected; use a trusted
|
||||
# job/step ``if:`` for conditional gating instead. The allow-list
|
||||
# defends against GitHub auto-creating typoed environments with no
|
||||
# protection rules.
|
||||
m_env = _JOB_ENVIRONMENT_RE.match(line)
|
||||
if m_env:
|
||||
value = m_env.group(1)
|
||||
if _environment_value_is_approved(value):
|
||||
current_job_has_environment = True
|
||||
continue
|
||||
|
||||
# Track job-level ``if:`` guards (e.g. ``claude.yml`` pinning
|
||||
# ``sender.login`` to the maintainer; ``verify-artifacts.yml``
|
||||
# gating on the actor). Captured BEFORE step detection so we know
|
||||
# not to misread it as a step ``if:``.
|
||||
if not in_step:
|
||||
m_job_if = _JOB_IF_RE.match(line)
|
||||
if m_job_if:
|
||||
value = m_job_if.group(1)
|
||||
if value in ("|", ">", "|-", ">-", "|+", ">+"):
|
||||
in_if_block_scalar = True
|
||||
if_block_scalar_indent = 4
|
||||
if_block_target = "job"
|
||||
current_job_if = ""
|
||||
else:
|
||||
current_job_if = _strip_yaml_trailing_comment(value)
|
||||
continue
|
||||
|
||||
# Detect step boundary. New step appends a fresh ``if:`` slot to
|
||||
# the per-job ``step_ifs`` list so hits in this step can be
|
||||
# gated against the step's final guard at flush time, even when
|
||||
# ``if:`` follows ``env:`` in the step body.
|
||||
#
|
||||
# Important: we do NOT ``continue`` after updating step state —
|
||||
# the step header line may itself contain an inline secret
|
||||
# reference (e.g. ``- run: echo ${{ secrets.X }}``) that must
|
||||
# still be recorded against the new step. Fall through to the
|
||||
# secret-detection block below.
|
||||
m_step = _STEP_HEADER_RE.match(line)
|
||||
if m_step:
|
||||
in_step = True
|
||||
current_step_index = len(step_ifs)
|
||||
step_ifs.append("")
|
||||
# Falls through to secret detection.
|
||||
|
||||
# Inside a step, capture the ``if:`` value. We attempt the match
|
||||
# only if the line did NOT just open a new step header — the
|
||||
# step-if regex is anchored at indent 6, the step-header regex
|
||||
# at indent 4 + ``- ``, so they cannot match the same line.
|
||||
elif in_step:
|
||||
m_if = _STEP_IF_RE.match(line)
|
||||
if m_if:
|
||||
value = m_if.group(1)
|
||||
if value in ("|", ">", "|-", ">-", "|+", ">+"):
|
||||
in_if_block_scalar = True
|
||||
if_block_scalar_indent = 6
|
||||
if_block_target = "step"
|
||||
step_ifs[current_step_index] = ""
|
||||
else:
|
||||
step_ifs[current_step_index] = _strip_yaml_trailing_comment(value)
|
||||
continue
|
||||
|
||||
# Skip comment lines outright — example references in docstring
|
||||
# comments (``${{ secrets.NAME }}`` documentation, security-design
|
||||
# notes, etc.) are not real expressions and must not trip the gate.
|
||||
if _COMMENT_RE.match(line):
|
||||
continue
|
||||
|
||||
# Strip any trailing ``# ...`` comment off the line before secret
|
||||
# detection, so an authored-out reference (``run: echo hi # not
|
||||
# ${{ secrets.X }}``) does not produce a spurious violation. The
|
||||
# strip is whitespace-anchored to avoid corrupting ``#`` inside
|
||||
# quoted strings (vanishingly rare in workflow YAML).
|
||||
searchable = _strip_yaml_trailing_comment(line)
|
||||
|
||||
# ``secrets: inherit`` on a reusable-workflow call passes every
|
||||
# caller secret to the called workflow. Treat as a non-bypassable
|
||||
# secret consumer so it requires the same gating.
|
||||
if _SECRETS_INHERIT_RE.match(searchable):
|
||||
pending_hits.append((i, "<inherit>", current_step_index if in_step else -1))
|
||||
continue
|
||||
|
||||
# Now check for ``secrets.X`` / ``secrets['X']`` / ``secrets[<expr>]``
|
||||
# references. Tag each hit with the enclosing step index (or -1
|
||||
# for job-scope hits) and defer the gating decision to flush_job(),
|
||||
# which sees the job-final environment + per-step final ``if:`` values.
|
||||
for m_secret in _SECRET_REF_RE.finditer(searchable):
|
||||
# Dot match -> group 1; bracket-quoted match -> group 2;
|
||||
# dynamic index -> neither group captures (use sentinel).
|
||||
secret_name = m_secret.group(1) or m_secret.group(2) or "<dynamic>"
|
||||
if secret_name in _BENIGN_SECRETS:
|
||||
continue
|
||||
pending_hits.append((i, secret_name, current_step_index if in_step else -1))
|
||||
|
||||
flush_job()
|
||||
|
||||
# Defence against silent-pass on a workflow with no recognisable
|
||||
# jobs (e.g. unexpected indentation, or a reusable-workflow stub).
|
||||
# If the file contains a top-level ``jobs:`` header but the parser
|
||||
# never identified a job, that's a parser miss — surface it so a
|
||||
# malformed checker invocation isn't mistaken for "all gated".
|
||||
if jobs_section_started and not saw_any_job:
|
||||
# Only complain when secrets are actually present in the file —
|
||||
# avoids noise on workflow stubs with no secrets at all.
|
||||
if any(_SECRET_REF_RE.search(line) for line in lines):
|
||||
violations.append(
|
||||
f"{path}: contains `jobs:` and `secrets.*` references but the "
|
||||
"parser identified no jobs (unexpected indentation?). Refusing "
|
||||
"to silently pass — please review or extend the checker."
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def _strip_yaml_trailing_comment(value: str) -> str:
|
||||
"""Strip a trailing ``# ...`` comment from a YAML scalar value.
|
||||
|
||||
The strip is anchored on whitespace before ``#`` so substrings of
|
||||
real values (``#foo`` adjacent to a letter, e.g. ``#fragment`` in a
|
||||
URL) are preserved. ``#`` inside a quoted string is theoretically
|
||||
legal YAML but does not appear in this repo's workflow YAML; we
|
||||
accept the rare false-strip risk in exchange for false-positive
|
||||
suppression on trailing-comment examples.
|
||||
"""
|
||||
return re.sub(r"\s+#.*$", "", value).strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diagnose GET_NOTEBOOK failures (Issue #114).
|
||||
|
||||
Compares LIST_NOTEBOOKS and GET_NOTEBOOK raw responses to help diagnose
|
||||
why GET_NOTEBOOK returns "No result found" while LIST_NOTEBOOKS works.
|
||||
|
||||
This script makes the exact same HTTP requests as the main client and
|
||||
prints the raw responses, parsed chunks, and found RPC IDs.
|
||||
|
||||
Usage:
|
||||
python scripts/diagnose_get_notebook.py <notebook_id>
|
||||
python scripts/diagnose_get_notebook.py # uses first notebook from list
|
||||
|
||||
Environment variables:
|
||||
NOTEBOOKLM_AUTH_JSON - Playwright storage state JSON (for CI)
|
||||
Or run 'notebooklm login' first for local auth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from notebooklm.auth import AuthTokens, fetch_tokens, load_auth_from_storage
|
||||
from notebooklm.rpc import (
|
||||
BATCHEXECUTE_URL,
|
||||
RPCMethod,
|
||||
build_request_body,
|
||||
encode_rpc_request,
|
||||
)
|
||||
from notebooklm.rpc.decoder import (
|
||||
collect_rpc_ids,
|
||||
decode_response,
|
||||
parse_chunked_response,
|
||||
strip_anti_xssi,
|
||||
)
|
||||
|
||||
|
||||
def load_auth() -> dict[str, str]:
|
||||
"""Load auth cookies from storage."""
|
||||
try:
|
||||
return load_auth_from_storage()
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"ERROR: No authentication found.\n"
|
||||
"Set NOTEBOOKLM_AUTH_JSON env var or run 'notebooklm login'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
print(f"ERROR: Invalid authentication: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def make_rpc_request(
|
||||
client: httpx.AsyncClient,
|
||||
auth: AuthTokens,
|
||||
method: RPCMethod,
|
||||
params: list,
|
||||
source_path: str = "/",
|
||||
) -> str:
|
||||
"""Make an RPC request and return raw response text."""
|
||||
query = urlencode(
|
||||
{
|
||||
"rpcids": method.value,
|
||||
"source-path": source_path,
|
||||
"f.sid": auth.session_id,
|
||||
"rt": "c",
|
||||
}
|
||||
)
|
||||
url = f"{BATCHEXECUTE_URL}?{query}"
|
||||
|
||||
rpc_request = encode_rpc_request(method, params)
|
||||
body = build_request_body(rpc_request, auth.csrf_token)
|
||||
|
||||
cookie_header = "; ".join(f"{k}={v}" for k, v in auth.cookies.items())
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie_header,
|
||||
}
|
||||
|
||||
response = await client.post(url, content=body, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
|
||||
def diagnose_response(label: str, raw: str, rpc_id: str) -> tuple[list, list, Any]:
|
||||
"""Print diagnostic info about a raw RPC response.
|
||||
|
||||
Returns:
|
||||
Tuple of (chunks, found_ids, decoded_result_or_None).
|
||||
"""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" {label}")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Raw response length: {len(raw)} bytes")
|
||||
print(f" First 200 chars: {raw[:200]!r}")
|
||||
|
||||
cleaned = strip_anti_xssi(raw)
|
||||
chunks = parse_chunked_response(cleaned)
|
||||
found_ids = collect_rpc_ids(chunks)
|
||||
|
||||
print(f" Chunks parsed: {len(chunks)}")
|
||||
print(f" Found RPC IDs: {found_ids}")
|
||||
print(f" Target RPC ID: {rpc_id}")
|
||||
print(f" Target in found_ids: {rpc_id in found_ids}")
|
||||
|
||||
# Show each chunk's structure
|
||||
for i, chunk in enumerate(chunks):
|
||||
if isinstance(chunk, list) and len(chunk) >= 2:
|
||||
tag = chunk[0] if isinstance(chunk[0], str) else "?"
|
||||
cid = chunk[1] if len(chunk) > 1 else "?"
|
||||
has_data = chunk[2] is not None if len(chunk) > 2 else False
|
||||
data_type = type(chunk[2]).__name__ if len(chunk) > 2 else "N/A"
|
||||
print(f" Chunk {i}: tag={tag}, id={cid}, has_data={has_data}, data_type={data_type}")
|
||||
else:
|
||||
preview = repr(chunk)[:100]
|
||||
print(f" Chunk {i}: {preview}")
|
||||
|
||||
# Try full decode_response (uses the same parsing internally)
|
||||
decoded_result = None
|
||||
try:
|
||||
decoded_result = decode_response(raw, rpc_id)
|
||||
preview = repr(decoded_result)[:200]
|
||||
print(f" decode_response: OK - {preview}")
|
||||
except Exception as e:
|
||||
print(f" decode_response: raised {type(e).__name__}: {e}")
|
||||
|
||||
return chunks, found_ids, decoded_result
|
||||
|
||||
|
||||
async def run_diagnosis(notebook_id: str | None = None) -> None:
|
||||
"""Run the diagnostic comparison."""
|
||||
cookies = load_auth()
|
||||
|
||||
print("Fetching auth tokens...")
|
||||
try:
|
||||
csrf_token, session_id = await fetch_tokens(cookies)
|
||||
except (ValueError, httpx.HTTPError) as e:
|
||||
print(f"ERROR: Failed to fetch auth tokens: {e}")
|
||||
print("Try running: notebooklm login")
|
||||
sys.exit(1)
|
||||
auth = AuthTokens(cookies=cookies, csrf_token=csrf_token, session_id=session_id)
|
||||
print(f"Auth OK (CSRF length: {len(auth.csrf_token)})")
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=60.0)) as client:
|
||||
# 1. LIST_NOTEBOOKS
|
||||
print("\n--- LIST_NOTEBOOKS ---")
|
||||
list_raw = await make_rpc_request(client, auth, RPCMethod.LIST_NOTEBOOKS, [])
|
||||
list_chunks, list_ids, list_result = diagnose_response(
|
||||
"LIST_NOTEBOOKS", list_raw, RPCMethod.LIST_NOTEBOOKS.value
|
||||
)
|
||||
|
||||
# Extract notebook ID if not provided
|
||||
if not notebook_id and list_result:
|
||||
try:
|
||||
if isinstance(list_result, list) and len(list_result) > 0:
|
||||
first_nb = list_result[0]
|
||||
if isinstance(first_nb, list):
|
||||
# Nested: [[notebook_data, ...], ...]
|
||||
if isinstance(first_nb[0], list):
|
||||
notebook_id = first_nb[0][2] if len(first_nb[0]) > 2 else None
|
||||
else:
|
||||
notebook_id = first_nb[2] if len(first_nb) > 2 else None
|
||||
except (IndexError, TypeError) as e:
|
||||
print(f" Warning: Could not extract notebook ID from LIST_NOTEBOOKS result: {e}")
|
||||
|
||||
if not notebook_id:
|
||||
print("\nERROR: Could not extract notebook ID from LIST_NOTEBOOKS.")
|
||||
print("Please provide a notebook ID as argument:")
|
||||
print(f" python {sys.argv[0]} <notebook_id>")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nUsing first notebook: {notebook_id}")
|
||||
|
||||
# 2. GET_NOTEBOOK
|
||||
print("\n--- GET_NOTEBOOK ---")
|
||||
get_raw = await make_rpc_request(
|
||||
client,
|
||||
auth,
|
||||
RPCMethod.GET_NOTEBOOK,
|
||||
[notebook_id, None, [2], None, 0],
|
||||
source_path=f"/notebook/{notebook_id}",
|
||||
)
|
||||
get_chunks, get_ids, _ = diagnose_response(
|
||||
"GET_NOTEBOOK", get_raw, RPCMethod.GET_NOTEBOOK.value
|
||||
)
|
||||
|
||||
# 3. Side-by-side comparison
|
||||
print(f"\n{'=' * 60}")
|
||||
print(" COMPARISON")
|
||||
print(f"{'=' * 60}")
|
||||
print(
|
||||
f" LIST_NOTEBOOKS: {len(list_raw)} bytes, {len(list_chunks)} chunks, IDs: {list_ids}"
|
||||
)
|
||||
print(f" GET_NOTEBOOK: {len(get_raw)} bytes, {len(get_chunks)} chunks, IDs: {get_ids}")
|
||||
|
||||
list_ok = RPCMethod.LIST_NOTEBOOKS.value in list_ids
|
||||
get_ok = RPCMethod.GET_NOTEBOOK.value in get_ids
|
||||
print(f"\n LIST_NOTEBOOKS working: {list_ok}")
|
||||
print(f" GET_NOTEBOOK working: {get_ok}")
|
||||
|
||||
if list_ok and not get_ok:
|
||||
print("\n DIAGNOSIS: LIST_NOTEBOOKS works but GET_NOTEBOOK does not.")
|
||||
print(" This matches Issue #114. The GET_NOTEBOOK RPC may be:")
|
||||
print(" - Returning null result data (server-side issue)")
|
||||
print(" - Requiring different parameters than expected")
|
||||
print(" - Affected by a server-side change not yet reflected in method IDs")
|
||||
elif list_ok and get_ok:
|
||||
print("\n DIAGNOSIS: Both RPCs are working. Issue may be intermittent.")
|
||||
else:
|
||||
print("\n DIAGNOSIS: Both RPCs are failing. Check authentication.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
notebook_id = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
|
||||
print("GET_NOTEBOOK Diagnostic Tool (Issue #114)")
|
||||
print("=" * 60)
|
||||
|
||||
asyncio.run(run_diagnosis(notebook_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone release sanity tool: drive a RUNNING remote MCP server's file
|
||||
side-channel end to end and print PASS/FAIL.
|
||||
|
||||
This productionizes the upload+download round-trip the Layer-B e2e tests run
|
||||
in-process, but against a real deployed server (the claude.ai connector backend)
|
||||
— it is the bootstrap for the manual "MCP connector smoke" release checklist in
|
||||
``docs/releasing.md``. Point it at the server's public base URL + bearer token;
|
||||
it mints a signed upload URL through ``source_add(file)``, POSTs a small file to
|
||||
it, confirms the source landed, then (optionally) mints a download URL for an
|
||||
existing artifact and streams it back.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/mcp_live_smoke.py \
|
||||
--base-url https://your-tunnel.example.com \
|
||||
--bearer "$NOTEBOOKLM_MCP_TOKEN" \
|
||||
--notebook <notebook-id-or-name> \
|
||||
[--download-notebook <id>] [--artifact-type report]
|
||||
|
||||
Requires the ``mcp`` extra (``fastmcp`` + ``httpx``). Exits 0 on PASS, 1 on FAIL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
#: Artifact ``_artifact_type`` values whose download is wired through
|
||||
#: ``artifact_download`` (serialized form, underscored; the tool key is hyphenated).
|
||||
_DOWNLOADABLE = {
|
||||
"audio",
|
||||
"video",
|
||||
"slide_deck",
|
||||
"infographic",
|
||||
"report",
|
||||
"mind_map",
|
||||
"data_table",
|
||||
"quiz",
|
||||
"flashcards",
|
||||
}
|
||||
|
||||
|
||||
def _ok(msg: str) -> None:
|
||||
print(f" PASS {msg}")
|
||||
|
||||
|
||||
def _fail(msg: str) -> None:
|
||||
print(f" FAIL {msg}")
|
||||
|
||||
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Live PASS/FAIL smoke for a running remote MCP server's file routes.",
|
||||
)
|
||||
parser.add_argument("--base-url", required=True, help="Public base URL of the MCP server.")
|
||||
parser.add_argument(
|
||||
"--bearer",
|
||||
default=os.environ.get("NOTEBOOKLM_MCP_TOKEN"),
|
||||
help="Bearer token (defaults to $NOTEBOOKLM_MCP_TOKEN).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--notebook",
|
||||
required=True,
|
||||
help="Notebook id or name to upload the smoke source into.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--download-notebook",
|
||||
default=None,
|
||||
help="Notebook to download an existing artifact from (default: --notebook).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--artifact-type",
|
||||
default=None,
|
||||
help="Force a download artifact_type (default: auto-pick an existing one).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-download",
|
||||
action="store_true",
|
||||
help="Only run the upload round-trip.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
async def _run(args: argparse.Namespace) -> bool:
|
||||
import httpx
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.transports import StreamableHttpTransport
|
||||
|
||||
if not args.bearer:
|
||||
_fail("no bearer token: pass --bearer or set $NOTEBOOKLM_MCP_TOKEN")
|
||||
return False
|
||||
|
||||
base_url = args.base_url.rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {args.bearer}"}
|
||||
transport = StreamableHttpTransport(f"{base_url}/mcp", headers=headers)
|
||||
|
||||
passed = True
|
||||
async with Client(transport) as mcp, httpx.AsyncClient(timeout=120.0) as http:
|
||||
# --- upload round-trip -------------------------------------------------
|
||||
print("Upload round-trip:")
|
||||
body = b"notebooklm MCP live smoke upload.\n"
|
||||
result = await mcp.call_tool(
|
||||
"source_add",
|
||||
{
|
||||
"notebook": args.notebook,
|
||||
"source_type": "file",
|
||||
"title": "MCP live smoke",
|
||||
"mime_type": "text/plain",
|
||||
},
|
||||
)
|
||||
structured = result.structured_content or {}
|
||||
if structured.get("status") != "upload_required" or not structured.get("url"):
|
||||
_fail(f"source_add(file) did not return an upload URL: {structured}")
|
||||
return False
|
||||
_ok("minted signed upload URL")
|
||||
|
||||
up = await http.post(
|
||||
structured["url"]
|
||||
+ ("&" if urlsplit(structured["url"]).query else "?")
|
||||
+ "filename=mcp-smoke.txt",
|
||||
content=body,
|
||||
headers={"Accept": "application/json", "Content-Type": "text/plain"},
|
||||
)
|
||||
if up.status_code != 200:
|
||||
_fail(f"upload POST returned {up.status_code}: {up.text}")
|
||||
return False
|
||||
source_id = up.json().get("source_id")
|
||||
if not source_id:
|
||||
_fail(f"upload response missing source_id: {up.text}")
|
||||
return False
|
||||
_ok(f"uploaded source {source_id}")
|
||||
|
||||
listing = await mcp.call_tool("source_list", {"notebook": args.notebook})
|
||||
ids = [s["id"] for s in (listing.structured_content or {}).get("sources", [])]
|
||||
if source_id in ids:
|
||||
_ok("source confirmed live in source_list")
|
||||
else:
|
||||
_fail("uploaded source not found in source_list")
|
||||
passed = False
|
||||
|
||||
# --- download round-trip ----------------------------------------------
|
||||
if args.skip_download:
|
||||
print("Download round-trip: skipped (--skip-download)")
|
||||
return passed
|
||||
|
||||
print("Download round-trip:")
|
||||
dl_notebook = args.download_notebook or args.notebook
|
||||
art_listing = await mcp.call_tool("artifact_list", {"notebook": dl_notebook})
|
||||
artifacts = (art_listing.structured_content or {}).get("artifacts", [])
|
||||
if args.artifact_type:
|
||||
# Tolerate underscored values copied from artifact metadata
|
||||
# (slide_deck/mind_map/…); the download tool expects hyphens.
|
||||
dl_type = args.artifact_type.replace("_", "-")
|
||||
else:
|
||||
candidate = next(
|
||||
(
|
||||
a
|
||||
for a in artifacts
|
||||
if a.get("_artifact_type") in _DOWNLOADABLE
|
||||
and a.get("status") in (None, "ready", "completed")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if candidate is None:
|
||||
_fail("no existing downloadable artifact (pass --artifact-type or generate one)")
|
||||
return False
|
||||
dl_type = candidate["_artifact_type"].replace("_", "-")
|
||||
|
||||
dl_result = await mcp.call_tool(
|
||||
"artifact_download", {"notebook": dl_notebook, "artifact_type": dl_type}
|
||||
)
|
||||
dl_structured = dl_result.structured_content or {}
|
||||
if dl_structured.get("status") != "download_ready" or not dl_structured.get("url"):
|
||||
_fail(f"artifact_download did not return a download URL: {dl_structured}")
|
||||
return False
|
||||
_ok(f"minted signed download URL for {dl_type}")
|
||||
|
||||
resp = await http.get(dl_structured["url"])
|
||||
if resp.status_code != 200 or not resp.content:
|
||||
_fail(f"download GET returned {resp.status_code} ({len(resp.content)} bytes)")
|
||||
return False
|
||||
_ok(f"downloaded {len(resp.content)} bytes")
|
||||
|
||||
return passed
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv)
|
||||
try:
|
||||
passed = asyncio.run(_run(args))
|
||||
except Exception as exc: # noqa: BLE001 - top-level smoke wants one clean line
|
||||
print(f" FAIL unexpected error: {exc}")
|
||||
passed = False
|
||||
print()
|
||||
print("RESULT: PASS" if passed else "RESULT: FAIL")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the committed test baselines from live code (ADR-0022).
|
||||
|
||||
A *baseline* is a committed snapshot of a value the code already derives — e.g.
|
||||
``notebooklm.types.__all__``, the collected public surface of the ungated public
|
||||
modules, or the CLI command tree. They live in ``tests/fixtures/baselines/`` (plus
|
||||
the pre-existing ``tests/fixtures/cli_contract_baseline.json``) and are registered
|
||||
in ``tests/_baselines/registry.py``.
|
||||
|
||||
This is the discoverable wrapper around the dev-only ``--update-baselines`` pytest
|
||||
flag: it runs the registry-driven freeze test in *update* mode, which rewrites each
|
||||
committed file from ``derive()``. After running, review the ``git diff`` — every
|
||||
changed line is a deliberate, reviewed acknowledgement of a public-surface change.
|
||||
|
||||
python scripts/regen_baselines.py
|
||||
|
||||
**Dev-only-regen invariant (ADR-0022):** CI never regenerates — it only diffs the
|
||||
committed files against ``derive()``. This script (and the underlying fixture)
|
||||
refuses to run when a CI environment is detected, so it cannot silently rewrite
|
||||
baselines in automation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# The single freeze test that performs the rewrite under ``--update-baselines``.
|
||||
_BASELINE_FREEZE_TEST = (
|
||||
"tests/_guardrails/test_public_surface_manifest.py::test_baseline_matches_committed_file"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
|
||||
if os.environ.get("CI", "").strip():
|
||||
print(
|
||||
"refusing to regenerate baselines in CI: CI only diffs (ADR-0022). "
|
||||
"Run this locally and commit the result.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
_BASELINE_FREEZE_TEST,
|
||||
"--update-baselines",
|
||||
"-q",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
*argv,
|
||||
]
|
||||
print("regenerating baselines:", " ".join(cmd), file=sys.stderr)
|
||||
result = subprocess.run(cmd, cwd=_PROJECT_ROOT)
|
||||
if result.returncode == 0:
|
||||
print(
|
||||
"baselines regenerated; review `git diff` — each change is a "
|
||||
"deliberate public-surface acknowledgement.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bulk re-scrub VCR cassettes for shape-based leaks the recorder missed.
|
||||
|
||||
Collapses every ``lh3.googleusercontent.com/(?:a|ogw)/<token>`` avatar URL to
|
||||
the canonical ``SCRUBBED_AVATAR_URL`` placeholder, every double-encoded
|
||||
``authuser%3D<email>`` redirect param to ``authuser%3DSCRUBBED_EMAIL%40example.com``
|
||||
(issue #1368), and re-derives the chunked ``<count>\\n<payload>\\n`` byte-count
|
||||
prefixes inside every recorded response body. Writes back only if anything
|
||||
changed; idempotent on a clean tree.
|
||||
|
||||
Why this script exists
|
||||
----------------------
|
||||
The avatar-URL scrubber (PR #565) added
|
||||
``lh3.googleusercontent.com/(?:a|ogw)/<token>`` → ``SCRUBBED_AVATAR_URL`` to
|
||||
the canonical pattern registry, but the 61 cassettes recorded before that
|
||||
pattern landed still embed the raw ``/ogw/`` avatar URLs. They are listed in
|
||||
``tests/scripts/cassette_repair_allowlist.txt`` under the "/ogw/ avatar URL
|
||||
group" header; this script is the one-off tool that walks each of those
|
||||
cassettes, re-scrubs them in place, and reports a byte-level diff so reviewers
|
||||
can verify the change set.
|
||||
|
||||
The same one-off-re-scrub need applies to the double-encoded
|
||||
``authuser%3D<email>`` shape (issue #1368): the canonical scrubber learned this
|
||||
form only after 9 cassettes had already been recorded with the maintainer's
|
||||
email leaked inside Google account-menu ``continue=`` redirect URLs, so they
|
||||
need a re-scrub in place too.
|
||||
|
||||
Why we DON'T call ``scrub_string`` here
|
||||
---------------------------------------
|
||||
``cassette_patterns.scrub_string`` applies every pattern in
|
||||
:data:`SENSITIVE_PATTERNS` — including the escaped-display-name
|
||||
scrubber that anchors on ``\\"First Last\\"`` inside double-encoded WRB
|
||||
payloads. That pattern carries a small false-positive allowlist
|
||||
(``DISPLAY_NAME_FALSE_POSITIVES``) covering font families, UI titles, and
|
||||
a handful of artifact / notebook titles from the test corpus — but the
|
||||
existing cassettes contain many more legitimate two-Capitalized-word
|
||||
titles ("Agent Architecture Quiz", "Study Guide", "Answer Key", "Context
|
||||
Caching Economics", ...) that would be silently clobbered to
|
||||
``SCRUBBED_NAME`` and break the cli-vcr tests that rely on matching
|
||||
those titles in the parsed response.
|
||||
|
||||
So the script applies ONLY the avatar-URL scrubber by name. Every other
|
||||
pattern in the registry — display names, emails, cookies, tokens — is
|
||||
already correctly applied on record by ``vcr_config.scrub_response`` and
|
||||
has been for every cassette in the tree; running them again here cannot
|
||||
produce new scrubs (the registry is idempotent on its own placeholders),
|
||||
but it CAN produce display-name false positives the recorder's allowlist
|
||||
was never tuned to cover.
|
||||
|
||||
The byte-count re-derivation (``recompute_chunk_prefix``) runs on every
|
||||
body unconditionally — it's a documented no-op on bodies that don't look
|
||||
chunked, and a safety net for any chunked body whose avatar URL inside a
|
||||
WRB payload shifted its length.
|
||||
|
||||
Architecture notes
|
||||
------------------
|
||||
The script parses each cassette with PyYAML's **safe** loader
|
||||
(``yaml.CSafeLoader`` if libyaml is available, else ``yaml.SafeLoader``) and
|
||||
applies the scrubbers to every ``response.body.string`` field. After
|
||||
scrubbing, the cassette is re-emitted with ``yaml.dump(data, Dumper=Dumper)``
|
||||
— matching vcrpy's own serializer (``vcrpy/serializers/yamlserializer.py``)
|
||||
— so a clean cassette round-trips identically. The safe-loader choice is
|
||||
intentional (P1-22): the previous ``CLoader`` / ``Loader`` family
|
||||
deserializes arbitrary Python via ``!!python/object`` tags and is a
|
||||
documented remote-code-execution risk on untrusted input. The dumper side
|
||||
stays on the standard ``CDumper`` / ``Dumper`` because the round-trip data
|
||||
contains only ``str`` / ``bytes`` / ``dict`` / ``list`` / ``None`` /
|
||||
``int`` — all of which the safe loader accepts without trouble.
|
||||
|
||||
We deliberately do NOT scrub the raw YAML text: a regex applied to the
|
||||
wrapped YAML form could match across YAML line-wrap boundaries and corrupt
|
||||
the file structure. Parsing first guarantees the scrubber only ever sees
|
||||
the semantic string content the recorder produced, never the YAML
|
||||
serialization artifacts around it.
|
||||
|
||||
Usage
|
||||
-----
|
||||
::
|
||||
|
||||
uv run python scripts/rescrub-cassettes.py # rescrub all
|
||||
uv run python scripts/rescrub-cassettes.py <paths...> # rescrub specific files
|
||||
|
||||
Exit codes:
|
||||
0 — done (whether or not anything changed)
|
||||
1 — a cassette failed to parse or write
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# Use libyaml-backed SAFE loader/dumper when available (P1-22). The previous
|
||||
# implementation imported the unsafe ``CLoader`` / ``Loader`` family, which
|
||||
# can deserialize arbitrary Python objects via tags like ``!!python/object``
|
||||
# and is documented as a remote-code-execution risk on untrusted YAML.
|
||||
# Cassettes are committed to the repo so the input is not adversarial today,
|
||||
# but the rescrub tool runs on any path the operator passes — including
|
||||
# downloaded cassettes from third-party debugging — so the safe loader is
|
||||
# the right default.
|
||||
#
|
||||
# The dumper side stays on the standard (non-safe) ``CDumper`` / ``Dumper``
|
||||
# because cassettes legitimately serialize ``str`` and ``bytes`` (vcrpy's
|
||||
# YAML serializer does the same), neither of which the safe loader rejects
|
||||
# on round-trip. The risk vector is on the LOAD path, not the DUMP path.
|
||||
try:
|
||||
from yaml import CDumper as Dumper
|
||||
from yaml import CSafeLoader as Loader
|
||||
except ImportError: # pragma: no cover — libyaml is a hard dep on dev machines
|
||||
from yaml import Dumper # type: ignore[assignment]
|
||||
from yaml import SafeLoader as Loader
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_TESTS_DIR = _REPO_ROOT / "tests"
|
||||
_CASSETTE_DIR = _TESTS_DIR / "cassettes"
|
||||
|
||||
# Import the cassette helper through the historical tests-dir path used by this
|
||||
# script; keeping the path narrow avoids adding the repo root to ``sys.path``.
|
||||
sys.path.insert(0, str(_TESTS_DIR))
|
||||
|
||||
from cassette_patterns import ( # noqa: E402
|
||||
AUTHUSER_EMAIL_DOUBLE_ENCODED_PATTERN,
|
||||
recompute_chunk_prefix,
|
||||
)
|
||||
|
||||
# The avatar-URL regex/replacement pair this script applies. Mirrored verbatim
|
||||
# from ``cassette_patterns.SENSITIVE_PATTERNS`` (section 13 — see the comment
|
||||
# block above for the rationale on why we copy it instead of running the
|
||||
# whole registry). When the canonical pattern changes in
|
||||
# ``cassette_patterns.py`` this constant should be updated in lockstep —
|
||||
# the unit test ``test_avatar_pattern_matches_registry`` in
|
||||
# ``tests/unit/test_rescrub_cassettes_script.py`` asserts the two stay in
|
||||
# sync so the drift is caught at PR review time.
|
||||
_AVATAR_URL_RE = re.compile(r"https?://lh3\.googleusercontent\.com/(?:a|ogw)/[A-Za-z0-9_=\-]+")
|
||||
_AVATAR_URL_REPLACEMENT = "SCRUBBED_AVATAR_URL"
|
||||
|
||||
# Double-encoded ``authuser%3D<email>`` redirect-param shape (issue #1368). This
|
||||
# leaked into 9 cassettes recorded BEFORE the canonical scrubber learned the
|
||||
# double-encoded form, so — like the avatar URLs — the committed fixtures need a
|
||||
# one-off re-scrub. Unlike the avatar pattern (copied for historical reasons),
|
||||
# we import the canonical regex directly from ``cassette_patterns`` so there is
|
||||
# no second copy to drift. The double-encoded shape has no legitimate
|
||||
# occurrence in fixture content, so applying it here cannot clobber test data
|
||||
# (the same reason ``scrub_string`` is not safe to run wholesale — see the
|
||||
# module docstring — does not apply to this surgical, false-positive-free shape).
|
||||
_AUTHUSER_EMAIL_DOUBLE_ENCODED_RE = re.compile(AUTHUSER_EMAIL_DOUBLE_ENCODED_PATTERN)
|
||||
_AUTHUSER_EMAIL_DOUBLE_ENCODED_REPLACEMENT = "authuser%3DSCRUBBED_EMAIL%40example.com"
|
||||
|
||||
|
||||
def _scrub_body_text(text: str) -> str:
|
||||
"""Apply this script's surgical scrubbers to a single text body.
|
||||
|
||||
Replaces ``lh3...../(a|ogw)/<token>`` avatar URLs and double-encoded
|
||||
``authuser%3D<email>`` redirect params with their canonical placeholders.
|
||||
Isolated from the rest of the canonical registry by design — see the
|
||||
module docstring for why this script doesn't call ``scrub_string``.
|
||||
"""
|
||||
text = _AVATAR_URL_RE.sub(_AVATAR_URL_REPLACEMENT, text)
|
||||
text = _AUTHUSER_EMAIL_DOUBLE_ENCODED_RE.sub(_AUTHUSER_EMAIL_DOUBLE_ENCODED_REPLACEMENT, text)
|
||||
return text
|
||||
|
||||
|
||||
def _rescrub_body(body: str | bytes) -> tuple[str | bytes, bool]:
|
||||
"""Apply this script's scrubbers + recompute_chunk_prefix to a body value.
|
||||
|
||||
Returns ``(new_body, changed)``. The body is preserved in its original
|
||||
type (``str`` or ``bytes``) so re-emitted cassettes match what vcrpy
|
||||
would have produced.
|
||||
|
||||
Binary bodies that aren't valid UTF-8 (audio, images, gzipped chunks)
|
||||
are returned unchanged — the scrub patterns are text-oriented and
|
||||
cannot meaningfully apply to binary payloads.
|
||||
"""
|
||||
if isinstance(body, bytes):
|
||||
try:
|
||||
decoded = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return body, False
|
||||
scrubbed = _scrub_body_text(decoded)
|
||||
rederived = recompute_chunk_prefix(scrubbed)
|
||||
encoded = rederived.encode("utf-8")
|
||||
return encoded, encoded != body
|
||||
if isinstance(body, str):
|
||||
scrubbed = _scrub_body_text(body)
|
||||
rederived = recompute_chunk_prefix(scrubbed)
|
||||
return rederived, rederived != body
|
||||
# ``body`` is something exotic (None, dict). Leave it alone.
|
||||
return body, False
|
||||
|
||||
|
||||
def _rescrub_cassette(path: Path) -> tuple[bool, int]:
|
||||
"""Re-scrub a single cassette file. Returns ``(changed, byte_diff)``.
|
||||
|
||||
``byte_diff`` is ``len(new_bytes) - len(old_bytes)`` — useful for the
|
||||
per-file diff stat the script prints at the end of a run.
|
||||
"""
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
# ``Loader`` is bound to ``CSafeLoader`` / ``SafeLoader`` at import time
|
||||
# above — no ``!!python/object`` tags will be deserialized regardless
|
||||
# of what the cassette contains (P1-22).
|
||||
data = yaml.load(raw, Loader=Loader)
|
||||
if not isinstance(data, dict):
|
||||
return False, 0
|
||||
|
||||
body_changed = False
|
||||
for interaction in data.get("interactions") or []:
|
||||
body_container = (interaction.get("response") or {}).get("body") or {}
|
||||
if "string" not in body_container:
|
||||
continue
|
||||
new_body, changed = _rescrub_body(body_container["string"])
|
||||
if changed:
|
||||
body_container["string"] = new_body
|
||||
body_changed = True
|
||||
|
||||
if not body_changed:
|
||||
return False, 0
|
||||
|
||||
dumped = yaml.dump(data, Dumper=Dumper)
|
||||
# ``yaml.dump`` with the default ``Dumper`` returns ``str``; vcrpy's
|
||||
# serializer behaves the same way. Write back as UTF-8 — the cassettes
|
||||
# routinely carry emoji in artifact / notebook titles and the platform
|
||||
# encoding (cp1252 on Windows) cannot encode them.
|
||||
path.write_text(dumped, encoding="utf-8")
|
||||
return True, len(dumped.encode("utf-8")) - len(raw.encode("utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Bulk re-scrub VCR cassettes for lh3.googleusercontent.com/(a|ogw) "
|
||||
"avatar URLs. Re-derives chunk byte-counts in the same pass. "
|
||||
"Idempotent."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
nargs="*",
|
||||
help=(
|
||||
"Cassette file(s) to re-scrub. If omitted, walks "
|
||||
"tests/cassettes/*.yaml from the repo root."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.paths:
|
||||
targets = [Path(p) for p in args.paths]
|
||||
else:
|
||||
if not _CASSETTE_DIR.exists():
|
||||
print(f"No cassette directory at {_CASSETTE_DIR}", file=sys.stderr)
|
||||
return 0
|
||||
targets = sorted(_CASSETTE_DIR.glob("*.yaml"))
|
||||
|
||||
if not targets:
|
||||
print("No cassettes to re-scrub.")
|
||||
return 0
|
||||
|
||||
total_changed = 0
|
||||
total_byte_diff = 0
|
||||
failed: list[tuple[Path, str]] = []
|
||||
|
||||
for path in targets:
|
||||
try:
|
||||
changed, byte_diff = _rescrub_cassette(path)
|
||||
except (yaml.YAMLError, OSError) as exc:
|
||||
failed.append((path, str(exc)))
|
||||
print(f"FAIL {path.name}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
if changed:
|
||||
total_changed += 1
|
||||
total_byte_diff += byte_diff
|
||||
sign = "+" if byte_diff >= 0 else ""
|
||||
print(f"scrubbed {path.name} ({sign}{byte_diff} bytes)")
|
||||
else:
|
||||
print(f"clean {path.name}")
|
||||
|
||||
print(
|
||||
f"\nSummary: {total_changed}/{len(targets)} cassettes re-scrubbed, "
|
||||
f"net byte diff {total_byte_diff:+d}."
|
||||
)
|
||||
if failed:
|
||||
print(f"FAILURES: {len(failed)}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scrub a DevTools **HAR** export down to NotebookLM's RPC payload SHAPES —
|
||||
request *and* response — with no cookies, tokens, or free-text values.
|
||||
|
||||
Capture once in the browser (DevTools → Network → ⤓ **Export HAR** /
|
||||
"Save all as HAR with content"), then::
|
||||
|
||||
python scrub_rpc_har.py capture.har # all batchexecute calls
|
||||
python scrub_rpc_har.py capture.har --rpcid CCqFvf # just one
|
||||
|
||||
For every ``/batchexecute`` call the tool reads ONLY the request body's ``f.req``
|
||||
field and the response body — never the ``headers``/``cookies`` arrays (where
|
||||
cookies, the ``at=`` CSRF token and ``Set-Cookie`` live) — and redacts every
|
||||
string leaf to its length, keeping the structural constants that carry the
|
||||
wire-format signal. Output is safe to paste into a bug report by construction.
|
||||
|
||||
For each RPC it prints:
|
||||
request : the params the web UI sent
|
||||
response : HTTP status + the gRPC status code (e.g. [3]) and/or result shape
|
||||
|
||||
so you can see at a glance whether the server rejected the request (a payload
|
||||
change) or returned a new result shape (a decode change).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import unquote
|
||||
|
||||
# Fail-closed perimeter. ``_redact`` turns every string leaf into ``<str:N>``,
|
||||
# so safety here is STRUCTURAL, not shape-based: ``_unredacted`` asserts that
|
||||
# every string that survives into the output is a ``<str:N>`` placeholder and
|
||||
# nothing else. This is shape-AGNOSTIC by design — it knows nothing about
|
||||
# credential formats (``AIza…`` keys, ``SNlM0e`` CSRF, OAuth/session tokens,
|
||||
# account email, or the ``WIZ_global_data`` page-HTML they hide in), so no new
|
||||
# Google token shape can ever outrun it, and there is no credential registry to
|
||||
# keep in sync with the runtime scrubber (src/notebooklm/_secrets.py).
|
||||
_REDACTED_STR = re.compile(r"<str:\d+>")
|
||||
# rpcids are short public method IDs (e.g. ``CCqFvf``); printed verbatim, so
|
||||
# pin their shape to keep a malformed HAR from injecting text through that slot.
|
||||
_SAFE_RPCID = re.compile(r"[A-Za-z0-9_]{3,24}")
|
||||
|
||||
# A tiny rpcid → friendly-name map (write path + common reads); unknowns show raw.
|
||||
_NAMES = {
|
||||
"CCqFvf": "CREATE_NOTEBOOK",
|
||||
"izAoDd": "ADD_SOURCE",
|
||||
"o4cbdc": "ADD_SOURCE_FILE",
|
||||
"wXbhsf": "LIST_NOTEBOOKS",
|
||||
"rLM1Ne": "GET_NOTEBOOK",
|
||||
"CYK0Xb": "DELETE_NOTEBOOK",
|
||||
"cZsgsb": "CREATE_NOTE",
|
||||
"izh1Gb": "GENERATE",
|
||||
"Ljjv0c": "START_FAST_RESEARCH",
|
||||
}
|
||||
|
||||
|
||||
def _redact(node):
|
||||
if isinstance(node, str):
|
||||
return f"<str:{len(node)}>"
|
||||
if isinstance(node, list):
|
||||
return [_redact(x) for x in node]
|
||||
if isinstance(node, dict):
|
||||
# Keys are wire strings too — redact them, else a decoded object like
|
||||
# {"user@gmail.com": ...} would leak the key verbatim.
|
||||
return {_redact(k): _redact(v) for k, v in node.items()}
|
||||
return node # int / float / bool / None — structural constants kept verbatim
|
||||
|
||||
|
||||
def _unredacted(node):
|
||||
"""Return the first string leaf that is NOT a ``<str:N>`` placeholder, or None.
|
||||
|
||||
The completeness check behind the fail-closed perimeter: every string in a
|
||||
redacted structure must be a ``<str:N>`` token. Dict keys are walked as well
|
||||
as values (both come off the wire); int/float/bool/None are structural
|
||||
constants (gRPC status codes, list nesting) and carry no string content, so
|
||||
they never trip it.
|
||||
"""
|
||||
if isinstance(node, str):
|
||||
return None if _REDACTED_STR.fullmatch(node) else node
|
||||
if isinstance(node, list):
|
||||
children = node
|
||||
elif isinstance(node, dict):
|
||||
children = [*node.keys(), *node.values()]
|
||||
else:
|
||||
return None
|
||||
for child in children:
|
||||
bad = _unredacted(child)
|
||||
if bad is not None:
|
||||
return bad
|
||||
return None
|
||||
|
||||
|
||||
def _decode_maybe_json(s):
|
||||
if isinstance(s, str) and s and s[0] in "[{":
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
return s
|
||||
return s
|
||||
|
||||
|
||||
def _iter_json_values(text):
|
||||
"""Yield each top-level JSON value in a chunked batchexecute body."""
|
||||
dec = json.JSONDecoder()
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
while i < n and text[i] not in "[{":
|
||||
i += 1
|
||||
if i >= n:
|
||||
return
|
||||
try:
|
||||
val, end = dec.raw_decode(text, i)
|
||||
yield val
|
||||
i = end
|
||||
except json.JSONDecodeError:
|
||||
i += 1
|
||||
|
||||
|
||||
def _req_freq(entry):
|
||||
pd = entry.get("request", {}).get("postData", {})
|
||||
for p in pd.get("params", []) or []:
|
||||
if p.get("name") == "f.req":
|
||||
return unquote(p.get("value", ""))
|
||||
m = re.search(r"f\.req=([^&]+)", pd.get("text", "") or "")
|
||||
return unquote(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _iter_request_calls(freq):
|
||||
try:
|
||||
outer = json.loads(freq)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
level = outer[0] if isinstance(outer, list) and outer else None
|
||||
calls = level if isinstance(level, list) and level and isinstance(level[0], list) else [level]
|
||||
for call in calls or []:
|
||||
if isinstance(call, list) and len(call) >= 2 and isinstance(call[0], str):
|
||||
yield call[0], _decode_maybe_json(call[1])
|
||||
|
||||
|
||||
def _response_frames(entry):
|
||||
"""Yield (rpcid, result, error_code) from the chunked response body."""
|
||||
body = entry.get("response", {}).get("content", {}).get("text")
|
||||
if not isinstance(body, str):
|
||||
return
|
||||
for chunk in _iter_json_values(body):
|
||||
if not isinstance(chunk, list):
|
||||
continue
|
||||
for frame in chunk:
|
||||
if not (isinstance(frame, list) and frame and isinstance(frame[0], str)):
|
||||
continue
|
||||
if frame[0] == "wrb.fr" and len(frame) > 1 and isinstance(frame[1], str):
|
||||
result = _decode_maybe_json(frame[2]) if len(frame) > 2 else None
|
||||
err = frame[5] if len(frame) > 5 and frame[5] else None
|
||||
yield frame[1], result, err
|
||||
elif frame[0] == "er" and len(frame) > 1 and isinstance(frame[1], str):
|
||||
yield frame[1], None, frame[2:] or "error"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("har", help="path to a DevTools HAR export")
|
||||
ap.add_argument("--rpcid", help="only show this rpcid (e.g. CCqFvf)")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.har, encoding="utf-8", errors="replace") as fh:
|
||||
har = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
return _die(f"Could not read HAR: {e}")
|
||||
|
||||
entries = [
|
||||
e
|
||||
for e in har.get("log", {}).get("entries", [])
|
||||
if "/batchexecute" in e.get("request", {}).get("url", "")
|
||||
]
|
||||
if not entries:
|
||||
return _die("No /batchexecute requests found in the HAR.")
|
||||
|
||||
blocks: list[str] = []
|
||||
for entry in entries:
|
||||
status = entry.get("response", {}).get("status", "?")
|
||||
# response frames indexed by rpcid. A streamed response can emit a null
|
||||
# placeholder frame before the populated one for the same rpcid, so let
|
||||
# a later informative frame win — but never let a null clobber a hit.
|
||||
resp = {}
|
||||
for rpcid, result, err in _response_frames(entry):
|
||||
if rpcid not in resp or result is not None or err is not None:
|
||||
resp[rpcid] = (result, err)
|
||||
for rpcid, params in _iter_request_calls(_req_freq(entry) or ""):
|
||||
if args.rpcid and rpcid != args.rpcid:
|
||||
continue
|
||||
if not _SAFE_RPCID.fullmatch(rpcid):
|
||||
continue # not a real rpcid — skip rather than print unknown text
|
||||
name = _NAMES.get(rpcid, "")
|
||||
head = f"{rpcid}" + (f" ({name})" if name else "")
|
||||
req_red = _redact(params)
|
||||
redacted = [req_red]
|
||||
lines = [head, f" request : {_dump(req_red)}"]
|
||||
if rpcid in resp:
|
||||
result, err = resp[rpcid]
|
||||
result_red, err_red = _redact(result), _redact(err)
|
||||
redacted += [result_red, err_red]
|
||||
bits = [f"HTTP {status}"]
|
||||
if err is not None:
|
||||
bits.append(f"status_code={_dump(err_red)}")
|
||||
bits.append(f"result={_dump(result_red)}")
|
||||
lines.append(" response: " + " | ".join(bits))
|
||||
else:
|
||||
lines.append(f" response: HTTP {status} (body not in HAR — export 'with content')")
|
||||
if any(_unredacted(node) is not None for node in redacted):
|
||||
# impossible by construction — fail closed if it ever happens
|
||||
return _die("Refusing to print: a value survived redaction. Please report this.")
|
||||
blocks.append("\n".join(lines))
|
||||
|
||||
if not blocks:
|
||||
return _die(
|
||||
"No matching RPC calls found" + (f" for rpcid {args.rpcid!r}." if args.rpcid else ".")
|
||||
)
|
||||
|
||||
out = "\n\n".join(blocks)
|
||||
|
||||
print(
|
||||
"NotebookLM RPC capture — string values → <str:N>; cookies / headers / "
|
||||
"at= / Set-Cookie never read:\n"
|
||||
)
|
||||
print(out)
|
||||
print(
|
||||
f"\n{len(blocks)} call(s). Safe to share — no cookies / CSRF / session "
|
||||
"tokens are present (they live in headers, which this tool never reads)."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _dump(node) -> str:
|
||||
return json.dumps(node, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _die(msg: str) -> int:
|
||||
print(msg, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the test-suite taxonomy inventory and validate taxonomy policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TESTS_DIR = REPO_ROOT / "tests"
|
||||
DEFAULT_REPORT_PATH = REPO_ROOT / ".sisyphus" / "test-suite-taxonomy-inventory.md"
|
||||
ALLOW_NO_VCR_FILES_PATH = REPO_ROOT / "tests/_fixtures/integration_allow_no_vcr_files.txt"
|
||||
ALLOW_NO_VCR_NODEIDS_PATH = REPO_ROOT / "tests/_fixtures/integration_allow_no_vcr_nodeids.txt"
|
||||
VCR_ALLOW_NO_VCR_NODEIDS_PATH = (
|
||||
REPO_ROOT / "tests/_fixtures/integration_vcr_allow_no_vcr_nodeids.txt"
|
||||
)
|
||||
|
||||
ALLOW_NO_VCR_RATIONALE = (
|
||||
"existing mock-only integration exception; migrate per test-suite taxonomy cleanup"
|
||||
)
|
||||
VCR_ALLOW_NO_VCR_RATIONALE = (
|
||||
"existing vcr/allow_no_vcr overlap; normalize per test-suite taxonomy cleanup"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ItemRecord:
|
||||
nodeid: str
|
||||
path: str
|
||||
markers: frozenset[str]
|
||||
has_use_cassette: bool = False
|
||||
|
||||
|
||||
class CollectionRecorder:
|
||||
def __init__(self) -> None:
|
||||
self.items: list[object] = []
|
||||
|
||||
def pytest_collection_modifyitems(self, config, items) -> None:
|
||||
self.items = list(items)
|
||||
|
||||
|
||||
def _repo_relative(path: Path) -> str:
|
||||
return path.resolve().relative_to(REPO_ROOT).as_posix()
|
||||
|
||||
|
||||
def has_use_cassette_decorator(item: object) -> bool:
|
||||
"""Detect ``@notebooklm_vcr.use_cassette(...)`` on a collected item."""
|
||||
func = getattr(item, "function", None)
|
||||
seen: set[int] = set()
|
||||
while func is not None and id(func) not in seen:
|
||||
seen.add(id(func))
|
||||
wrapper = getattr(func, "_self_wrapper", None)
|
||||
if wrapper is not None:
|
||||
owner = getattr(wrapper, "__self__", None)
|
||||
if owner is not None and type(owner).__name__ == "CassetteContextDecorator":
|
||||
return True
|
||||
func = getattr(func, "__wrapped__", None)
|
||||
return False
|
||||
|
||||
|
||||
def collect_items() -> list[ItemRecord]:
|
||||
recorder = CollectionRecorder()
|
||||
args = ["--collect-only", "-q", "-o", "addopts=", str(TESTS_DIR)]
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
exit_code = pytest.main(args, plugins=[recorder])
|
||||
if exit_code not in (0, pytest.ExitCode.NO_TESTS_COLLECTED):
|
||||
raise RuntimeError(
|
||||
"pytest collection failed while generating taxonomy inventory.\n"
|
||||
f"exit_code={exit_code}\n"
|
||||
f"stdout_tail={stdout.getvalue()[-1000:]}\n"
|
||||
f"stderr_tail={stderr.getvalue()[-1000:]}"
|
||||
)
|
||||
|
||||
records: list[ItemRecord] = []
|
||||
for item in recorder.items:
|
||||
path = Path(item.path)
|
||||
markers = frozenset(marker.name for marker in item.iter_markers())
|
||||
records.append(
|
||||
ItemRecord(
|
||||
nodeid=item.nodeid,
|
||||
path=_repo_relative(path),
|
||||
markers=markers,
|
||||
has_use_cassette=has_use_cassette_decorator(item),
|
||||
)
|
||||
)
|
||||
return sorted(records, key=lambda record: record.nodeid)
|
||||
|
||||
|
||||
def logical_module_key(path: str) -> str:
|
||||
stem = Path(path).stem
|
||||
if stem.startswith("test_"):
|
||||
stem = stem.removeprefix("test_")
|
||||
for suffix in ("_integration", "_mock", "_vcr", "_characterization"):
|
||||
if stem.endswith(suffix):
|
||||
stem = stem[: -len(suffix)]
|
||||
return stem
|
||||
|
||||
|
||||
def normalized_identity(record: ItemRecord, move_map: dict[str, str] | None = None) -> str:
|
||||
if move_map and record.nodeid in move_map:
|
||||
return move_map[record.nodeid]
|
||||
_, separator, suffix = record.nodeid.partition("::")
|
||||
if not separator:
|
||||
suffix = "<module>"
|
||||
return f"{logical_module_key(record.path)}::{suffix}"
|
||||
|
||||
|
||||
def duplicate_normalized_identities(
|
||||
records: list[ItemRecord],
|
||||
move_map: dict[str, str] | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
seen: dict[str, list[str]] = defaultdict(list)
|
||||
for record in records:
|
||||
seen[normalized_identity(record, move_map)].append(record.nodeid)
|
||||
return {identity: nodeids for identity, nodeids in seen.items() if len(nodeids) > 1}
|
||||
|
||||
|
||||
def _count(records: list[ItemRecord], marker: str) -> int:
|
||||
return sum(1 for record in records if marker in record.markers)
|
||||
|
||||
|
||||
def _records_under(records: list[ItemRecord], prefix: str) -> list[ItemRecord]:
|
||||
return [record for record in records if record.path.startswith(prefix)]
|
||||
|
||||
|
||||
def _sorted_paths(paths: set[str]) -> list[str]:
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def _markdown_list(values: list[str], *, empty: str = "None.") -> str:
|
||||
if not values:
|
||||
return empty
|
||||
return "\n".join(f"- `{value}`" for value in values)
|
||||
|
||||
|
||||
def _allowlist(entries: list[str], rationale: str) -> str:
|
||||
if not entries:
|
||||
return "# No entries.\n"
|
||||
return "\n".join(f"{entry} # {rationale}" for entry in sorted(entries)) + "\n"
|
||||
|
||||
|
||||
def allowlist_entries_from_text(content: str) -> list[str]:
|
||||
"""Parse an allowlist file, ignoring blank lines and trailing rationales."""
|
||||
entries: list[str] = []
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
entry, _, _comment = stripped.partition(" #")
|
||||
entry = entry.strip()
|
||||
if entry:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def read_allowlist_entries(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return allowlist_entries_from_text(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def taxonomy_policy_violations(
|
||||
records: list[ItemRecord],
|
||||
*,
|
||||
allow_no_vcr_files: list[str],
|
||||
) -> list[str]:
|
||||
"""Return stable taxonomy policy violations for CI.
|
||||
|
||||
This deliberately avoids snapshotting global collection counts, marker
|
||||
counts, or exact test node IDs. Those are useful in the generated report,
|
||||
but too noisy for a CI contract: adding or renaming unrelated tests should
|
||||
not require refreshing taxonomy inventory files.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
e2e = _records_under(records, "tests/e2e/")
|
||||
integration = _records_under(records, "tests/integration/")
|
||||
|
||||
# tests/e2e/conftest.py auto-adds the marker during normal collection.
|
||||
# Keep this as defense-in-depth for hook regressions and direct helper callers.
|
||||
e2e_without_marker = [record.nodeid for record in e2e if "e2e" not in record.markers]
|
||||
if e2e_without_marker:
|
||||
violations.append(
|
||||
"tests/e2e/ items must carry @pytest.mark.e2e:\n "
|
||||
+ "\n ".join(sorted(e2e_without_marker))
|
||||
)
|
||||
|
||||
cassette_without_vcr = [
|
||||
record.nodeid
|
||||
for record in integration
|
||||
if record.has_use_cassette and "vcr" not in record.markers
|
||||
]
|
||||
if cassette_without_vcr:
|
||||
violations.append(
|
||||
"tests/integration/ items using use_cassette must also carry "
|
||||
"@pytest.mark.vcr:\n " + "\n ".join(sorted(cassette_without_vcr))
|
||||
)
|
||||
|
||||
allowlisted_paths = set(allow_no_vcr_files)
|
||||
current_allow_no_vcr_paths = {
|
||||
record.path for record in integration if "allow_no_vcr" in record.markers
|
||||
}
|
||||
unapproved_paths = sorted(current_allow_no_vcr_paths - allowlisted_paths)
|
||||
if unapproved_paths:
|
||||
violations.append(
|
||||
"new tests/integration/ allow_no_vcr file(s) need a deliberate "
|
||||
"file-level allowlist entry:\n " + "\n ".join(unapproved_paths)
|
||||
)
|
||||
|
||||
stale_paths = sorted(allowlisted_paths - current_allow_no_vcr_paths)
|
||||
if stale_paths:
|
||||
violations.append(
|
||||
"integration_allow_no_vcr_files.txt contains stale file(s); remove "
|
||||
"entries once their allow_no_vcr tests are migrated:\n " + "\n ".join(stale_paths)
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def render_inventory(records: list[ItemRecord]) -> str:
|
||||
e2e = _records_under(records, "tests/e2e/")
|
||||
integration = _records_under(records, "tests/integration/")
|
||||
characterization_marker_files = _sorted_paths(
|
||||
{record.path for record in records if "characterization" in record.markers}
|
||||
)
|
||||
characterization_named_files = _sorted_paths(
|
||||
{_repo_relative(path) for path in TESTS_DIR.rglob("*characterization*.py")}
|
||||
)
|
||||
|
||||
integration_vcr = [record for record in integration if "vcr" in record.markers]
|
||||
integration_allow_no_vcr = [
|
||||
record for record in integration if "allow_no_vcr" in record.markers
|
||||
]
|
||||
integration_overlap = [
|
||||
record for record in integration if {"vcr", "allow_no_vcr"} <= record.markers
|
||||
]
|
||||
cassette_only = [
|
||||
record for record in integration if record.has_use_cassette and "vcr" not in record.markers
|
||||
]
|
||||
|
||||
marker_counts = Counter(marker for record in records for marker in record.markers)
|
||||
|
||||
lines = [
|
||||
"# Test Suite Taxonomy Inventory",
|
||||
"",
|
||||
"Generated by `scripts/test_taxonomy_inventory.py --write-report`.",
|
||||
"",
|
||||
"`scripts/test_taxonomy_inventory.py --check` validates the stable taxonomy "
|
||||
"policy only. This report and the exact node ID lists are audit aids; they "
|
||||
"are not a CI snapshot contract.",
|
||||
"",
|
||||
"## Collection Totals",
|
||||
"",
|
||||
f"- All collected tests with `-o addopts=''`: {len(records)}",
|
||||
f"- E2E total: {len(e2e)}",
|
||||
f"- E2E marked `e2e`: {_count(e2e, 'e2e')}",
|
||||
f"- E2E not marked `e2e`: {len(e2e) - _count(e2e, 'e2e')}",
|
||||
f"- E2E marked `readonly`: {_count(e2e, 'readonly')}",
|
||||
(
|
||||
"- E2E marked both `e2e` and `readonly`: "
|
||||
f"{sum(1 for record in e2e if {'e2e', 'readonly'} <= record.markers)}"
|
||||
),
|
||||
f"- Integration total: {len(integration)}",
|
||||
f"- Integration `vcr and not allow_no_vcr`: {len(integration_vcr) - len(integration_overlap)}",
|
||||
(
|
||||
"- Integration `allow_no_vcr and not vcr`: "
|
||||
f"{len(integration_allow_no_vcr) - len(integration_overlap)}"
|
||||
),
|
||||
f"- Integration `vcr and allow_no_vcr`: {len(integration_overlap)}",
|
||||
f"- Integration bare `use_cassette` without `vcr`: {len(cassette_only)}",
|
||||
"",
|
||||
"## Marker Counts",
|
||||
"",
|
||||
]
|
||||
lines.extend(f"- `{name}`: {count}" for name, count in sorted(marker_counts.items()))
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Characterization Files",
|
||||
"",
|
||||
"### Files With `characterization` Marker",
|
||||
"",
|
||||
_markdown_list(characterization_marker_files),
|
||||
"",
|
||||
"### Files Named `*characterization*`",
|
||||
"",
|
||||
_markdown_list(characterization_named_files),
|
||||
"",
|
||||
"## Integration Allowlist Baselines",
|
||||
"",
|
||||
"### `allow_no_vcr` Files",
|
||||
"",
|
||||
_markdown_list(_sorted_paths({record.path for record in integration_allow_no_vcr})),
|
||||
"",
|
||||
"### `allow_no_vcr` Node IDs",
|
||||
"",
|
||||
(
|
||||
f"{len(integration_allow_no_vcr)} node IDs. See "
|
||||
"`tests/_fixtures/integration_allow_no_vcr_nodeids.txt` for the exact baseline."
|
||||
),
|
||||
"",
|
||||
"### `vcr and allow_no_vcr` Node IDs",
|
||||
"",
|
||||
(
|
||||
f"{len(integration_overlap)} node IDs. See "
|
||||
"`tests/_fixtures/integration_vcr_allow_no_vcr_nodeids.txt` for the exact baseline."
|
||||
),
|
||||
"",
|
||||
"### Bare `use_cassette` Without `vcr`",
|
||||
"",
|
||||
f"{len(cassette_only)} node IDs. Any nonzero count is a missing-marker bug.",
|
||||
"",
|
||||
"## Allowlist File Schema",
|
||||
"",
|
||||
"- UTF-8 text.",
|
||||
"- Blank lines and `#` comments are ignored.",
|
||||
"- Non-comment entries are sorted.",
|
||||
"- Each entry is followed by ` # ` and a one-line rationale.",
|
||||
"- `integration_allow_no_vcr_files.txt` is the CI-enforced baseline.",
|
||||
"- Node ID allowlists contain full collected node IDs for manual audit only.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def rendered_baseline_outputs(records: list[ItemRecord]) -> dict[Path, str]:
|
||||
integration = _records_under(records, "tests/integration/")
|
||||
allow_no_vcr = [record for record in integration if "allow_no_vcr" in record.markers]
|
||||
overlap = [record for record in allow_no_vcr if "vcr" in record.markers]
|
||||
return {
|
||||
ALLOW_NO_VCR_FILES_PATH: _allowlist(
|
||||
_sorted_paths({record.path for record in allow_no_vcr}),
|
||||
ALLOW_NO_VCR_RATIONALE,
|
||||
),
|
||||
ALLOW_NO_VCR_NODEIDS_PATH: _allowlist(
|
||||
[record.nodeid for record in allow_no_vcr],
|
||||
ALLOW_NO_VCR_RATIONALE,
|
||||
),
|
||||
VCR_ALLOW_NO_VCR_NODEIDS_PATH: _allowlist(
|
||||
[record.nodeid for record in overlap],
|
||||
VCR_ALLOW_NO_VCR_RATIONALE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def write_outputs(outputs: dict[Path, str]) -> None:
|
||||
for path, content in outputs.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(content)
|
||||
|
||||
|
||||
def check_outputs(outputs: dict[Path, str]) -> int:
|
||||
stale: list[str] = []
|
||||
for path, content in outputs.items():
|
||||
existing = path.read_text(encoding="utf-8") if path.exists() else None
|
||||
if existing != content:
|
||||
stale.append(_repo_relative(path))
|
||||
if stale:
|
||||
print("taxonomy inventory outputs are stale:", file=sys.stderr)
|
||||
for path in stale:
|
||||
print(f" {path}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def check_taxonomy_policy(records: list[ItemRecord]) -> int:
|
||||
if not ALLOW_NO_VCR_FILES_PATH.exists():
|
||||
print(
|
||||
"taxonomy policy violations:\n\n"
|
||||
f"allowlist file not found: {_repo_relative(ALLOW_NO_VCR_FILES_PATH)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
violations = taxonomy_policy_violations(
|
||||
records,
|
||||
allow_no_vcr_files=read_allowlist_entries(ALLOW_NO_VCR_FILES_PATH),
|
||||
)
|
||||
if not violations:
|
||||
print("OK: taxonomy policy is current")
|
||||
return 0
|
||||
|
||||
print("taxonomy policy violations:", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f"\n{violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
action_group = parser.add_mutually_exclusive_group()
|
||||
action_group.add_argument(
|
||||
"--check", action="store_true", help="validate stable taxonomy policy"
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--check-generated",
|
||||
action="store_true",
|
||||
help="fail if generated allowlist baseline outputs differ",
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--write-report",
|
||||
action="store_true",
|
||||
help=(
|
||||
"write the markdown audit report and baseline allowlists "
|
||||
f"(default: {_repo_relative(DEFAULT_REPORT_PATH)})"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report-path",
|
||||
type=Path,
|
||||
default=DEFAULT_REPORT_PATH,
|
||||
help="path for --write-report output",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.report_path != DEFAULT_REPORT_PATH and not args.write_report:
|
||||
parser.error("--report-path requires --write-report")
|
||||
|
||||
records = collect_items()
|
||||
if args.check:
|
||||
return check_taxonomy_policy(records)
|
||||
outputs = rendered_baseline_outputs(records)
|
||||
if args.check_generated:
|
||||
return check_outputs(outputs)
|
||||
if args.write_report:
|
||||
report_path = args.report_path
|
||||
if not report_path.is_absolute():
|
||||
report_path = REPO_ROOT / report_path
|
||||
outputs[report_path] = render_inventory(records)
|
||||
write_outputs(outputs)
|
||||
print(f"wrote {len(outputs)} taxonomy inventory output(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user