Files
teng-lin--notebooklm-py/scripts/check_deprecation_targets.py
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Waiting to run
Test / Test (macos-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.14) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.14) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.14) (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

273 lines
10 KiB
Python

"""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())