This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 7e47a528bca Consolidate AllowlistManager for prek hooks (#69327)
7e47a528bca is described below
commit 7e47a528bca17f7ad5ba41c4a94e642727d33b9b
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Tue Jul 7 07:47:15 2026 +0900
Consolidate AllowlistManager for prek hooks (#69327)
* Extract common AllowListManager base class for prek hooks
The allowlist load/save/generate/cleanup logic was duplicated across
check_new_airflow_exception_usage.py and check_provide_session_kwargs.py.
This consolidates the shared pattern into an abstract AllowListManager
in common_prek_utils.py, with subclasses providing only iter_files()
and count_occurrences().
* Move check loop and parse into AllowlistManager base class
The initial extraction left the scan/tighten/report loop duplicated
in both hook scripts and exposed `parse` as a staticmethod that
silently ignored the instance's `repo_root`.
- Convert `parse` from `@staticmethod` to instance method so it
always uses `self.repo_root` (fixes the latent footgun where
`_parse_tracked_allowlist` called parse unbound).
- Hoist the check loop into `AllowlistManager.check()` with
`violation_panel_text()` (abstract) and `format_violation_details()`
(overridable) hooks — both `_check_*` wrappers are now one-liners.
- Fix naming: `AllowListManager` → `AllowlistManager` to match the
subclass convention used throughout.
- Add test coverage for `check_new_airflow_exception_usage.py`.
---
.../ci/prek/check_new_airflow_exception_usage.py | 141 +++------------
scripts/ci/prek/check_provide_session_kwargs.py | 190 ++++----------------
scripts/ci/prek/common_prek_utils.py | 184 ++++++++++++++++++-
.../prek/test_check_new_airflow_exception_usage.py | 194 +++++++++++++++++++++
.../ci/prek/test_check_provide_session_kwargs.py | 44 ++---
5 files changed, 455 insertions(+), 298 deletions(-)
diff --git a/scripts/ci/prek/check_new_airflow_exception_usage.py
b/scripts/ci/prek/check_new_airflow_exception_usage.py
index 6ba97db865e..5c096021920 100755
--- a/scripts/ci/prek/check_new_airflow_exception_usage.py
+++ b/scripts/ci/prek/check_new_airflow_exception_usage.py
@@ -54,87 +54,40 @@ from __future__ import annotations
import argparse
import re
+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 = Path(__file__).parents[3]
+REPO_ROOT = AIRFLOW_ROOT_PATH
# Match lines that actually raise AirflowException. Comment filtering is done
# in _raise_lines() by skipping lines whose stripped form starts with "#".
_RAISE_RE = re.compile(r"raise\s+AirflowException\b")
-class AllowlistManager:
+class AirflowExceptionAllowlistManager(AllowlistManager):
def __init__(self, allowlist_file: Path) -> None:
- self.allowlist_file = allowlist_file
-
- def load(self) -> dict[str, int]:
- """Return mapping of ``relative_path -> allowed_count``."""
- if not self.allowlist_file.exists():
- return {}
-
- result: dict[str, int] = {}
- for raw_line in self.allowlist_file.read_text().splitlines():
- if not (stripped := raw_line.strip()):
- continue
-
- rel_str, _, count_str = stripped.rpartition("::")
- if not rel_str or not count_str:
- continue
-
- try:
- result[rel_str] = int(count_str)
- except ValueError:
- continue
-
- return result
-
- def save(self, counts: dict[str, int]) -> None:
- lines = [f"{rel}::{count}" for rel, count in sorted(counts.items())]
- self.allowlist_file.write_text("\n".join(lines) + "\n")
-
- def generate(self) -> int:
- console.print(f"Scanning [cyan]{REPO_ROOT}[/cyan] for raise
AirflowException …")
- counts: dict[str, int] = {}
- for path in _iter_python_files():
- n = len(_raise_lines(path))
- if n > 0:
- counts[str(path.relative_to(REPO_ROOT))] = n
-
- self.save(counts)
- total = sum(counts.values())
- console.print(
- f"[green]✓ Generated[/green]
[cyan]{self.allowlist_file.relative_to(REPO_ROOT)}[/cyan] "
- f"with [bold]{len(counts)}[/bold] files / [bold]{total}[/bold]
occurrences."
+ 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 len(_raise_lines(path))
+
+ def violation_panel_text(self) -> str:
+ return (
+ "New [bold]raise AirflowException[/bold] usage detected.\n"
+ "Define a dedicated exception class or use an existing specific
exception.\n"
+ "If this usage is intentional and pre-existing, run:\n\n"
+ " [cyan]uv run
./scripts/ci/prek/check_new_airflow_exception_usage.py --generate[/cyan]\n\n"
+ "to regenerate the allowlist, then commit the updated\n"
+ "[cyan]generated/known_airflow_exceptions.txt[/cyan]."
)
- return 0
-
- def cleanup(self) -> int:
- allowlist = self.load()
- if not allowlist:
- console.print("[yellow]Allowlist is empty – nothing to clean
up.[/yellow]")
- return 0
-
- stale: list[str] = [rel for rel in allowlist if not (REPO_ROOT /
rel).exists()]
- if stale:
- console.print(
- f"[yellow]Removing {len(stale)} stale entr{'y' if len(stale)
== 1 else 'ies'}:[/yellow]"
- )
- for s in sorted(stale):
- console.print(f" [dim]-[/dim] {s}")
- for s in stale:
- del allowlist[s]
- self.save(allowlist)
- console.print(
- f"\n[green]Updated[/green]
[cyan]{self.allowlist_file.relative_to(REPO_ROOT)}[/cyan]"
- )
- else:
- console.print("[green]✓ No stale entries found.[/green]")
- return 0
def _raise_lines(path: Path) -> list[str]:
@@ -162,57 +115,9 @@ def _iter_python_files() -> list[Path]:
def _check_airflow_exception_usage(
- files: list[Path], allowlist: dict[str, int], manager: AllowlistManager
+ files: list[Path], allowlist: dict[str, int], manager:
AirflowExceptionAllowlistManager
) -> int:
- violations: list[tuple[Path, int, int]] = []
- tightened: list[tuple[str, int, int]] = [] # (rel, old_count, new_count)
-
- for path in files:
- if not path.exists() or path.suffix != ".py":
- continue
- actual = len(_raise_lines(path))
- rel = str(path.relative_to(REPO_ROOT))
- allowed = allowlist.get(rel, 0)
- if actual > allowed:
- violations.append((path, actual, allowed))
- elif actual < allowed:
- # Usage was reduced — tighten the allowlist entry so it can't
creep back up.
- if actual == 0:
- del allowlist[rel]
- else:
- allowlist[rel] = actual
- tightened.append((rel, allowed, actual))
-
- if tightened:
- manager.save(allowlist)
- console.print(
- f"[green]✓ Tightened {len(tightened)} entr{'y' if len(tightened)
== 1 else 'ies'} "
- f"in
[cyan]{manager.allowlist_file.relative_to(REPO_ROOT)}[/cyan][/green] "
- "(stage the updated file):"
- )
- for rel, old, new in tightened:
- console.print(f" [cyan]{rel}[/cyan] {old} → {new}")
-
- if violations:
- console.print(
- Panel.fit(
- "New [bold]raise AirflowException[/bold] usage detected.\n"
- "Define a dedicated exception class or use an existing
specific exception.\n"
- "If this usage is intentional and pre-existing, run:\n\n"
- " [cyan]uv run
./scripts/ci/prek/check_new_airflow_exception_usage.py --generate[/cyan]\n\n"
- "to regenerate the allowlist, then commit the updated\n"
- "[cyan]generated/known_airflow_exceptions.txt[/cyan].",
- title="[red]❌ Check failed[/red]",
- border_style="red",
- )
- )
- for path, actual, allowed in violations:
- console.print(f" [cyan]{path.relative_to(REPO_ROOT)}[/cyan]
count={actual} (allowed={allowed})")
- return 1
-
- # Return 1 when the allowlist was tightened so pre-commit reports the file
as modified
- # and prompts the user to stage the updated allowlist.
- return 1 if tightened else 0
+ return manager.check(files, allowlist)
def main(argv: list[str] | None = None) -> int:
@@ -239,7 +144,7 @@ def main(argv: list[str] | None = None) -> int:
)
args = parser.parse_args(argv)
- manager = AllowlistManager(REPO_ROOT / "generated" /
"known_airflow_exceptions.txt")
+ manager = AirflowExceptionAllowlistManager(REPO_ROOT / "generated" /
"known_airflow_exceptions.txt")
if args.generate:
return manager.generate()
diff --git a/scripts/ci/prek/check_provide_session_kwargs.py
b/scripts/ci/prek/check_provide_session_kwargs.py
index 29152e13a4d..0025126d52f 100755
--- a/scripts/ci/prek/check_provide_session_kwargs.py
+++ b/scripts/ci/prek/check_provide_session_kwargs.py
@@ -65,14 +65,16 @@ 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 = Path(__file__).parents[3]
+REPO_ROOT = AIRFLOW_ROOT_PATH
_PROVIDE_SESSION_DECORATOR = "provide_session"
@@ -133,109 +135,33 @@ def _count_violations(path: Path) -> int:
return sum(1 for _ in _iter_positional_session_in_provide_session(path))
-def _is_safe_relative(rel: str) -> bool:
- """Whether ``rel`` is a plain relative path that stays inside
``REPO_ROOT``.
-
- Rejects absolute paths and any entry that resolves outside the repo root so
- callers can ``relative_to(REPO_ROOT)`` without fear of a ``ValueError``.
- """
- candidate = Path(rel)
- if candidate.is_absolute():
- return False
- try:
- (REPO_ROOT / candidate).resolve().relative_to(REPO_ROOT.resolve())
- except ValueError:
- return False
- return True
-
-
-class AllowlistManager:
+class ProvideSessionAllowlistManager(AllowlistManager):
def __init__(self, allowlist_file: Path) -> None:
- self.allowlist_file = allowlist_file
-
- @staticmethod
- def parse(text: str) -> dict[str, int]:
- """Parse allowlist *text* into a ``{rel_path: count}`` mapping.
-
- Same validation rules as :meth:`load` so we can reuse parsing for the
- on-disk allowlist *and* for the git-tracked version fetched from
- ``HEAD`` when guarding against entry-removal bypasses.
- """
- result: dict[str, int] = {}
- for raw_line in text.splitlines():
- if not (stripped := raw_line.strip()):
- continue
-
- rel_str, _, count_str = stripped.rpartition("::")
- if not rel_str or not count_str:
- continue
-
- try:
- count = int(count_str)
- except ValueError:
- continue
-
- if not _is_safe_relative(rel_str):
- console.print(
- f"[yellow]Ignoring unsafe allowlist entry (escapes repo
root):[/yellow] {rel_str}"
- )
- continue
-
- result[rel_str] = count
-
- return result
-
- def load(self) -> dict[str, int]:
- if not self.allowlist_file.exists():
- return {}
- return self.parse(self.allowlist_file.read_text())
-
- def save(self, counts: dict[str, int]) -> None:
- lines = [f"{rel}::{count}" for rel, count in sorted(counts.items())]
- self.allowlist_file.write_text("\n".join(lines) + "\n")
-
- def generate(self) -> int:
- roots = ", ".join(_PROJECT_SOURCE_ROOTS)
- console.print(
- f"Scanning project source roots ([cyan]{roots}[/cyan]) under
[cyan]{REPO_ROOT}[/cyan] "
- "for @provide_session functions with positional session …"
+ 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]."
)
- counts: dict[str, int] = {}
- for path in _iter_python_files():
- n = _count_violations(path)
- if n > 0:
- counts[str(path.relative_to(REPO_ROOT))] = n
-
- self.save(counts)
- total = sum(counts.values())
- console.print(
- f"[green]Generated[/green]
[cyan]{self.allowlist_file.relative_to(REPO_ROOT)}[/cyan] "
- f"with [bold]{len(counts)}[/bold] files / [bold]{total}[/bold]
offenders."
- )
- return 0
-
- def cleanup(self) -> int:
- allowlist = self.load()
- if not allowlist:
- console.print("[yellow]Allowlist is empty - nothing to clean
up.[/yellow]")
- return 0
- stale: list[str] = [rel for rel in allowlist if not (REPO_ROOT /
rel).exists()]
- if stale:
- console.print(
- f"[yellow]Removing {len(stale)} stale entr{'y' if len(stale)
== 1 else 'ies'}:[/yellow]"
- )
- for s in sorted(stale):
- console.print(f" [dim]-[/dim] {s}")
- for s in stale:
- del allowlist[s]
- self.save(allowlist)
- console.print(
- f"\n[green]Updated[/green]
[cyan]{self.allowlist_file.relative_to(REPO_ROOT)}[/cyan]"
- )
- else:
- console.print("[green]No stale entries found.[/green]")
- return 0
+ 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]:
@@ -250,7 +176,7 @@ def _iter_python_files() -> list[Path]:
def _check_provide_session_kwargs(
- files: list[Path], allowlist: dict[str, int], manager: AllowlistManager
+ 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():
@@ -265,57 +191,7 @@ def _check_provide_session_kwargs(
)
)
return 1
-
- violations: list[tuple[Path, int, int]] = []
- tightened: list[tuple[str, int, int]] = []
-
- for path in files:
- if not path.exists() or path.suffix != ".py":
- continue
- actual = _count_violations(path)
- rel = str(path.relative_to(REPO_ROOT))
- allowed = allowlist.get(rel, 0)
- if actual > allowed:
- violations.append((path, actual, allowed))
- elif actual < allowed:
- if actual == 0:
- del allowlist[rel]
- else:
- allowlist[rel] = actual
- tightened.append((rel, allowed, actual))
-
- if tightened:
- manager.save(allowlist)
- console.print(
- f"[green]Tightened {len(tightened)} entr{'y' if len(tightened) ==
1 else 'ies'} "
- f"in
[cyan]{manager.allowlist_file.relative_to(REPO_ROOT)}[/cyan][/green] "
- "(stage the updated file):"
- )
- for rel, old, new in tightened:
- console.print(f" [cyan]{rel}[/cyan] {old} -> {new}")
-
- if violations:
- console.print(
- Panel.fit(
- "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].",
- title="[red]Check failed[/red]",
- border_style="red",
- )
- )
- for path, actual, allowed in violations:
- console.print(f" [cyan]{path.relative_to(REPO_ROOT)}[/cyan]
count={actual} (allowed={allowed})")
- for func, argument in
_iter_positional_session_in_provide_session(path):
- console.print(f" [dim]L{argument.lineno}[/dim] def
{func.name}(...)")
- return 1
-
- return 1 if tightened else 0
+ return manager.check(files, allowlist)
def main(argv: list[str] | None = None) -> int:
@@ -342,7 +218,7 @@ def main(argv: list[str] | None = None) -> int:
)
args = parser.parse_args(argv)
- manager = AllowlistManager(Path(__file__).parent /
"known_provide_session_positional.txt")
+ manager = ProvideSessionAllowlistManager(Path(__file__).parent /
"known_provide_session_positional.txt")
if args.generate:
return manager.generate()
@@ -366,7 +242,7 @@ def main(argv: list[str] | None = None) -> int:
return _check_provide_session_kwargs(paths, allowlist, manager)
-def _parse_tracked_allowlist(manager: AllowlistManager) -> dict[str, int]:
+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
@@ -390,11 +266,11 @@ def _parse_tracked_allowlist(manager: AllowlistManager)
-> dict[str, int]:
return {}
if completed.returncode != 0:
return {}
- return AllowlistManager.parse(completed.stdout)
+ return manager.parse(completed.stdout)
def _expand_for_allowlist_edits(
- paths: list[Path], manager: AllowlistManager, allowlist: dict[str, int]
+ paths: list[Path], manager: ProvideSessionAllowlistManager, allowlist:
dict[str, int]
) -> list[Path]:
"""Add allowlisted files when the allowlist itself is being changed.
diff --git a/scripts/ci/prek/common_prek_utils.py
b/scripts/ci/prek/common_prek_utils.py
index 434bf13a482..26e49175ce6 100644
--- a/scripts/ci/prek/common_prek_utils.py
+++ b/scripts/ci/prek/common_prek_utils.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import abc
import ast
import difflib
import os
@@ -26,7 +27,7 @@ import subprocess
import sys
import textwrap
import time
-from collections.abc import Callable, Generator
+from collections.abc import Callable, Generator, Iterable
from contextlib import contextmanager
from pathlib import Path
from tempfile import NamedTemporaryFile, _TemporaryFileWrapper
@@ -58,10 +59,12 @@ GITHUB_TOKEN_ENV_VARS = ("GH_TOKEN", "GITHUB_TOKEN")
try:
from rich.console import Console
+ from rich.panel import Panel
console = Console(width=400, color_system="standard")
except ImportError:
console = None # type: ignore[assignment]
+ Panel = None # type: ignore[assignment,misc]
@contextmanager
@@ -875,3 +878,182 @@ def parse_operations(
commands[group_name].append(subcommand)
return commands
+
+
+def _is_safe_relative(rel: str, repo_root: Path) -> bool:
+ """Whether ``rel`` is a plain relative path that stays inside
``repo_root``."""
+ candidate = Path(rel)
+ if candidate.is_absolute():
+ return False
+ try:
+ (repo_root / candidate).resolve().relative_to(repo_root.resolve())
+ except ValueError:
+ return False
+ return True
+
+
+class AllowlistManager(abc.ABC):
+ """Common base for prek hooks that track per-file occurrence counts in
allowlist files.
+
+ Subclasses implement :meth:`iter_files`, :meth:`count_occurrences`, and
+ :meth:`violation_panel_text` to define what gets scanned, how violations
+ are counted, and what help text to show. Everything else — loading,
saving,
+ generating, cleaning up, and the check loop — is handled here.
+ """
+
+ def __init__(self, allowlist_file: Path, *, repo_root: Path =
AIRFLOW_ROOT_PATH) -> None:
+ self.allowlist_file = allowlist_file
+ self.repo_root = repo_root
+
+ def parse(self, text: str) -> dict[str, int]:
+ """Parse allowlist *text* into a ``{rel_path: count}`` mapping.
+
+ Entries that escape the repo root (absolute paths or ``..`` segments)
+ are silently skipped.
+ """
+ result: dict[str, int] = {}
+ for raw_line in text.splitlines():
+ if not (stripped := raw_line.strip()):
+ continue
+
+ rel_str, _, count_str = stripped.rpartition("::")
+ if not rel_str or not count_str:
+ continue
+
+ try:
+ count = int(count_str)
+ except ValueError:
+ continue
+
+ if not _is_safe_relative(rel_str, self.repo_root):
+ if console:
+ console.print(
+ f"[yellow]Ignoring unsafe allowlist entry (escapes
repo root):[/yellow] {rel_str}"
+ )
+ continue
+
+ result[rel_str] = count
+
+ return result
+
+ def load(self) -> dict[str, int]:
+ """Return mapping of ``relative_path -> allowed_count``."""
+ if not self.allowlist_file.exists():
+ return {}
+ return self.parse(self.allowlist_file.read_text())
+
+ def save(self, counts: dict[str, int]) -> None:
+ lines = [f"{rel}::{count}" for rel, count in sorted(counts.items())]
+ self.allowlist_file.write_text("\n".join(lines) + "\n")
+
+ @abc.abstractmethod
+ def iter_files(self) -> Iterable[Path]:
+ """Return all files to scan during ``--generate`` or
``--all-files``."""
+
+ @abc.abstractmethod
+ def count_occurrences(self, path: Path) -> int:
+ """Count the number of violations/occurrences in a single file."""
+
+ @abc.abstractmethod
+ def violation_panel_text(self) -> str:
+ """Return the rich markup body for the violation help panel."""
+
+ def format_violation_details(self, path: Path) -> list[str]:
+ """Return extra detail lines for each violating file."""
+ return []
+
+ def check(self, files: list[Path], allowlist: dict[str, int]) -> int:
+ """Run the check loop: compare counts, tighten entries, report
violations."""
+ violations: list[tuple[Path, int, int]] = []
+ tightened: list[tuple[str, int, int]] = []
+
+ for path in files:
+ if not path.exists() or path.suffix != ".py":
+ continue
+ actual = self.count_occurrences(path)
+ rel = str(path.relative_to(self.repo_root))
+ allowed = allowlist.get(rel, 0)
+ if actual > allowed:
+ violations.append((path, actual, allowed))
+ elif actual < allowed:
+ if actual == 0:
+ del allowlist[rel]
+ else:
+ allowlist[rel] = actual
+ tightened.append((rel, allowed, actual))
+
+ if tightened:
+ self.save(allowlist)
+ if console:
+ console.print(
+ f"[green]Tightened {len(tightened)} entr{'y' if
len(tightened) == 1 else 'ies'} "
+ f"in
[cyan]{self.allowlist_file.relative_to(self.repo_root)}[/cyan][/green] "
+ "(stage the updated file):"
+ )
+ for rel, old, new in tightened:
+ console.print(f" [cyan]{rel}[/cyan] {old} → {new}")
+
+ if violations:
+ if console:
+ console.print(
+ Panel.fit(
+ self.violation_panel_text(),
+ title="[red]Check failed[/red]",
+ border_style="red",
+ )
+ )
+ for path, actual, allowed in violations:
+ console.print(
+ f" [cyan]{path.relative_to(self.repo_root)}[/cyan] "
+ f"count={actual} (allowed={allowed})"
+ )
+ for detail in self.format_violation_details(path):
+ console.print(detail)
+ return 1
+
+ return 1 if tightened else 0
+
+ def generate(self) -> int:
+ if console:
+ console.print(f"Scanning [cyan]{self.repo_root}[/cyan] …")
+ counts: dict[str, int] = {}
+ for path in self.iter_files():
+ n = self.count_occurrences(path)
+ if n > 0:
+ counts[str(path.relative_to(self.repo_root))] = n
+
+ self.save(counts)
+ total = sum(counts.values())
+ if console:
+ console.print(
+ f"[green]Generated[/green]
[cyan]{self.allowlist_file.relative_to(self.repo_root)}[/cyan] "
+ f"with [bold]{len(counts)}[/bold] files / [bold]{total}[/bold]
occurrences."
+ )
+ return 0
+
+ def cleanup(self) -> int:
+ allowlist = self.load()
+ if not allowlist:
+ if console:
+ console.print("[yellow]Allowlist is empty – nothing to clean
up.[/yellow]")
+ return 0
+
+ stale: list[str] = [rel for rel in allowlist if not (self.repo_root /
rel).exists()]
+ if stale:
+ if console:
+ console.print(
+ f"[yellow]Removing {len(stale)} stale entr{'y' if
len(stale) == 1 else 'ies'}:[/yellow]"
+ )
+ for s in sorted(stale):
+ console.print(f" [dim]-[/dim] {s}")
+ for s in stale:
+ del allowlist[s]
+ self.save(allowlist)
+ if console:
+ console.print(
+ f"\n[green]Updated[/green]
[cyan]{self.allowlist_file.relative_to(self.repo_root)}[/cyan]"
+ )
+ else:
+ if console:
+ console.print("[green]No stale entries found.[/green]")
+ return 0
diff --git a/scripts/tests/ci/prek/test_check_new_airflow_exception_usage.py
b/scripts/tests/ci/prek/test_check_new_airflow_exception_usage.py
new file mode 100644
index 00000000000..f1abb7d6308
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_new_airflow_exception_usage.py
@@ -0,0 +1,194 @@
+# 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.
+from __future__ import annotations
+
+import textwrap
+from pathlib import Path
+
+import pytest
+from ci.prek import check_new_airflow_exception_usage as hook
+from ci.prek.check_new_airflow_exception_usage import (
+ AirflowExceptionAllowlistManager,
+ _check_airflow_exception_usage,
+ _raise_lines,
+)
+
+
[email protected]
+def create_fake_repo(tmp_path, monkeypatch):
+ monkeypatch.setattr(hook, "REPO_ROOT", tmp_path)
+
+ def _write(rel: str, code: str) -> Path:
+ path = tmp_path / rel
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(textwrap.dedent(code))
+ return path
+
+ return _write
+
+
+class TestRaiseLines:
+ def test_counts_raise_airflow_exception(self, write_python_file):
+ path = write_python_file(
+ """\
+ from airflow.exceptions import AirflowException
+ raise AirflowException("boom")
+ raise AirflowException("bang")
+ """
+ )
+ assert len(_raise_lines(path)) == 2
+
+ def test_ignores_commented_lines(self, write_python_file):
+ path = write_python_file(
+ """\
+ # raise AirflowException("commented out")
+ raise AirflowException("real")
+ """
+ )
+ assert len(_raise_lines(path)) == 1
+
+ def test_ignores_other_raises(self, write_python_file):
+ path = write_python_file(
+ """\
+ raise ValueError("not this")
+ raise TypeError("nor this")
+ """
+ )
+ assert len(_raise_lines(path)) == 0
+
+ def test_missing_file_returns_empty(self, tmp_path):
+ assert _raise_lines(tmp_path / "nonexistent.py") == []
+
+
+class TestAirflowExceptionAllowlistManager:
+ def test_load_missing_file_returns_empty(self, tmp_path):
+ manager = AirflowExceptionAllowlistManager(tmp_path / "missing.txt")
+ assert manager.load() == {}
+
+ def test_save_and_load_round_trip(self, tmp_path):
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ manager.save({"b/file.py": 2, "a/file.py": 1})
+ text = (tmp_path / "allowlist.txt").read_text()
+ assert text.splitlines() == ["a/file.py::1", "b/file.py::2"]
+ assert manager.load() == {"a/file.py": 1, "b/file.py": 2}
+
+ def test_load_skips_blank_and_malformed_lines(self, tmp_path):
+ path = tmp_path / "allowlist.txt"
+ path.write_text("\nvalid/file.py::3\nnocount\n::5\nbad::notanumber\n")
+ assert AirflowExceptionAllowlistManager(path).load() ==
{"valid/file.py": 3}
+
+ @pytest.mark.usefixtures("create_fake_repo")
+ def test_load_skips_unsafe_entries(self, tmp_path):
+ path = tmp_path / "allowlist.txt"
+
path.write_text("airflow-core/src/airflow/safe.py::1\n../escape.py::1\n/etc/passwd::1\n")
+ assert AirflowExceptionAllowlistManager(path).load() ==
{"airflow-core/src/airflow/safe.py": 1}
+
+
+class TestCheckAirflowExceptionUsage:
+ def test_no_violations_passes(self, create_fake_repo, tmp_path):
+ path = create_fake_repo(
+ "airflow-core/src/airflow/clean.py",
+ """\
+ raise ValueError("specific exception")
+ """,
+ )
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ assert _check_airflow_exception_usage([path], {}, manager) == 0
+
+ def test_new_violation_fails(self, create_fake_repo, tmp_path):
+ path = create_fake_repo(
+ "airflow-core/src/airflow/bad.py",
+ """\
+ raise AirflowException("boom")
+ """,
+ )
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ assert _check_airflow_exception_usage([path], {}, manager) == 1
+
+ def test_violation_within_allowlist_passes(self, create_fake_repo,
tmp_path):
+ path = create_fake_repo(
+ "airflow-core/src/airflow/grandfathered.py",
+ """\
+ raise AirflowException("old")
+ """,
+ )
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ allowlist = {"airflow-core/src/airflow/grandfathered.py": 1}
+ assert _check_airflow_exception_usage([path], allowlist, manager) == 0
+
+ def test_exceeding_allowlist_fails(self, create_fake_repo, tmp_path):
+ path = create_fake_repo(
+ "airflow-core/src/airflow/grew.py",
+ """\
+ raise AirflowException("one")
+ raise AirflowException("two")
+ """,
+ )
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ allowlist = {"airflow-core/src/airflow/grew.py": 1}
+ assert _check_airflow_exception_usage([path], allowlist, manager) == 1
+
+ def test_reducing_violations_tightens_allowlist(self, create_fake_repo,
tmp_path):
+ path = create_fake_repo(
+ "airflow-core/src/airflow/improved.py",
+ """\
+ raise AirflowException("one remains")
+ """,
+ )
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ allowlist = {"airflow-core/src/airflow/improved.py": 2}
+ assert _check_airflow_exception_usage([path], allowlist, manager) == 1
+ assert manager.load() == {"airflow-core/src/airflow/improved.py": 1}
+
+ def test_fixing_all_violations_removes_entry(self, create_fake_repo,
tmp_path):
+ path = create_fake_repo(
+ "airflow-core/src/airflow/fixed.py",
+ """\
+ raise ValueError("migrated away from AirflowException")
+ """,
+ )
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ allowlist = {"airflow-core/src/airflow/fixed.py": 1}
+ assert _check_airflow_exception_usage([path], allowlist, manager) == 1
+ assert manager.load() == {}
+
+ def test_non_python_file_is_skipped(self, create_fake_repo, tmp_path):
+ path = create_fake_repo(
+ "airflow-core/src/airflow/not_python.txt",
+ "raise AirflowException('in a text file')\n",
+ )
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ assert _check_airflow_exception_usage([path], {}, manager) == 0
+
+
+class TestCleanup:
+ def test_cleanup_removes_stale_entries(self, create_fake_repo, tmp_path):
+ create_fake_repo("airflow-core/src/airflow/keeper.py", "pass")
+ allowlist_path = tmp_path / "allowlist.txt"
+ manager = AirflowExceptionAllowlistManager(allowlist_path)
+ manager.save(
+ {
+ "airflow-core/src/airflow/keeper.py": 1,
+ "airflow-core/src/airflow/gone.py": 1,
+ }
+ )
+ assert manager.cleanup() == 0
+ assert manager.load() == {"airflow-core/src/airflow/keeper.py": 1}
+
+ def test_cleanup_empty_allowlist(self, tmp_path):
+ manager = AirflowExceptionAllowlistManager(tmp_path / "allowlist.txt")
+ assert manager.cleanup() == 0
diff --git a/scripts/tests/ci/prek/test_check_provide_session_kwargs.py
b/scripts/tests/ci/prek/test_check_provide_session_kwargs.py
index 78b85cd270b..4e96f1a413e 100644
--- a/scripts/tests/ci/prek/test_check_provide_session_kwargs.py
+++ b/scripts/tests/ci/prek/test_check_provide_session_kwargs.py
@@ -25,7 +25,7 @@ from pathlib import Path
import pytest
from ci.prek import check_provide_session_kwargs as hook
from ci.prek.check_provide_session_kwargs import (
- AllowlistManager,
+ ProvideSessionAllowlistManager,
_check_provide_session_kwargs,
_count_violations,
_expand_for_allowlist_edits,
@@ -228,13 +228,13 @@ class TestIterPositionalSessionInProvideSession:
assert _count_violations(path) == 1
-class TestAllowlistManager:
+class TestProvideSessionAllowlistManager:
def test_load_missing_file_returns_empty(self, tmp_path):
- manager = AllowlistManager(tmp_path / "missing.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "missing.txt")
assert manager.load() == {}
def test_save_and_load_round_trip(self, tmp_path):
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
manager.save({"b/file.py": 2, "a/file.py": 1})
# Sorted by key in the file
text = (tmp_path / "allowlist.txt").read_text()
@@ -244,7 +244,7 @@ class TestAllowlistManager:
def test_load_skips_blank_and_malformed_lines(self, tmp_path):
path = tmp_path / "allowlist.txt"
path.write_text("\nvalid/file.py::3\nnocount\n::5\nbad::notanumber\n")
- assert AllowlistManager(path).load() == {"valid/file.py": 3}
+ assert ProvideSessionAllowlistManager(path).load() ==
{"valid/file.py": 3}
@pytest.mark.usefixtures("create_fake_repo")
def test_load_skips_unsafe_entries(self, tmp_path):
@@ -252,7 +252,7 @@ class TestAllowlistManager:
path = tmp_path / "allowlist.txt"
path.write_text("airflow-core/src/airflow/safe.py::1\n../escape.py::1\n/etc/passwd::1\n")
# `create_fake_repo` patches REPO_ROOT to tmp_path so the safety check
is meaningful.
- assert AllowlistManager(path).load() ==
{"airflow-core/src/airflow/safe.py": 1}
+ assert ProvideSessionAllowlistManager(path).load() ==
{"airflow-core/src/airflow/safe.py": 1}
class TestCheckProvideSessionKwargs:
@@ -265,7 +265,7 @@ class TestCheckProvideSessionKwargs:
pass
""",
)
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
assert _check_provide_session_kwargs([path], {}, manager) == 0
def test_new_violation_fails(self, create_fake_repo, tmp_path):
@@ -277,7 +277,7 @@ class TestCheckProvideSessionKwargs:
pass
""",
)
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
assert _check_provide_session_kwargs([path], {}, manager) == 1
def test_violation_within_allowlist_passes(self, create_fake_repo,
tmp_path):
@@ -289,7 +289,7 @@ class TestCheckProvideSessionKwargs:
pass
""",
)
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
allowlist = {"airflow-core/src/airflow/grandfathered.py": 1}
assert _check_provide_session_kwargs([path], allowlist, manager) == 0
@@ -306,7 +306,7 @@ class TestCheckProvideSessionKwargs:
pass
""",
)
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
allowlist = {"airflow-core/src/airflow/grew.py": 1}
assert _check_provide_session_kwargs([path], allowlist, manager) == 1
@@ -323,7 +323,7 @@ class TestCheckProvideSessionKwargs:
pass
""",
)
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
allowlist = {"airflow-core/src/airflow/improved.py": 2}
# Exit non-zero so pre-commit reports the modified allowlist
assert _check_provide_session_kwargs([path], allowlist, manager) == 1
@@ -338,7 +338,7 @@ class TestCheckProvideSessionKwargs:
pass
""",
)
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
allowlist = {"airflow-core/src/airflow/fixed.py": 1}
assert _check_provide_session_kwargs([path], allowlist, manager) == 1
assert manager.load() == {}
@@ -347,14 +347,14 @@ class TestCheckProvideSessionKwargs:
path = create_fake_repo(
"airflow-core/src/airflow/not_python.txt", "@provide_session\ndef
foo(session=N): pass\n"
)
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
assert _check_provide_session_kwargs([path], {}, manager) == 0
@pytest.mark.usefixtures("create_fake_repo")
def test_missing_allowlist_file_fails_loudly(self, tmp_path):
"""Passing the allowlist path when the file is missing must fail, not
silently pass."""
allowlist_path = tmp_path / "allowlist.txt"
- manager = AllowlistManager(allowlist_path)
+ manager = ProvideSessionAllowlistManager(allowlist_path)
assert not allowlist_path.exists()
assert _check_provide_session_kwargs([allowlist_path.resolve()], {},
manager) == 1
@@ -362,12 +362,12 @@ class TestCheckProvideSessionKwargs:
class TestExpandForAllowlistEdits:
def test_unchanged_when_allowlist_not_in_paths(self, create_fake_repo,
tmp_path):
py = create_fake_repo("airflow-core/src/airflow/x.py", "pass")
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
assert _expand_for_allowlist_edits([py], manager,
{"airflow-core/src/airflow/x.py": 1}) == [py]
def test_appends_allowlisted_files_when_allowlist_edited(self,
create_fake_repo, tmp_path):
allowlist_path = tmp_path / "allowlist.txt"
- manager = AllowlistManager(allowlist_path)
+ manager = ProvideSessionAllowlistManager(allowlist_path)
listed = create_fake_repo("airflow-core/src/airflow/listed.py", "pass")
# Pass a resolved path — matches production behavior (``main()``
resolves argv).
result = _expand_for_allowlist_edits(
@@ -383,7 +383,7 @@ class TestExpandForAllowlistEdits:
def test_detection_robust_to_symlinked_allowlist(self, create_fake_repo,
tmp_path):
"""A symlink pointing at the allowlist file must still trigger
expansion."""
allowlist_path = tmp_path / "allowlist.txt"
- manager = AllowlistManager(allowlist_path)
+ manager = ProvideSessionAllowlistManager(allowlist_path)
listed = create_fake_repo("airflow-core/src/airflow/listed.py", "pass")
manager.save({"airflow-core/src/airflow/listed.py": 1})
@@ -410,7 +410,7 @@ class TestExpandForAllowlistEdits:
""",
)
allowlist_path = tmp_path / "allowlist.txt"
- manager = AllowlistManager(allowlist_path)
+ manager = ProvideSessionAllowlistManager(allowlist_path)
manager.save({rel: 1})
create_git_repo("seed allowlist at HEAD")
@@ -429,7 +429,7 @@ class TestExpandForAllowlistEdits:
@pytest.mark.usefixtures("create_fake_repo")
def test_parse_tracked_allowlist_empty_when_no_git_history(self, tmp_path):
"""Without a git repo the git-tracked allowlist lookup returns empty
and does not crash."""
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
assert _parse_tracked_allowlist(manager) == {}
def test_re_validates_listed_files_so_loosening_cannot_bypass(self,
create_fake_repo, tmp_path, capsys):
@@ -448,7 +448,7 @@ class TestExpandForAllowlistEdits:
""",
)
allowlist_path = tmp_path / "allowlist.txt"
- manager = AllowlistManager(allowlist_path)
+ manager = ProvideSessionAllowlistManager(allowlist_path)
# Allowlist loosened to 5 although file only has 2 positional sessions.
allowlist = {rel: 5}
manager.save(allowlist)
@@ -467,7 +467,7 @@ class TestCleanup:
def test_cleanup_removes_stale_entries(self, create_fake_repo, tmp_path):
create_fake_repo("airflow-core/src/airflow/keeper.py", "pass")
allowlist_path = tmp_path / "allowlist.txt"
- manager = AllowlistManager(allowlist_path)
+ manager = ProvideSessionAllowlistManager(allowlist_path)
manager.save(
{
"airflow-core/src/airflow/keeper.py": 1,
@@ -478,5 +478,5 @@ class TestCleanup:
assert manager.load() == {"airflow-core/src/airflow/keeper.py": 1}
def test_cleanup_empty_allowlist(self, tmp_path):
- manager = AllowlistManager(tmp_path / "allowlist.txt")
+ manager = ProvideSessionAllowlistManager(tmp_path / "allowlist.txt")
assert manager.cleanup() == 0