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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:30:13 +08:00
commit 09e9f3545f
1231 changed files with 649127 additions and 0 deletions
+161
View File
@@ -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())
+409
View File
@@ -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))
+761
View File
@@ -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."
}
]
}
+991
View File
@@ -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())
+288
View File
@@ -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())
+666
View File
@@ -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())
+180
View File
@@ -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())
+231
View File
@@ -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())
+225
View File
@@ -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())
+223
View File
@@ -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())
+272
View File
@@ -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())
+336
View File
@@ -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
+166
View File
@@ -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())
+474
View File
@@ -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())
+230
View File
@@ -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()
+204
View File
@@ -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())
+72
View File
@@ -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())
+291
View File
@@ -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())
+249
View File
@@ -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())
+444
View File
@@ -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())