blob: 0025126d52f0be32a06d5f0c16178b3cdd149246 [file]
#!/usr/bin/env python
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "rich>=13.0.0",
# ]
# ///
"""Check that no new ``@provide_session`` functions declare ``session`` positionally.
The project convention is that any function decorated with ``@provide_session``
must declare ``session`` as keyword-only (after a bare ``*`` in the signature),
so callers cannot pass it positionally by accident. See
``contributing-docs/05_pull_requests.rst#database-session-handling``.
All *existing* offenders are recorded in ``known_provide_session_positional.txt``
next to this script as ``relative/path::N`` entries (one per file), where ``N``
is the maximum number of ``@provide_session`` functions with a positional
``session`` argument allowed in that file. A file whose current count exceeds
the recorded limit is treated as a violation – move the ``session`` argument
behind a bare ``*`` instead.
Modes
-----
Default (files passed by prek/pre-commit):
Check only the supplied files; fail if any file's count exceeds the limit.
When a file's count has *decreased*, the allowlist entry is tightened
automatically and the hook exits with a non-zero code so that pre-commit
reports the modified allowlist – just stage
``scripts/ci/prek/known_provide_session_positional.txt`` and re-run.
``--all-files``:
Walk every ``.py`` file under the project source roots
(``airflow-core``, ``providers``, ``shared``) —
the same scope the pre-commit hook applies to.
``--cleanup``:
Remove entries for files that no longer exist. Safe to run at any time;
does not add new entries or raise limits.
``--generate``:
Scan the same project source roots as ``--all-files`` and *rebuild* the
allowlist from scratch. Intended for the initial setup or after a
large-scale clean-up sprint.
"""
from __future__ import annotations
import argparse
import ast
import subprocess
import typing
from collections.abc import Iterable
from pathlib import Path
from common_prek_utils import AIRFLOW_ROOT_PATH, AllowlistManager
from rich.console import Console
from rich.panel import Panel
console = Console(color_system="standard", width=200)
REPO_ROOT = AIRFLOW_ROOT_PATH
_PROVIDE_SESSION_DECORATOR = "provide_session"
# Top-level directories scanned by ``--all-files`` / ``--generate``. Keep in sync with the
# ``files:`` pattern for this hook in ``.pre-commit-config.yaml``.
_PROJECT_SOURCE_ROOTS = ("airflow-core", "providers", "shared")
def _has_provide_session_decorator(nodes: list[ast.expr]) -> bool:
"""Whether one of ``nodes`` is a ``@provide_session`` decorator.
Accepts both bare names (``@provide_session``) and attribute access
(``@something.provide_session``).
"""
for node in nodes:
if isinstance(node, ast.Name) and node.id == _PROVIDE_SESSION_DECORATOR:
return True
if isinstance(node, ast.Attribute) and node.attr == _PROVIDE_SESSION_DECORATOR:
return True
return False
def _session_is_positional(args: ast.arguments) -> ast.arg | None:
"""Return the ``session`` arg if it is positional (not keyword-only).
Covers both regular positional args and positional-only args (``def f(session, /, ...)``).
"""
for argument in (*args.posonlyargs, *args.args):
if argument.arg == "session":
return argument
return None
def _iter_positional_session_in_provide_session(
path: Path,
) -> typing.Iterator[tuple[ast.FunctionDef | ast.AsyncFunctionDef, ast.arg]]:
"""Yield ``@provide_session`` functions in *path* whose ``session`` is positional."""
try:
source = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return
try:
tree = ast.parse(source, str(path))
except SyntaxError:
return
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if not _has_provide_session_decorator(node.decorator_list):
continue
argument = _session_is_positional(node.args)
if argument is None:
continue
yield node, argument
def _count_violations(path: Path) -> int:
return sum(1 for _ in _iter_positional_session_in_provide_session(path))
class ProvideSessionAllowlistManager(AllowlistManager):
def __init__(self, allowlist_file: Path) -> None:
super().__init__(allowlist_file, repo_root=REPO_ROOT)
def iter_files(self) -> Iterable[Path]:
return _iter_python_files()
def count_occurrences(self, path: Path) -> int:
return _count_violations(path)
def violation_panel_text(self) -> str:
return (
"New [bold]@provide_session[/bold] function with positional ``session`` detected.\n"
"Move ``session`` after a bare ``*`` in the signature so callers must pass it by keyword:\n\n"
" [cyan]@provide_session\n"
" def foo(arg, *, session: Session = NEW_SESSION) -> None: ...[/cyan]\n\n"
"If this usage is intentional and pre-existing, run:\n\n"
" [cyan]uv run ./scripts/ci/prek/check_provide_session_kwargs.py --generate[/cyan]\n\n"
"to regenerate the allowlist, then commit the updated\n"
"[cyan]scripts/ci/prek/known_provide_session_positional.txt[/cyan]."
)
def format_violation_details(self, path: Path) -> list[str]:
return [
f" [dim]L{argument.lineno}[/dim] def {func.name}(...)"
for func, argument in _iter_positional_session_in_provide_session(path)
]
def _iter_python_files() -> list[Path]:
candidates: list[Path] = []
for top in _PROJECT_SOURCE_ROOTS:
candidates.extend(
p.resolve()
for p in (REPO_ROOT / top).rglob("*.py")
if ".tox" not in p.parts and "__pycache__" not in p.parts
)
return candidates
def _check_provide_session_kwargs(
files: list[Path], allowlist: dict[str, int], manager: ProvideSessionAllowlistManager
) -> int:
allowlist_file = manager.allowlist_file.resolve()
if any(p.resolve() == allowlist_file for p in files) and not allowlist_file.exists():
console.print(
Panel.fit(
f"Allowlist file [cyan]{allowlist_file}[/cyan] is missing.\n"
"It was passed to the hook but cannot be read, so the check cannot proceed.\n"
"Restore it from git or regenerate it with:\n\n"
" [cyan]uv run ./scripts/ci/prek/check_provide_session_kwargs.py --generate[/cyan]",
title="[red]Check failed[/red]",
border_style="red",
)
)
return 1
return manager.check(files, allowlist)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Prevent new @provide_session functions from declaring `session` positionally.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("files", nargs="*", metavar="FILE", help="Files to check (provided by prek)")
parser.add_argument(
"--all-files",
action="store_true",
help=("Check every Python file under the project source roots (airflow-core, providers, shared)"),
)
parser.add_argument(
"--cleanup",
action="store_true",
help="Remove stale entries from the allowlist and exit",
)
parser.add_argument(
"--generate",
action="store_true",
help="Regenerate the allowlist from the current codebase and exit",
)
args = parser.parse_args(argv)
manager = ProvideSessionAllowlistManager(Path(__file__).parent / "known_provide_session_positional.txt")
if args.generate:
return manager.generate()
if args.cleanup:
return manager.cleanup()
allowlist = manager.load()
if args.all_files:
return _check_provide_session_kwargs(_iter_python_files(), allowlist, manager)
if not args.files:
console.print(
"[yellow]No files provided. Pass filenames or use --all-files to scan the whole repo.[/yellow]"
)
return 0
paths = [Path(f).resolve() for f in args.files]
paths = _expand_for_allowlist_edits(paths, manager, allowlist)
return _check_provide_session_kwargs(paths, allowlist, manager)
def _parse_tracked_allowlist(manager: ProvideSessionAllowlistManager) -> dict[str, int]:
"""Return the allowlist as recorded at ``HEAD`` (the git-tracked version).
Used by :func:`_expand_for_allowlist_edits` so that *removing* an entry
cannot silently drop coverage: the previously-listed file is still
re-validated against the new (post-edit) allowlist. Returns an empty mapping
when git is unavailable, the file does not yet exist at ``HEAD``, or the
allowlist sits outside ``REPO_ROOT``.
"""
try:
rel = manager.allowlist_file.resolve().relative_to(REPO_ROOT.resolve())
except ValueError:
return {}
try:
completed = subprocess.run(
["git", "-C", str(REPO_ROOT), "show", f"HEAD:{rel.as_posix()}"],
capture_output=True,
text=True,
check=False,
)
except (FileNotFoundError, OSError):
return {}
if completed.returncode != 0:
return {}
return manager.parse(completed.stdout)
def _expand_for_allowlist_edits(
paths: list[Path], manager: ProvideSessionAllowlistManager, allowlist: dict[str, int]
) -> list[Path]:
"""Add allowlisted files when the allowlist itself is being changed.
Without this, a contributor could raise counts in
``known_provide_session_positional.txt`` and the hook would do no validation
(since only the ``.txt`` file is passed), letting the loosened allowlist
sail through. We also union the git-tracked allowlist (from ``HEAD``) so
that removing an entry cannot silently bypass the check for a file that
still has positional ``session`` arguments.
Both sides of the allowlist-file comparison are resolved so the detection is
robust to symlinks and unresolved inputs (the hook can be invoked with either).
"""
allowlist_file = manager.allowlist_file.resolve()
if not any(p.resolve() == allowlist_file for p in paths):
return paths
expanded = list(paths)
seen = {p.resolve() for p in paths if p.suffix == ".py"}
tracked = _parse_tracked_allowlist(manager)
for rel in {*allowlist, *tracked}:
candidate = (REPO_ROOT / rel).resolve()
if candidate.exists() and candidate not in seen:
seen.add(candidate)
expanded.append(candidate)
return expanded
if __name__ == "__main__":
raise SystemExit(main())