teng-lin--notebooklm-py
09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
147 行
6.3 KiB
Python
147 行
6.3 KiB
Python
"""Final guard that the deleted concrete session surface stays deleted."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SRC_ROOT = REPO_ROOT / "src" / "notebooklm"
|
|
TEST_ROOT = REPO_ROOT / "tests"
|
|
SESSION_ATTR = "_" + "session"
|
|
DELETED_MODULE = "notebooklm" + "." + SESSION_ATTR
|
|
ALLOWED_MODULE_PREFIXES = (
|
|
"notebooklm" + "." + SESSION_ATTR + "_helpers",
|
|
"notebooklm" + "." + SESSION_ATTR + "_init",
|
|
)
|
|
OLD_HELPERS = {
|
|
"build" + "_" + "session" + "_for_tests",
|
|
"build_client" + "_for_tests",
|
|
}
|
|
|
|
|
|
def _python_files() -> list[Path]:
|
|
return sorted(
|
|
p
|
|
for root in (SRC_ROOT, TEST_ROOT)
|
|
for p in root.rglob("*.py")
|
|
if "__pycache__" not in p.parts
|
|
)
|
|
|
|
|
|
def _is_deleted_module_string(value: str) -> bool:
|
|
if value in ALLOWED_MODULE_PREFIXES or any(
|
|
value.startswith(prefix + ".") for prefix in ALLOWED_MODULE_PREFIXES
|
|
):
|
|
return False
|
|
return value == DELETED_MODULE or value.startswith(DELETED_MODULE + ".")
|
|
|
|
|
|
def _dynamic_attr_name(call: ast.Call) -> str | None:
|
|
if isinstance(call.func, ast.Name) and call.func.id in {"getattr", "setattr", "delattr"}:
|
|
if len(call.args) >= 2 and isinstance(call.args[1], ast.Constant):
|
|
return call.args[1].value if isinstance(call.args[1].value, str) else None
|
|
if isinstance(call.func, ast.Name) and call.func.id == "attrgetter":
|
|
if call.args and isinstance(call.args[0], ast.Constant):
|
|
return call.args[0].value if isinstance(call.args[0].value, str) else None
|
|
if isinstance(call.func, ast.Attribute) and call.func.attr == "attrgetter":
|
|
if call.args and isinstance(call.args[0], ast.Constant):
|
|
return call.args[0].value if isinstance(call.args[0].value, str) else None
|
|
return None
|
|
|
|
|
|
def _subscript_string_key(node: ast.Subscript) -> str | None:
|
|
key = node.slice
|
|
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
|
return key.value
|
|
return None
|
|
|
|
|
|
def scan_source(source: str, *, rel: str = "<memory>") -> list[str]:
|
|
tree = ast.parse(source)
|
|
violations: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef) and node.name == "Session":
|
|
violations.append(f"{rel}:{node.lineno}: class Session")
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.module in {SESSION_ATTR, DELETED_MODULE}:
|
|
violations.append(f"{rel}:{node.lineno}: imports deleted module")
|
|
if node.module == "notebooklm":
|
|
for alias in node.names:
|
|
if alias.name in {SESSION_ATTR, DELETED_MODULE}:
|
|
violations.append(f"{rel}:{node.lineno}: imports deleted module")
|
|
for alias in node.names:
|
|
if alias.name in OLD_HELPERS:
|
|
violations.append(f"{rel}:{node.lineno}: imports old helper {alias.name}")
|
|
if node.module in {SESSION_ATTR, DELETED_MODULE} and alias.name == "Session":
|
|
violations.append(f"{rel}:{node.lineno}: imports Session")
|
|
elif isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name == DELETED_MODULE:
|
|
violations.append(f"{rel}:{node.lineno}: imports deleted module")
|
|
elif isinstance(node, ast.Attribute):
|
|
if node.attr == SESSION_ATTR:
|
|
violations.append(f"{rel}:{node.lineno}: deleted session attribute")
|
|
if node.attr == "collaborators":
|
|
parent = getattr(node, "value", None)
|
|
if isinstance(parent, ast.Name) and parent.id == "composed":
|
|
violations.append(f"{rel}:{node.lineno}: composed" + ".collaborators")
|
|
elif isinstance(node, ast.Call):
|
|
attr = _dynamic_attr_name(node)
|
|
if attr == SESSION_ATTR:
|
|
violations.append(f"{rel}:{node.lineno}: dynamic deleted session attr")
|
|
if isinstance(node.func, ast.Name) and node.func.id in OLD_HELPERS:
|
|
violations.append(f"{rel}:{node.lineno}: calls old helper {node.func.id}")
|
|
elif isinstance(node, ast.Subscript):
|
|
key = _subscript_string_key(node)
|
|
if key == SESSION_ATTR:
|
|
violations.append(f"{rel}:{node.lineno}: dynamic deleted session attr")
|
|
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
if _is_deleted_module_string(node.value):
|
|
violations.append(f"{rel}:{node.lineno}: string targets deleted module")
|
|
old_helper_module = "_helpers" + "." + "session_factory"
|
|
if node.value in OLD_HELPERS or old_helper_module in node.value:
|
|
violations.append(f"{rel}:{node.lineno}: string mentions old helper")
|
|
return violations
|
|
|
|
|
|
def test_no_deleted_session_surface_in_source_or_tests() -> None:
|
|
violations: list[str] = []
|
|
for path in _python_files():
|
|
rel = path.relative_to(REPO_ROOT).as_posix()
|
|
violations.extend(scan_source(path.read_text(encoding="utf-8"), rel=rel))
|
|
assert not violations, "Deleted session surface remains:\n " + "\n ".join(violations)
|
|
|
|
|
|
def test_scan_source_catches_static_and_dynamic_deleted_surface() -> None:
|
|
source = "\n".join(
|
|
[
|
|
"class Session: pass",
|
|
f"from notebooklm.{SESSION_ATTR} import Session",
|
|
f"from notebooklm import {SESSION_ATTR}",
|
|
f"import notebooklm.{SESSION_ATTR}",
|
|
f"client.{SESSION_ATTR}.close()",
|
|
f"getattr(client, {SESSION_ATTR!r})",
|
|
f"setattr(client, {SESSION_ATTR!r}, None)",
|
|
f"delattr(client, {SESSION_ATTR!r})",
|
|
f"vars(client)[{SESSION_ATTR!r}]",
|
|
f"client.__dict__[{SESSION_ATTR!r}]",
|
|
f"attrgetter({SESSION_ATTR!r})",
|
|
f"target = {DELETED_MODULE!r}",
|
|
("build_client" + "_for_tests(auth)"),
|
|
"composed" + ".collaborators",
|
|
]
|
|
)
|
|
violations = scan_source(source)
|
|
assert len(violations) >= 14
|
|
|
|
|
|
def test_scan_source_allows_surviving_sibling_modules() -> None:
|
|
source = "\n".join(
|
|
[
|
|
repr("notebooklm" + "." + SESSION_ATTR + "_helpers.asyncio.sleep"),
|
|
repr("notebooklm" + "." + SESSION_ATTR + "_init.httpx.AsyncClient"),
|
|
]
|
|
)
|
|
assert scan_source(source) == []
|