This is an automated email from the ASF dual-hosted git repository.

amoghrajesh 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 d8cfc951f7e Convert check-sdk-imports to capture occurences than files 
(#71212)
d8cfc951f7e is described below

commit d8cfc951f7edcb15a0473b25a26b9495ff53d3c1
Author: Amogh Desai <[email protected]>
AuthorDate: Tue Aug 11 09:27:06 2026 +0530

    Convert check-sdk-imports to capture occurences than files (#71212)
---
 airflow-core/.pre-commit-config.yaml               | 103 +--------
 generated/known_sdk_imports_in_core.txt            |  44 ++++
 scripts/ci/prek/check_sdk_imports_in_core.py       | 210 +++++++++++++++++--
 .../ci/prek/test_check_sdk_imports_in_core.py      | 232 ++++++++++++++++++++-
 4 files changed, 470 insertions(+), 119 deletions(-)

diff --git a/airflow-core/.pre-commit-config.yaml 
b/airflow-core/.pre-commit-config.yaml
index 08382f3934f..c59444e234d 100644
--- a/airflow-core/.pre-commit-config.yaml
+++ b/airflow-core/.pre-commit-config.yaml
@@ -308,113 +308,12 @@ repos:
           ^tests/unit/core/test_configuration\.py$|
           ^tests/unit/models/test_renderedtifields\.py$|
           ^tests/unit/models/test_variable\.py$
-      - id: check-sdk-imports
+      - id: check-sdk-imports-in-core
         name: Check for SDK imports in core files
         entry: ../scripts/ci/prek/check_sdk_imports_in_core.py
         language: python
         types: [python]
         files: ^src/airflow/
-        exclude: |
-          (?x)
-          # Allow SDK imports in these legitimate locations
-          ^src/airflow/example_dags/.*\.py$|
-
-          # TODO: These files need to be refactored to remove SDK coupling
-          ^src/airflow/__init__\.py$|
-          ^src/airflow/api/common/mark_tasks\.py$|
-          ^src/airflow/api_fastapi/core_api/datamodels/assets\.py$|
-          ^src/airflow/api_fastapi/core_api/datamodels/connections\.py$|
-          ^src/airflow/api_fastapi/core_api/datamodels/hitl\.py$|
-          ^src/airflow/api_fastapi/core_api/datamodels/variables\.py$|
-          ^src/airflow/api_fastapi/core_api/routes/ui/grid\.py$|
-          ^src/airflow/api_fastapi/core_api/routes/ui/structure\.py$|
-          ^src/airflow/api_fastapi/core_api/services/public/connections\.py$|
-          ^src/airflow/api_fastapi/core_api/services/ui/connections\.py$|
-          ^src/airflow/api_fastapi/core_api/services/ui/grid\.py$|
-          ^src/airflow/api_fastapi/core_api/services/ui/task_group.py$|
-          ^src/airflow/api_fastapi/execution_api/routes/hitl\.py$|
-          ^src/airflow/api_fastapi/execution_api/routes/task_instances\.py$|
-          ^src/airflow/api_fastapi/execution_api/versions/v2026_04_06\.py$|
-          ^src/airflow/api_fastapi/logging/decorators\.py$|
-          ^src/airflow/assets/evaluation\.py$|
-          ^src/airflow/assets/manager\.py$|
-          ^src/airflow/cli/commands/connection_command\.py$|
-          ^src/airflow/cli/commands/task_command\.py$|
-          ^src/airflow/cli/commands/triggerer_command.py$|
-          ^src/airflow/configuration\.py$|
-          ^src/airflow/dag_processing/collection\.py$|
-          ^src/airflow/dag_processing/dagbag\.py$|
-          ^src/airflow/dag_processing/importers/.*\.py$|
-          ^src/airflow/dag_processing/manager\.py$|
-          ^src/airflow/dag_processing/processor\.py$|
-          ^src/airflow/datasets/metadata\.py$|
-          ^src/airflow/exceptions\.py$|
-          ^src/airflow/executors/base_executor\.py$|
-          ^src/airflow/jobs/triggerer_job_runner\.py$|
-          ^src/airflow/lineage/hook\.py$|
-          ^src/airflow/listeners/spec/asset\.py$|
-          ^src/airflow/listeners/spec/taskinstance\.py$|
-          ^src/airflow/logging/remote\.py$|
-          ^src/airflow/models/__init__\.py$|
-          ^src/airflow/models/asset\.py$|
-          ^src/airflow/models/baseoperator\.py$|
-          ^src/airflow/models/callback\.py$|
-          ^src/airflow/models/connection\.py$|
-          ^src/airflow/models/dag\.py$|
-          ^src/airflow/models/dagrun\.py$|
-          ^src/airflow/models/deadline\.py$|
-          ^src/airflow/models/expandinput\.py$|
-          ^src/airflow/models/mappedoperator\.py$|
-          ^src/airflow/models/operator\.py$|
-          ^src/airflow/models/param\.py$|
-          ^src/airflow/models/renderedtifields\.py$|
-          ^src/airflow/models/serialized_dag\.py$|
-          ^src/airflow/models/taskinstance\.py$|
-          ^src/airflow/models/taskinstancekey\.py$|
-          ^src/airflow/models/taskmap\.py$|
-          ^src/airflow/models/taskmixin\.py$|
-          ^src/airflow/models/taskreschedule\.py$|
-          ^src/airflow/models/trigger\.py$|
-          ^src/airflow/stats\.py$|
-          ^src/airflow/models/variable\.py$|
-          ^src/airflow/models/xcom\.py$|
-          ^src/airflow/models/xcom_arg\.py$|
-          ^src/airflow/operators/subdag\.py$|
-          ^src/airflow/plugins_manager\.py$|
-          ^src/airflow/providers_manager\.py$|
-          ^src/airflow/secrets/__init__.py$|
-          ^src/airflow/serialization/decoders\.py$|
-          ^src/airflow/serialization/definitions/[_/a-z]+\.py$|
-          ^src/airflow/serialization/encoders\.py$|
-          ^src/airflow/serialization/enums\.py$|
-          ^src/airflow/serialization/helpers\.py$|
-          ^src/airflow/serialization/serialized_objects\.py$|
-          ^src/airflow/settings\.py$|
-          ^src/airflow/task/task_runner/bash_task_runner\.py$|
-          ^src/airflow/task/task_runner/standard_task_runner\.py$|
-          ^src/airflow/ti_deps/deps/mapped_task_upstream_dep\.py$|
-          ^src/airflow/ti_deps/deps/prev_dagrun_dep\.py$|
-          ^src/airflow/ti_deps/deps/trigger_rule_dep\.py$|
-          ^src/airflow/timetables/assets\.py$|
-          ^src/airflow/timetables/base\.py$|
-          ^src/airflow/timetables/simple\.py$|
-          ^src/airflow/triggers/base\.py$|
-          ^src/airflow/utils/cli\.py$|
-          ^src/airflow/utils/context\.py$|
-          ^src/airflow/utils/dag_cycle_tester\.py$|
-          ^src/airflow/utils/dag_edges\.py$|
-          ^src/airflow/utils/dag_parsing_context\.py$|
-          ^src/airflow/utils/decorators\.py$|
-          ^src/airflow/utils/dot_renderer\.py$|
-          ^src/airflow/utils/edgemodifier\.py$|
-          ^src/airflow/utils/email\.py$|
-          ^src/airflow/utils/helpers\.py$|
-          ^src/airflow/utils/operator_helpers\.py$|
-          ^src/airflow/utils/session\.py$|
-          ^src/airflow/utils/task_group\.py$|
-          ^src/airflow/utils/trigger_rule\.py$|
-          ^src/airflow/utils/json\.py$|
-          ^src/airflow/utils/types\.py$
         ## ONLY ADD PREK HOOKS HERE THAT REQUIRE CI IMAGE
       - id: check-schema-defaults
         name: Check schema defaults match server-side defaults
diff --git a/generated/known_sdk_imports_in_core.txt 
b/generated/known_sdk_imports_in_core.txt
new file mode 100644
index 00000000000..b93815ae6d9
--- /dev/null
+++ b/generated/known_sdk_imports_in_core.txt
@@ -0,0 +1,44 @@
+airflow-core/src/airflow/__init__.py::1
+airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_04_06.py::1
+airflow-core/src/airflow/cli/commands/task_command.py::7
+airflow-core/src/airflow/cli/commands/triggerer_command.py::1
+airflow-core/src/airflow/configuration.py::1
+airflow-core/src/airflow/dag_processing/dagbag.py::1
+airflow-core/src/airflow/dag_processing/importers/base.py::1
+airflow-core/src/airflow/dag_processing/importers/python_importer.py::7
+airflow-core/src/airflow/dag_processing/manager.py::4
+airflow-core/src/airflow/dag_processing/processor.py::15
+airflow-core/src/airflow/exceptions.py::1
+airflow-core/src/airflow/executors/base_executor.py::3
+airflow-core/src/airflow/jobs/triggerer_job_runner.py::18
+airflow-core/src/airflow/models/__init__.py::5
+airflow-core/src/airflow/models/asset.py::2
+airflow-core/src/airflow/models/connection.py::5
+airflow-core/src/airflow/models/dag.py::1
+airflow-core/src/airflow/models/dagrun.py::2
+airflow-core/src/airflow/models/taskinstance.py::1
+airflow-core/src/airflow/models/trigger.py::4
+airflow-core/src/airflow/models/variable.py::8
+airflow-core/src/airflow/models/xcom_arg.py::1
+airflow-core/src/airflow/plugins_manager.py::1
+airflow-core/src/airflow/providers_manager.py::5
+airflow-core/src/airflow/secrets/__init__.py::1
+airflow-core/src/airflow/serialization/decoders.py::2
+airflow-core/src/airflow/serialization/definitions/baseoperator.py::1
+airflow-core/src/airflow/serialization/definitions/dag.py::2
+airflow-core/src/airflow/serialization/definitions/deadline.py::1
+airflow-core/src/airflow/serialization/definitions/mappedoperator.py::5
+airflow-core/src/airflow/serialization/encoders.py::11
+airflow-core/src/airflow/serialization/serialized_objects.py::16
+airflow-core/src/airflow/settings.py::1
+airflow-core/src/airflow/stats.py::1
+airflow-core/src/airflow/timetables/simple.py::1
+airflow-core/src/airflow/triggers/base.py::3
+airflow-core/src/airflow/utils/cli.py::2
+airflow-core/src/airflow/utils/context.py::2
+airflow-core/src/airflow/utils/dag_cycle_tester.py::1
+airflow-core/src/airflow/utils/dag_edges.py::1
+airflow-core/src/airflow/utils/dot_renderer.py::3
+airflow-core/src/airflow/utils/edgemodifier.py::1
+airflow-core/src/airflow/utils/json.py::4
+airflow-core/src/airflow/utils/operator_helpers.py::2
diff --git a/scripts/ci/prek/check_sdk_imports_in_core.py 
b/scripts/ci/prek/check_sdk_imports_in_core.py
index 03192728930..aea9446305b 100755
--- a/scripts/ci/prek/check_sdk_imports_in_core.py
+++ b/scripts/ci/prek/check_sdk_imports_in_core.py
@@ -22,16 +22,64 @@
 #   "rich>=13.6.0",
 # ]
 # ///
+"""Check that no new ``airflow.sdk`` imports are introduced in 
``airflow-core``.
+
+All *existing* imports are recorded in 
``generated/known_sdk_imports_in_core.txt``
+as ``relative/path::N`` entries (one per file), where ``N`` is the maximum
+number of ``airflow.sdk`` import statements allowed in that file. A file whose
+current count exceeds the recorded limit is treated as a violation -- core
+should not gain new runtime dependencies on the Task SDK. Two escape hatches
+exist: ``# noqa: SDK001`` on a single import line, which keeps that line out of
+the count and out of the allowlist entirely, or raising the file's recorded
+count via ``--generate FILE`` when a file genuinely needs several.
+
+``airflow-core/src/airflow/example_dags`` is skipped entirely: those files are
+Dags authored with the Task SDK, not core runtime code.
+
+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
+    ``generated/known_sdk_imports_in_core.txt`` and re-run.
+
+``--all-files``:
+    Walk all of ``airflow-core/src/airflow`` and check every ``.py`` file.
+
+``--cleanup``:
+    Remove entries for files that no longer exist. Safe to run at any time;
+    does not add new entries or raise limits.
+
+``--generate [FILE ...]``:
+    With files, record the current counts for just those files and leave every
+    other entry alone -- the usual way to approve a deliberate new import.
+    With no files, *rebuild* the allowlist from scratch; that also approves any
+    unrelated drift, so it is meant for the initial setup or a clean-up sprint.
+"""
+
 from __future__ import annotations
 
 import argparse
-import sys
+import ast
+from collections.abc import Iterable
 from pathlib import Path
 
-from common_prek_utils import find_import_violations, report_import_violations
+from common_prek_utils import AIRFLOW_CORE_ROOT_PATH, AllowlistManager, 
find_import_violations
+from rich.console import Console
+
+console = Console(color_system="standard", width=200)
+
+REPO_ROOT = AIRFLOW_CORE_ROOT_PATH.parent
+CORE_SRC_ROOT = AIRFLOW_CORE_ROOT_PATH / "src" / "airflow"
 
 NOCHECK_CODE = "SDK001"
 
+# Dags are authored *with* the task SDK, so example Dags shipped in core are 
not core code
+# for the purpose of this check and never take part in the ratchet.
+EXCLUDED_DIR_NAMES = frozenset({"example_dags"})
+
 
 def check_file_for_sdk_imports(file_path: Path) -> list[tuple[int, str]]:
     """Check file for airflow.sdk imports. Returns list of (line_num, 
import_statement)."""
@@ -42,23 +90,153 @@ def check_file_for_sdk_imports(file_path: Path) -> 
list[tuple[int, str]]:
     )
 
 
-def main():
-    parser = argparse.ArgumentParser(description="Check for SDK imports in 
airflow-core files")
-    parser.add_argument("files", nargs="*", help="Files to check")
-    args = parser.parse_args()
+def _can_parse(path: Path) -> bool:
+    """Whether *path* is valid enough Python for its import count to be 
trustworthy.
 
-    if not args.files:
-        return
+    ``find_import_violations`` swallows parse failures and reports zero 
violations for
+    them, which is indistinguishable from a file that genuinely has none. 
Callers must
+    filter those files out *before* counting, so a file mid-edit with a syntax 
error
+    never looks like a legitimate drop in its allowlist entry.
+    """
+    try:
+        ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+    except (OSError, UnicodeDecodeError, SyntaxError):
+        return False
+    return True
 
-    report_import_violations(
-        args.files,
-        check_func=check_file_for_sdk_imports,
-        violation_label="SDK import(s) in core files",
-        nocheck_code=NOCHECK_CODE,
-        only_python_files=True,
+
+class SdkImportsAllowlistManager(AllowlistManager):
+    def __init__(self, allowlist_file: Path) -> None:
+        super().__init__(allowlist_file, repo_root=REPO_ROOT)
+
+    def is_excluded(self, path: Path) -> bool:
+        return not EXCLUDED_DIR_NAMES.isdisjoint(path.parts)
+
+    def iter_files(self) -> Iterable[Path]:
+        return (
+            path for path in CORE_SRC_ROOT.rglob("*.py") if not 
self.is_excluded(path) and _can_parse(path)
+        )
+
+    def check(self, files: list[Path], allowlist: dict[str, int]) -> int:
+        checkable = []
+        for path in files:
+            if self.is_excluded(path):
+                continue
+            if path.exists() and path.suffix == ".py" and not _can_parse(path):
+                console.print(
+                    f"[yellow]Skipping unparsable file (not counted, allowlist 
entry left "
+                    f"untouched):[/yellow] {path}"
+                )
+                continue
+            checkable.append(path)
+        return super().check(checkable, allowlist)
+
+    def count_occurrences(self, path: Path) -> int:
+        return len(check_file_for_sdk_imports(path))
+
+    def generate_for(self, files: list[Path]) -> int:
+        """Rewrite the allowlist entries for *files* only, leaving every other 
entry untouched."""
+        allowlist = self.load()
+        updated: list[tuple[str, int, int]] = []
+
+        for path in files:
+            if self.is_excluded(path) or not path.exists() or path.suffix != 
".py":
+                continue
+            if not _can_parse(path):
+                console.print(f"[yellow]Skipping unparsable file:[/yellow] 
{path}")
+                continue
+            try:
+                rel = str(path.resolve().relative_to(self.repo_root.resolve()))
+            except ValueError:
+                console.print(f"[yellow]Skipping file outside the 
repository:[/yellow] {path}")
+                continue
+            before = allowlist.get(rel, 0)
+            after = self.count_occurrences(path)
+            if before == after:
+                continue
+            if after:
+                allowlist[rel] = after
+            else:
+                del allowlist[rel]
+            updated.append((rel, before, after))
+
+        if not updated:
+            console.print("[green]Allowlist already up to date for the given 
files.[/green]")
+            return 0
+
+        self.save(allowlist)
+        console.print(
+            f"[green]Updated[/green] 
[cyan]{self.allowlist_file.relative_to(self.repo_root)}[/cyan]:"
+        )
+        for rel, before, after in updated:
+            console.print(f"  [cyan]{rel}[/cyan]  {before} -> {after}")
+        return 0
+
+    def violation_panel_text(self) -> str:
+        return (
+            "New [bold]airflow.sdk[/bold] import detected in airflow-core.\n"
+            "Core (scheduler/API server) should not gain new runtime 
dependencies "
+            "on the Task SDK.\n\n"
+            "The preferred fix is to drop the import. If it has to stay, pick 
one:\n\n"
+            "[bold]1. A single deliberate import line[/bold] -- append `# 
noqa: SDK001` to it.\n"
+            "   The line is then invisible to this check and never reaches the 
allowlist.\n\n"
+            "[bold]2. A file that genuinely needs several[/bold] -- record its 
new count:\n\n"
+            "  [cyan]uv run ./scripts/ci/prek/check_sdk_imports_in_core.py 
--generate FILE ...[/cyan]\n\n"
+            "   then commit the updated 
[cyan]generated/known_sdk_imports_in_core.txt[/cyan].\n"
+            "   Passing no FILE rebuilds the whole allowlist and approves any 
unrelated\n"
+            "   drift with it, so prefer the scoped form."
+        )
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        description="Prevent new airflow.sdk imports in airflow-core.",
+        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 airflow-core/src/airflow",
+    )
+    parser.add_argument(
+        "--cleanup",
+        action="store_true",
+        help="Remove stale entries from the allowlist and exit",
     )
+    parser.add_argument(
+        "--generate",
+        action="store_true",
+        help=(
+            "Record the current counts for the given FILEs and exit; "
+            "with no FILE, rebuild the whole allowlist from the current 
codebase"
+        ),
+    )
+    args = parser.parse_args(argv)
+
+    manager = SdkImportsAllowlistManager(REPO_ROOT / "generated" / 
"known_sdk_imports_in_core.txt")
+    files = [Path(f).resolve() for f in args.files]
+
+    if args.generate:
+        return manager.generate_for(files) if files else manager.generate()
+
+    if args.cleanup:
+        return manager.cleanup()
+
+    allowlist = manager.load()
+
+    if args.all_files:
+        return manager.check(list(manager.iter_files()), allowlist)
+
+    if not files:
+        console.print(
+            "[yellow]No files provided. Pass filenames or use --all-files to 
scan the whole repo.[/yellow]"
+        )
+        return 0
+
+    return manager.check(files, allowlist)
 
 
 if __name__ == "__main__":
-    main()
-    sys.exit(0)
+    raise SystemExit(main())
diff --git a/scripts/tests/ci/prek/test_check_sdk_imports_in_core.py 
b/scripts/tests/ci/prek/test_check_sdk_imports_in_core.py
index ed1bcbe057f..3a430c3c2c1 100644
--- a/scripts/tests/ci/prek/test_check_sdk_imports_in_core.py
+++ b/scripts/tests/ci/prek/test_check_sdk_imports_in_core.py
@@ -19,8 +19,23 @@ from __future__ import annotations
 import textwrap
 from pathlib import Path
 
+import check_sdk_imports_in_core as hook
 import pytest
-from check_sdk_imports_in_core import check_file_for_sdk_imports
+from check_sdk_imports_in_core import SdkImportsAllowlistManager, 
check_file_for_sdk_imports
+
+
[email protected]
+def create_fake_core_repo(tmp_path, monkeypatch):
+    monkeypatch.setattr(hook, "REPO_ROOT", tmp_path)
+    monkeypatch.setattr(hook, "CORE_SRC_ROOT", tmp_path / "airflow-core" / 
"src" / "airflow")
+
+    def _write(rel: str, code: str) -> Path:
+        path = tmp_path / "airflow-core" / "src" / "airflow" / rel
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text(textwrap.dedent(code))
+        return path
+
+    return _write
 
 
 class TestCheckFileForSdkImports:
@@ -178,3 +193,218 @@ class TestNocheckMarker:
         f = tmp_path / "example.py"
         f.write_text(code)
         assert check_file_for_sdk_imports(f) == expected
+
+
+class TestSdkImportsAllowlistRatchet:
+    def test_no_violations_passes(self, create_fake_core_repo, tmp_path):
+        path = create_fake_core_repo(
+            "models/clean.py",
+            """\
+            from airflow.models import DagRun
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        assert manager.check([path], {}) == 0
+
+    def test_new_violation_fails(self, create_fake_core_repo, tmp_path):
+        path = create_fake_core_repo(
+            "models/bad.py",
+            """\
+            from airflow.sdk import DAG
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        assert manager.check([path], {}) == 1
+
+    def test_violation_within_allowlist_passes(self, create_fake_core_repo, 
tmp_path):
+        path = create_fake_core_repo(
+            "models/grandfathered.py",
+            """\
+            from airflow.sdk import DAG
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        allowlist = {"airflow-core/src/airflow/models/grandfathered.py": 1}
+        assert manager.check([path], allowlist) == 0
+
+    def test_exceeding_allowlist_fails(self, create_fake_core_repo, tmp_path):
+        path = create_fake_core_repo(
+            "models/grew.py",
+            """\
+            from airflow.sdk import DAG
+            from airflow.sdk.definitions.deadline import VariableInterval
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        allowlist = {"airflow-core/src/airflow/models/grew.py": 1}
+        assert manager.check([path], allowlist) == 1
+
+    def test_reducing_violations_tightens_allowlist(self, 
create_fake_core_repo, tmp_path):
+        path = create_fake_core_repo(
+            "models/improved.py",
+            """\
+            from airflow.sdk import DAG
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        allowlist = {"airflow-core/src/airflow/models/improved.py": 2}
+        assert manager.check([path], allowlist) == 1
+        assert manager.load() == 
{"airflow-core/src/airflow/models/improved.py": 1}
+
+    def test_unparseable_file_does_not_tighten_allowlist(self, 
create_fake_core_repo, tmp_path):
+        """A syntax error must not look like a legitimate drop to zero imports.
+
+        ``find_import_violations`` reports zero violations for a file it 
cannot parse,
+        indistinguishable from a file that genuinely has none -- the allowlist 
entry must
+        be left alone rather than tightened or deleted.
+        """
+        path = create_fake_core_repo(
+            "models/broken.py",
+            """\
+            from airflow.sdk import DAG
+            def broken(
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        manager.save({"airflow-core/src/airflow/models/broken.py": 1})
+
+        assert manager.check([path], manager.load()) == 0
+        assert manager.load() == {"airflow-core/src/airflow/models/broken.py": 
1}
+
+    def test_fixing_all_violations_removes_entry(self, create_fake_core_repo, 
tmp_path):
+        path = create_fake_core_repo(
+            "models/fixed.py",
+            """\
+            from airflow.models import DagRun
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        allowlist = {"airflow-core/src/airflow/models/fixed.py": 1}
+        assert manager.check([path], allowlist) == 1
+        assert manager.load() == {}
+
+    def test_noqa_marker_avoids_ratchet_entirely(self, create_fake_core_repo, 
tmp_path):
+        path = create_fake_core_repo(
+            "models/one_off.py",
+            """\
+            from airflow.sdk import DAG  # noqa: SDK001
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        assert manager.check([path], {}) == 0
+
+    def test_example_dags_are_not_checked(self, create_fake_core_repo, 
tmp_path):
+        path = create_fake_core_repo(
+            "example_dags/example_simplest_dag.py",
+            """\
+            from airflow.sdk import DAG
+            from airflow.sdk import task
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        assert manager.check([path], {}) == 0
+
+    def test_non_python_file_is_skipped(self, create_fake_core_repo, tmp_path):
+        path = create_fake_core_repo(
+            "models/not_python.txt",
+            "from airflow.sdk import DAG\n",
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        assert manager.check([path], {}) == 0
+
+
+class TestSdkImportsAllowlistCleanup:
+    def test_cleanup_removes_stale_entries(self, create_fake_core_repo, 
tmp_path):
+        create_fake_core_repo("models/keeper.py", "from airflow.models import 
DagRun\n")
+        allowlist_path = tmp_path / "allowlist.txt"
+        manager = SdkImportsAllowlistManager(allowlist_path)
+        manager.save(
+            {
+                "airflow-core/src/airflow/models/keeper.py": 0,
+                "airflow-core/src/airflow/models/gone.py": 1,
+            }
+        )
+        assert manager.cleanup() == 0
+        assert manager.load() == {"airflow-core/src/airflow/models/keeper.py": 
0}
+
+    def test_cleanup_empty_allowlist(self, tmp_path):
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        assert manager.cleanup() == 0
+
+
+class TestSdkImportsAllowlistGenerate:
+    def test_generate_records_current_occurrences(self, create_fake_core_repo, 
tmp_path):
+        create_fake_core_repo("models/a.py", "from airflow.sdk import DAG\n")
+        create_fake_core_repo("models/b.py", "from airflow.models import 
DagRun\n")
+        allowlist_path = tmp_path / "allowlist.txt"
+        manager = SdkImportsAllowlistManager(allowlist_path)
+        assert manager.generate() == 0
+        assert manager.load() == {"airflow-core/src/airflow/models/a.py": 1}
+
+    def test_main_generate_with_files_is_scoped(self, create_fake_core_repo, 
tmp_path):
+        path = create_fake_core_repo("models/a.py", "from airflow.sdk import 
DAG\n")
+        (tmp_path / "generated").mkdir()
+        allowlist_path = tmp_path / "generated" / 
"known_sdk_imports_in_core.txt"
+        
allowlist_path.write_text("airflow-core/src/airflow/models/other.py::2\n")
+
+        assert hook.main(["--generate", str(path)]) == 0
+        assert allowlist_path.read_text().splitlines() == [
+            "airflow-core/src/airflow/models/a.py::1",
+            "airflow-core/src/airflow/models/other.py::2",
+        ]
+
+    def test_generate_for_leaves_other_entries_untouched(self, 
create_fake_core_repo, tmp_path):
+        target = create_fake_core_repo(
+            "models/target.py",
+            """\
+            from airflow.sdk import DAG
+            from airflow.sdk import task
+            """,
+        )
+        create_fake_core_repo("models/drifted.py", "from airflow.sdk import 
DAG\n")
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        manager.save({"airflow-core/src/airflow/models/target.py": 1})
+
+        assert manager.generate_for([target]) == 0
+        assert manager.load() == {"airflow-core/src/airflow/models/target.py": 
2}
+
+    def test_generate_for_drops_entry_when_file_is_clean(self, 
create_fake_core_repo, tmp_path):
+        path = create_fake_core_repo("models/fixed.py", "from airflow.models 
import DagRun\n")
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        manager.save(
+            {
+                "airflow-core/src/airflow/models/fixed.py": 1,
+                "airflow-core/src/airflow/models/other.py": 3,
+            }
+        )
+
+        assert manager.generate_for([path]) == 0
+        assert manager.load() == {"airflow-core/src/airflow/models/other.py": 
3}
+
+    def test_generate_for_leaves_unparseable_file_untouched(self, 
create_fake_core_repo, tmp_path):
+        path = create_fake_core_repo(
+            "models/broken.py",
+            """\
+            from airflow.sdk import DAG
+            def broken(
+            """,
+        )
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        manager.save({"airflow-core/src/airflow/models/broken.py": 1})
+
+        assert manager.generate_for([path]) == 0
+        assert manager.load() == {"airflow-core/src/airflow/models/broken.py": 
1}
+
+    def test_generate_for_skips_example_dags(self, create_fake_core_repo, 
tmp_path):
+        path = create_fake_core_repo("example_dags/tutorial.py", "from 
airflow.sdk import DAG\n")
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+
+        assert manager.generate_for([path]) == 0
+        assert manager.load() == {}
+
+    def test_generate_skips_example_dags(self, create_fake_core_repo, 
tmp_path):
+        create_fake_core_repo("models/a.py", "from airflow.sdk import DAG\n")
+        create_fake_core_repo("example_dags/tutorial.py", "from airflow.sdk 
import DAG\n")
+        manager = SdkImportsAllowlistManager(tmp_path / "allowlist.txt")
+        assert manager.generate() == 0
+        assert manager.load() == {"airflow-core/src/airflow/models/a.py": 1}

Reply via email to