Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

314 lines
12 KiB
Python

"""Guardrail tests for installation documentation invariants.
These tests catch silent drift between `pyproject.toml`, `docs/installation.md`,
and the agent-context files (`CLAUDE.md`, `AGENTS.md`, `SKILL.md`).
When any of these tests fail, the docs are out of sync with the package — fix
the doc, not the test (unless the test is genuinely wrong).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import pytest
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover -- only hit on Python 3.10
import tomli as tomllib # transitive via uv.lock; declared in [dev] for safety
pytestmark = pytest.mark.repo_lint
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALLATION_MD = REPO_ROOT / "docs" / "installation.md"
PYPROJECT_TOML = REPO_ROOT / "pyproject.toml"
CLAUDE_MD = REPO_ROOT / "CLAUDE.md"
AGENTS_MD = REPO_ROOT / "AGENTS.md"
SKILL_MD = REPO_ROOT / "SKILL.md"
TROUBLESHOOTING_MD = REPO_ROOT / "docs" / "troubleshooting.md"
CHANGELOG_MD = REPO_ROOT / "CHANGELOG.md"
CANONICAL_CONTRIBUTOR_INSTALL = "uv sync --frozen --extra browser --extra dev --extra markdown"
SKILL_BROWSER_LINE_RE = re.compile(r'pip install "notebooklm-py\[browser\]"(?![\w,])')
INSTALLATION_LINK_RE = re.compile(r"\bdocs/installation\.md\b")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _pyproject_extras() -> set[str]:
data = tomllib.loads(_read(PYPROJECT_TOML))
return set(data["project"]["optional-dependencies"].keys())
# ---------------------------------------------------------------------------
# §8.4 #1 — extras matrix in installation.md mirrors pyproject.toml
# ---------------------------------------------------------------------------
def test_installation_md_extras_matrix_mirrors_pyproject() -> None:
"""The extras matrix in installation.md must list exactly the keys defined
in pyproject.toml's [project.optional-dependencies].
Catches: "added a new extra, forgot to document" and "removed an extra
but matrix still references it".
"""
pyproject_extras = _pyproject_extras()
installation_text = _read(INSTALLATION_MD)
# Collect backticked extra names from the matrix `Extra` column.
# Matrix rows look like: `| `browser` | ... |`. Exclude the `(none)` row.
matrix_extras: set[str] = set()
for line in installation_text.splitlines():
if not line.startswith("| `") or " | " not in line:
continue
match = re.match(r"\|\s*`([a-z]+)`\s*\|", line)
if match:
matrix_extras.add(match.group(1))
assert matrix_extras == pyproject_extras, (
f"installation.md extras matrix is out of sync with pyproject.toml.\n"
f" pyproject.toml: {sorted(pyproject_extras)}\n"
f" installation.md: {sorted(matrix_extras)}\n"
f" missing from doc: {sorted(pyproject_extras - matrix_extras)}\n"
f" extra in doc: {sorted(matrix_extras - pyproject_extras)}"
)
# ---------------------------------------------------------------------------
# §8.4 #2 — wrong package name `notebooklm[<extra>]` (without -py) must not
# appear anywhere outside CHANGELOG (which records the bug-fix history).
# ---------------------------------------------------------------------------
def test_no_wrong_package_name_anywhere() -> None:
"""`notebooklm[browser|cookies|markdown]` (missing the `-py` suffix) is an
invalid PyPI package name. It must never appear in user-facing files.
"""
bad_pattern = re.compile(r"notebooklm\[(browser|cookies|markdown)\]")
scan_dirs = [
REPO_ROOT / "docs",
REPO_ROOT / "src",
]
scan_files = [
REPO_ROOT / "README.md",
REPO_ROOT / "CONTRIBUTING.md",
CLAUDE_MD,
AGENTS_MD,
SKILL_MD,
]
hits: list[str] = []
for path in scan_files:
if path.is_file():
for lineno, line in enumerate(_read(path).splitlines(), start=1):
if bad_pattern.search(line):
hits.append(f"{path.relative_to(REPO_ROOT)}:{lineno}: {line.strip()}")
for root in scan_dirs:
if not root.is_dir():
continue
for path in root.rglob("*"):
if not path.is_file():
continue
if path.suffix not in {".md", ".py", ".yml", ".yaml"}:
continue
try:
text = _read(path)
except (OSError, UnicodeDecodeError):
continue
for lineno, line in enumerate(text.splitlines(), start=1):
if bad_pattern.search(line):
hits.append(f"{path.relative_to(REPO_ROOT)}:{lineno}: {line.strip()}")
assert not hits, (
"Found `notebooklm[<extra>]` (missing `-py`) — should be `notebooklm-py[<extra>]`:\n"
+ "\n".join(hits)
)
# ---------------------------------------------------------------------------
# §8.4 #3 — per-file install-block assertions (catches summarize-away edits)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", [CLAUDE_MD, AGENTS_MD], ids=["CLAUDE.md", "AGENTS.md"])
def test_contributor_context_files_contain_canonical_uv_sync(path: Path) -> None:
"""CLAUDE.md and AGENTS.md are agent-context files for contributors working
on this repo. They must contain the canonical `uv sync --frozen` block
(agents replay context; trimming it forces them to reinvent — incorrectly)
and a link to docs/installation.md.
"""
text = _read(path)
assert CANONICAL_CONTRIBUTOR_INSTALL in text, (
f"{path.name} must contain the literal contributor install command "
f"`{CANONICAL_CONTRIBUTOR_INSTALL}` — agents replay this verbatim."
)
assert INSTALLATION_LINK_RE.search(text), f"{path.name} must link to docs/installation.md."
def test_skill_md_contains_agent_install_pattern() -> None:
"""SKILL.md is the AGENT-facing entry point (Persona A), NOT contributor.
It must contain the agent install pattern (`pip install "notebooklm-py[browser]"`),
a separate line for the optional `[cookies]` install, and a link to
docs/installation.md.
The strict `[browser]"` regex (no extras inside the brackets) forbids a
regression to the single-step `[browser,cookies]` form which would break
on Python 3.13+.
"""
text = _read(SKILL_MD)
assert SKILL_BROWSER_LINE_RE.search(text), (
'SKILL.md must contain `pip install "notebooklm-py[browser]"` '
"(exact `[browser]` extra, no others bracketed in)."
)
assert 'pip install "notebooklm-py[cookies]"' in text, (
'SKILL.md must contain a separate `pip install "notebooklm-py[cookies]"` line '
"(optional install, may fail on Python 3.13+)."
)
assert INSTALLATION_LINK_RE.search(text), "SKILL.md must link to docs/installation.md."
def test_skill_md_does_not_use_status_for_auth() -> None:
"""SKILL.md historically claimed `notebooklm status` shows
`Authenticated as: email@...` — that's false. `status` is context-only
(selected notebook); auth is verified with `notebooklm auth check`.
"""
text = _read(SKILL_MD)
assert "Authenticated as: email" not in text, (
"SKILL.md must not claim `notebooklm status` shows 'Authenticated as: email@...' "
"— `status` is context-only. Use `notebooklm auth check` for auth verification."
)
assert "auth check" in text, (
"SKILL.md Agent Setup Verification section must mention `notebooklm auth check`."
)
# ---------------------------------------------------------------------------
# §8.4 #4 — every (installation.md#anchor) cross-link resolves to a heading
# ---------------------------------------------------------------------------
def _markdown_heading_slug(heading: str) -> str:
"""GitHub markdown heading slug: lowercase, spaces → '-', strip non-word
chars except '-'. Approximates GitHub's algorithm well enough for our anchors.
"""
slug = heading.strip().lower()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"\s+", "-", slug)
return slug
def _collect_headings(path: Path) -> set[str]:
slugs: set[str] = set()
for line in _read(path).splitlines():
m = re.match(r"^(#+)\s+(.+?)\s*$", line)
if m:
slugs.add(_markdown_heading_slug(m.group(2)))
return slugs
def test_installation_md_internal_anchors_resolve() -> None:
"""Every `(installation.md#anchor)` cross-link from any other doc must point
to a heading that exists in installation.md.
"""
install_anchors = _collect_headings(INSTALLATION_MD)
cross_link_re = re.compile(r"\(([^)]*installation\.md)#([a-z0-9-]+)\)")
failures: list[str] = []
scan_files = list((REPO_ROOT / "docs").rglob("*.md"))
scan_files += [
REPO_ROOT / "README.md",
REPO_ROOT / "CONTRIBUTING.md",
CLAUDE_MD,
AGENTS_MD,
SKILL_MD,
]
for path in scan_files:
if not path.is_file():
continue
for lineno, line in enumerate(_read(path).splitlines(), start=1):
for match in cross_link_re.finditer(line):
anchor = match.group(2)
if anchor not in install_anchors:
failures.append(
f"{path.relative_to(REPO_ROOT)}:{lineno} → "
f"installation.md#{anchor} (not found)"
)
assert not failures, (
"Cross-links to installation.md anchors that don't exist:\n"
+ "\n".join(failures)
+ f"\n\nAvailable anchors: {sorted(install_anchors)}"
)
# ---------------------------------------------------------------------------
# §8.4 #5 — troubleshooting.md still has the four bare platform headings
# (cross-linked from installation.md as #linux/#macos/#windows/#wsl)
# ---------------------------------------------------------------------------
def test_troubleshooting_md_keeps_bare_platform_headings() -> None:
"""installation.md cross-links to `troubleshooting.md#linux` etc.
If someone renames the headings (e.g., `### Linux (Debian/Ubuntu)`), the
inbound anchors silently 404. Hold the line.
"""
text = _read(TROUBLESHOOTING_MD)
# Match the heading line *exactly* — `### Linux\n`, not `### Linux (...)`.
required = ["### Linux", "### macOS", "### Windows", "### WSL"]
missing = [h for h in required if not re.search(rf"^{re.escape(h)}\s*$", text, re.MULTILINE)]
assert not missing, (
f"docs/troubleshooting.md is missing bare platform headings: {missing}.\n"
"If you intentionally renamed them, update installation.md cross-links to match."
)
# ---------------------------------------------------------------------------
# Bonus: --all-extras must not appear outside installation.md (which contains
# the warning callout) and CHANGELOG (history).
# ---------------------------------------------------------------------------
def test_no_uv_sync_all_extras_in_canonical_install_paths() -> None:
"""`uv sync --all-extras` includes `cookies`, which fails on Python 3.13+.
The `[all]` extra deliberately excludes `cookies`. Only the warning callout
in installation.md is allowed to mention `--all-extras` as a flag.
Match the actual CLI flag (`--all-extras` with leading dashes) so we don't
false-positive on the `#all-vs-all-extras` anchor used in cross-links.
"""
bad_pattern = re.compile(r"--all-extras\b")
forbidden_locations: list[Path] = [
REPO_ROOT / "README.md",
REPO_ROOT / "CONTRIBUTING.md",
CLAUDE_MD,
AGENTS_MD,
SKILL_MD,
]
forbidden_locations += [
p for p in (REPO_ROOT / "docs").rglob("*.md") if p.name not in {"installation.md"}
]
hits: list[str] = []
for path in forbidden_locations:
if not path.is_file():
continue
for lineno, line in enumerate(_read(path).splitlines(), start=1):
if bad_pattern.search(line):
hits.append(f"{path.relative_to(REPO_ROOT)}:{lineno}: {line.strip()}")
assert not hits, (
"`--all-extras` found outside docs/installation.md (where the warning lives):\n"
+ "\n".join(hits)
)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))