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

kaxil 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 1787ef7cbba Run the deferred AWS hook configuration check as a prek 
hook (#73681)
1787ef7cbba is described below

commit 1787ef7cbbab44e2df726387614ecb916e03d797
Author: Sepuri Sai Krishna <[email protected]>
AuthorDate: Fri Oct 2 04:29:34 2026 +0530

    Run the deferred AWS hook configuration check as a prek hook (#73681)
    
    The check is a static sweep over the provider source, but it lived in the 
test
    suite, so it only ran where the amazon provider and its optional peers were
    installed. A missing optional dependency turned it into a collection error
    rather than a result, and a contributor editing a call site only learned 
about a
    dropped parameter once the suite ran.
    
    Keying the hand-built hook exceptions by path rather than by basename 
closes a
    collision that would silently widen an exception the day two trigger modules
    share a name.
    
    Follow-up to apache/airflow#72171, where moving this was agreed in review.
---
 providers/.pre-commit-config.yaml                  |   7 +
 .../amazon/aws/test_deferred_hook_configuration.py | 242 +------------
 .../ci/prek/check_deferred_hook_configuration.py   | 294 ++++++++++++++++
 .../prek/test_check_deferred_hook_configuration.py | 373 +++++++++++++++++++++
 4 files changed, 688 insertions(+), 228 deletions(-)

diff --git a/providers/.pre-commit-config.yaml 
b/providers/.pre-commit-config.yaml
index 66ef2b5be0b..ae234bd20a3 100644
--- a/providers/.pre-commit-config.yaml
+++ b/providers/.pre-commit-config.yaml
@@ -37,6 +37,13 @@ repos:
         entry: ../scripts/ci/prek/check_trigger_serialize_init.py
         pass_filenames: true
         files: ^.*/src/airflow/providers/.*/triggers/[^/]+\.py$
+      - id: check-deferred-hook-configuration
+        name: Check deferred AWS tasks pass their hook configuration
+        description: An operator that defers must hand region_name, verify and 
botocore_config to its trigger
+        language: python
+        entry: ../scripts/ci/prek/check_deferred_hook_configuration.py
+        pass_filenames: false
+        files: ^amazon/src/airflow/providers/amazon/aws/.*\.py$
       - id: check-migration-patterns
         name: Check migration files for anti-patterns and style (MIG001-MIG004)
         entry: ../scripts/ci/prek/check_migration_patterns.py
diff --git 
a/providers/amazon/tests/unit/amazon/aws/test_deferred_hook_configuration.py 
b/providers/amazon/tests/unit/amazon/aws/test_deferred_hook_configuration.py
index c3b8dd6da66..1c92a1f9ab7 100644
--- a/providers/amazon/tests/unit/amazon/aws/test_deferred_hook_configuration.py
+++ b/providers/amazon/tests/unit/amazon/aws/test_deferred_hook_configuration.py
@@ -15,192 +15,35 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+"""
+The half of the deferred hook configuration invariant that needs the classes 
themselves.
+
+The static half -- that every ``self.defer()`` hands its hook configuration to 
the trigger -- is a
+prek hook, ``scripts/ci/prek/check_deferred_hook_configuration.py``, because 
it only needs to parse
+the provider. This one has to import it, so it stays a test.
+"""
+
 from __future__ import annotations
 
-import ast
 import importlib
 import inspect
 import pkgutil
-from collections.abc import Iterator
-from pathlib import Path
 
 import pytest
 
-import airflow.providers.amazon.aws as aws_module
 import airflow.providers.amazon.aws.triggers as triggers_module
 from airflow.providers.amazon.aws.triggers.base import AwsBaseWaiterTrigger
 
-AWS_ROOT = Path(inspect.getfile(aws_module)).parent
-HOOK_CONFIGURATION = ("region_name", "verify", "botocore_config")
-
-# A deferrable task builds its hook twice: once in the worker, once in the 
triggerer. Unless the
-# operator hands its hook configuration to the trigger, the triggerer silently 
falls back to boto3
-# defaults -- a different region, different SSL verification, different 
timeouts.
-UNCONFIGURABLE_TRIGGERS = frozenset(
-    {
-        # Not an AwsBaseWaiterTrigger: its hook is addressed by execution 
name, and takes no
-        # connection parameters at all.
-        "SageMakerNotebookJobTrigger",
-        # A KubernetesPodTrigger; it reaches the pod through a kubeconfig, not 
a boto3 client.
-        "EksPodTrigger",
-    }
-)
-
-# Sites whose trigger is built elsewhere and only referenced here, so the 
class cannot be read off
-# the call. Kept explicit so that a new unreadable site fails the suite 
instead of being skipped.
-UNREADABLE_DEFER_SITES = frozenset({("operators/eks.py", "trigger")})
-
-# Services carved out as Contributors Workshop tasks, so their triggers are 
still unmigrated. Each
-# entry is one self-contained contribution: widen the trigger's __init__, set 
aws_hook_class, pass
-# the parameters at the call site, then delete the entry here. The test 
asserts an entry is still
-# needed, so the allowlist cannot outlive the work it tracks.
-PENDING_MIGRATION = frozenset(
-    {
-        ("sensors/batch.py", "BatchJobTrigger"),
-    }
-)
-
-
-def read_trigger_name(call: ast.Call) -> str | None:
-    """The trigger class a construction names, or ``None`` when the callee 
cannot be read."""
-    if isinstance(call.func, ast.Name):
-        return call.func.id
-    if isinstance(call.func, ast.Attribute):
-        return call.func.attr
-    return None
-
-
-def trigger_constructions(expr: ast.expr) -> list[tuple[ast.Call, str]] | None:
-    """
-    Resolve a ``trigger=`` expression to the constructions it can evaluate to, 
each with its name.
-
-    ``None`` means the expression cannot be read statically. Returning that 
rather than an empty
-    list is what keeps a site from disappearing: a bare reference, a 
subscript, or a conditional
-    with one unreadable branch all have to be acknowledged in 
``UNREADABLE_DEFER_SITES`` instead of
-    quietly contributing nothing to the sweep.
-    """
-    if isinstance(expr, ast.Call):
-        # A construction whose callee cannot be named is no more readable than 
a bare reference:
-        # the allowlists key on the class name, so an unnamed one could never 
match them.
-        name = read_trigger_name(expr)
-        return [(expr, name)] if name is not None else None
-    if isinstance(expr, ast.IfExp):
-        constructions: list[tuple[ast.Call, str]] = []
-        for branch in (expr.body, expr.orelse):
-            resolved = trigger_constructions(branch)
-            if resolved is None:
-                return None
-            constructions.extend(resolved)
-        return constructions
-    return None
-
-
-def walk_defer_sites() -> Iterator[tuple[Path, ast.expr]]:
-    """Yield the ``trigger=`` expression of every ``self.defer(...)`` in the 
provider."""
-    # Every file, not just operators/ and sensors/: ``defer`` is a 
BaseOperator method, so a site
-    # can appear anywhere, and a directory filter would drop a nested 
subpackage without saying so.
-    for path in sorted(AWS_ROOT.rglob("*.py")):
-        for node in ast.walk(ast.parse(path.read_text())):
-            if not isinstance(node, ast.Call):
-                continue
-            func = node.func
-            if not (
-                isinstance(func, ast.Attribute)
-                and func.attr == "defer"
-                and isinstance(func.value, ast.Name)
-                and func.value.id == "self"
-            ):
-                continue
-            trigger = next((kw.value for kw in node.keywords if kw.arg == 
"trigger"), None)
-            if trigger is not None:
-                yield path, trigger
-
-
-def find_defer_sites() -> list[tuple[str, int, str, list[str]]]:
-    """Collect every ``self.defer(trigger=SomeTrigger(...))`` in the 
provider."""
-    sites: list[tuple[str, int, str, list[str]]] = []
-    for path, trigger in walk_defer_sites():
-        # The trigger may be built inline, or picked between in a conditional 
expression, so take
-        # every construction the expression can yield rather than assuming a 
single call.
-        for call, name in trigger_constructions(trigger) or ():
-            if name in UNCONFIGURABLE_TRIGGERS:
-                continue
-            passed = {kw.arg for kw in call.keywords if kw.arg}
-            sites.append(
-                (
-                    path.relative_to(AWS_ROOT).as_posix(),
-                    call.lineno,
-                    name,
-                    [p for p in HOOK_CONFIGURATION if p not in passed],
-                )
-            )
-    return sites
-
-
-def find_unreadable_defer_sites() -> set[tuple[str, str]]:
-    """Defer sites whose trigger expression cannot be resolved to the 
constructions it yields."""
-    return {
-        (path.relative_to(AWS_ROOT).as_posix(), ast.unparse(trigger))
-        for path, trigger in walk_defer_sites()
-        if trigger_constructions(trigger) is None
-    }
-
-
-DEFER_SITES = find_defer_sites()
-
-
-def test_defer_sites_are_discovered():
-    assert DEFER_SITES, f"no self.defer(trigger=...) calls found under 
{AWS_ROOT}"
-
-
-def test_no_defer_site_escapes_the_check():
-    """A defer site whose trigger cannot be read statically must be 
acknowledged, not skipped."""
-    assert find_unreadable_defer_sites() == UNREADABLE_DEFER_SITES
-
-
[email protected](
-    ("expression", "expected"),
-    [
-        pytest.param("SomeTrigger(x=1)", 1, id="call"),
-        pytest.param("A() if flag else B()", 2, 
id="conditional-both-readable"),
-        pytest.param("trigger", None, id="bare-name"),
-        pytest.param("self._trigger", None, id="attribute"),
-        pytest.param("triggers[kind]", None, id="subscript"),
-        pytest.param("A() if flag else self._trigger", None, 
id="conditional-one-unreadable"),
-        pytest.param("TRIGGERS[kind](x=1)", None, id="unnameable-callee"),
-        pytest.param("module.SomeTrigger(x=1)", 1, 
id="module-qualified-callee"),
-    ],
-)
-def test_unreadable_trigger_expressions_resolve_to_none(expression, expected):
-    """Anything the sweep cannot resolve must report None so the site is 
forced onto the allowlist."""
-    constructions = trigger_constructions(ast.parse(expression, 
mode="eval").body)
-
-    assert (constructions if constructions is None else len(constructions)) == 
expected
-
-
[email protected](
-    ("source", "line", "trigger", "missing"),
-    DEFER_SITES,
-    ids=[f"{source}:{line}" for source, line, _, _ in DEFER_SITES],
-)
-def test_deferred_trigger_receives_hook_configuration(source, line, trigger, 
missing):
-    if (source, trigger) in PENDING_MIGRATION:
-        assert missing, (
-            f"{source}:{line} now passes its hook configuration to {trigger}. "
-            f"Drop it from PENDING_MIGRATION so the site stays covered."
-        )
-        pytest.skip(f"{source} is a Contributors Workshop task; see 
PENDING_MIGRATION")
-
-    assert not missing, (
-        f"{source}:{line} defers to {trigger} without passing {', 
'.join(missing)}. "
-        f"The triggerer builds its own hook, so anything not passed here is 
lost."
-    )
-
 
 def find_waiter_triggers() -> list[type[AwsBaseWaiterTrigger]]:
     """Import every trigger module, then walk the subclass tree."""
     for module in pkgutil.iter_modules(triggers_module.__path__):
-        importlib.import_module(f"{triggers_module.__name__}.{module.name}")
+        try:
+            
importlib.import_module(f"{triggers_module.__name__}.{module.name}")
+        except ImportError:
+            # triggers/eks.py reaches into cncf.kubernetes. Installing amazon 
without its optional
+            # peers is supported, and must not turn collection of this module 
into an error.
+            continue
 
     found: set[type[AwsBaseWaiterTrigger]] = set()
     pending = [AwsBaseWaiterTrigger]
@@ -224,60 +67,3 @@ def test_waiter_trigger_can_build_a_hook(trigger_class):
     inspect.signature(trigger_class.aws_hook_class).bind_partial(
         aws_conn_id=None, region_name=None, verify=None, config=None
     )
-
-
-# A trigger may build a second hook by hand for a side channel -- streaming 
CloudWatch logs, most
-# often -- alongside the one ``aws_hook_class`` gives it. That hook talks to 
AWS too, so it needs
-# the same configuration; a trigger whose job client verifies TLS while its 
log client does not is
-# the same bug in miniature.
-HAND_BUILT_HOOK_EXCEPTIONS = frozenset(
-    {
-        # Addressed by execution name; takes no connection parameters at all.
-        ("sagemaker_unified_studio.py", "SageMakerNotebookHook"),
-        # EksPodOperator is a KubernetesPodOperator: it carries no verify or 
botocore_config to pass.
-        ("eks.py", "EksHook"),
-    }
-)
-
-
-def find_hand_built_hooks() -> list[tuple[str, int, str, list[str]]]:
-    """Collect every hook constructed directly inside a trigger module."""
-    sites = []
-    for path in sorted((AWS_ROOT / "triggers").rglob("*.py")):
-        for node in ast.walk(ast.parse(path.read_text())):
-            if not isinstance(node, ast.Call):
-                continue
-            # Match a module-qualified ``module.SomeHook(...)`` as well as a 
bare name.
-            hook = node.func.attr if isinstance(node.func, ast.Attribute) else 
getattr(node.func, "id", "")
-            if not hook.endswith("Hook"):
-                continue
-            passed = {keyword.arg for keyword in node.keywords if keyword.arg}
-            # AwsGenericHook names the botocore config ``config``.
-            if "config" in passed:
-                passed.add("botocore_config")
-            missing = [name for name in HOOK_CONFIGURATION if name not in 
passed]
-            sites.append((path.name, node.lineno, hook, missing))
-    return sites
-
-
-HAND_BUILT_HOOKS = find_hand_built_hooks()
-
-
[email protected](
-    ("source", "line", "hook", "missing"),
-    HAND_BUILT_HOOKS,
-    ids=[f"{source}:{line}" for source, line, _, _ in HAND_BUILT_HOOKS],
-)
-def test_hand_built_trigger_hook_receives_configuration(source, line, hook, 
missing):
-    """A hook a trigger builds itself must carry the same configuration as its 
main hook."""
-    if (source, hook) in HAND_BUILT_HOOK_EXCEPTIONS:
-        assert missing, (
-            f"{source}:{line} now configures {hook}. "
-            f"Drop it from HAND_BUILT_HOOK_EXCEPTIONS so the site stays 
covered."
-        )
-        pytest.skip(f"{source} builds {hook} with nothing to configure")
-
-    assert not missing, (
-        f"{source}:{line} builds {hook} without {', '.join(missing)}. "
-        f"It reaches AWS with boto3 defaults while the trigger's own hook does 
not."
-    )
diff --git a/scripts/ci/prek/check_deferred_hook_configuration.py 
b/scripts/ci/prek/check_deferred_hook_configuration.py
new file mode 100755
index 00000000000..ef28acc2e39
--- /dev/null
+++ b/scripts/ci/prek/check_deferred_hook_configuration.py
@@ -0,0 +1,294 @@
+#!/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,<3.11"
+# dependencies = [
+#   "rich>=13.6.0",
+# ]
+# ///
+"""
+Check that deferrable Amazon tasks hand their hook configuration to the 
trigger they defer to.
+
+A deferrable task builds its hook twice: once in the worker, once in the 
triggerer. The triggerer
+half is built from the arguments the operator passed at the ``self.defer()`` 
call site, so anything
+not passed there is lost, and the deferred half silently reaches AWS with 
boto3 defaults: a
+different region, different TLS verification, different timeouts.
+
+Two shapes are checked, both by parsing the provider with ``ast``:
+
+1. Every ``self.defer(trigger=SomeTrigger(...))`` passes ``region_name``, 
``verify`` and
+   ``botocore_config``.
+2. Every hook a trigger module builds by hand, alongside the one 
``aws_hook_class`` gives it,
+   carries the same configuration. A trigger whose job client verifies TLS 
while its log client
+   does not is the same bug in miniature.
+
+A ``trigger=`` expression that cannot be resolved statically is never skipped 
silently: it has to
+be acknowledged in ``UNREADABLE_DEFER_SITES`` instead. The allowlists below 
are checked in both
+directions, so an entry that is no longer needed fails the check and has to be 
removed.
+"""
+
+from __future__ import annotations
+
+import ast
+import sys
+from collections.abc import Iterator
+from pathlib import Path
+
+from common_prek_utils import AIRFLOW_PROVIDERS_ROOT_PATH, console
+
+AWS_ROOT = AIRFLOW_PROVIDERS_ROOT_PATH / "amazon" / "src" / "airflow" / 
"providers" / "amazon" / "aws"
+
+HOOK_CONFIGURATION = ("region_name", "verify", "botocore_config")
+
+# Triggers that take no boto3 client configuration at all, so there is nothing 
to hand them.
+UNCONFIGURABLE_TRIGGERS = frozenset(
+    {
+        # Not an AwsBaseWaiterTrigger: its hook is addressed by execution name.
+        "SageMakerNotebookJobTrigger",
+        # A KubernetesPodTrigger; it reaches the pod through a kubeconfig, not 
a boto3 client.
+        "EksPodTrigger",
+    }
+)
+
+# Sites whose trigger is built elsewhere and only referenced at the call, so 
the class cannot be
+# read off it.
+UNREADABLE_DEFER_SITES = frozenset({("operators/eks.py", "trigger")})
+
+# Services carved out as Contributors Workshop tasks, so their triggers are 
still unmigrated. Each
+# entry is one self-contained contribution: widen the trigger's __init__, set 
aws_hook_class, pass
+# the parameters at the call site, then delete the entry here.
+PENDING_MIGRATION = frozenset(
+    {
+        ("sensors/batch.py", "BatchJobTrigger"),
+    }
+)
+
+# Hand-built hooks that take no connection parameters, keyed by path relative 
to the aws package so
+# that two trigger modules sharing a basename cannot share an entry.
+HAND_BUILT_HOOK_EXCEPTIONS = frozenset(
+    {
+        # Addressed by execution name; takes no connection parameters at all.
+        ("triggers/sagemaker_unified_studio.py", "SageMakerNotebookHook"),
+        # EksPodOperator is a KubernetesPodOperator: it carries no verify or 
botocore_config to pass.
+        ("triggers/eks.py", "EksHook"),
+    }
+)
+
+
+def read_trigger_name(call: ast.Call) -> str | None:
+    """Return the trigger class a construction names, or ``None`` when the 
callee cannot be read."""
+    if isinstance(call.func, ast.Name):
+        return call.func.id
+    if isinstance(call.func, ast.Attribute):
+        return call.func.attr
+    return None
+
+
+def resolve_trigger_constructions(expr: ast.expr) -> list[tuple[ast.Call, 
str]] | None:
+    """
+    Resolve a ``trigger=`` expression to the constructions it can evaluate to, 
each with its name.
+
+    ``None`` means the expression cannot be read statically. Returning that 
rather than an empty
+    list is what keeps a site from disappearing: a bare reference, a 
subscript, or a conditional
+    with one unreadable branch all have to be acknowledged in 
``UNREADABLE_DEFER_SITES`` instead of
+    quietly contributing nothing to the sweep.
+    """
+    if isinstance(expr, ast.Call):
+        # A construction whose callee cannot be named is no more readable than 
a bare reference:
+        # the allowlists key on the class name, so an unnamed one could never 
match them.
+        name = read_trigger_name(expr)
+        return [(expr, name)] if name is not None else None
+    if isinstance(expr, ast.IfExp):
+        constructions: list[tuple[ast.Call, str]] = []
+        for branch in (expr.body, expr.orelse):
+            resolved = resolve_trigger_constructions(branch)
+            if resolved is None:
+                return None
+            constructions.extend(resolved)
+        return constructions
+    return None
+
+
+def read_module(path: Path) -> ast.Module | None:
+    """Parse *path*, or return ``None`` when it cannot be parsed."""
+    try:
+        return ast.parse(path.read_text())
+    except SyntaxError:
+        return None
+
+
+def find_unparseable_modules(root: Path) -> list[str]:
+    """Return the modules the sweep could not parse, so they are reported 
rather than skipped."""
+    return [
+        path.relative_to(root).as_posix() for path in 
sorted(root.rglob("*.py")) if read_module(path) is None
+    ]
+
+
+def walk_defer_sites(root: Path) -> Iterator[tuple[Path, ast.expr]]:
+    """Yield the ``trigger=`` expression of every ``self.defer(...)`` under 
*root*."""
+    # Every file, not just operators/ and sensors/: ``defer`` is a 
BaseOperator method, so a site
+    # can appear anywhere, and a directory filter would drop a nested 
subpackage without saying so.
+    for path in sorted(root.rglob("*.py")):
+        tree = read_module(path)
+        if tree is None:
+            continue
+        for node in ast.walk(tree):
+            if not isinstance(node, ast.Call):
+                continue
+            func = node.func
+            if not (
+                isinstance(func, ast.Attribute)
+                and func.attr == "defer"
+                and isinstance(func.value, ast.Name)
+                and func.value.id == "self"
+            ):
+                continue
+            trigger = next((kw.value for kw in node.keywords if kw.arg == 
"trigger"), None)
+            if trigger is not None:
+                yield path, trigger
+
+
+def find_defer_sites(root: Path) -> list[tuple[str, int, str, list[str]]]:
+    """Collect every ``self.defer(trigger=SomeTrigger(...))`` under *root*."""
+    sites: list[tuple[str, int, str, list[str]]] = []
+    for path, trigger in walk_defer_sites(root):
+        # The trigger may be built inline, or picked between in a conditional 
expression, so take
+        # every construction the expression can yield rather than assuming a 
single call.
+        for call, name in resolve_trigger_constructions(trigger) or ():
+            if name in UNCONFIGURABLE_TRIGGERS:
+                continue
+            passed = {kw.arg for kw in call.keywords if kw.arg}
+            sites.append(
+                (
+                    path.relative_to(root).as_posix(),
+                    call.lineno,
+                    name,
+                    [parameter for parameter in HOOK_CONFIGURATION if 
parameter not in passed],
+                )
+            )
+    return sites
+
+
+def find_unreadable_defer_sites(root: Path) -> set[tuple[str, str]]:
+    """Return defer sites whose trigger expression cannot be resolved to the 
constructions it yields."""
+    return {
+        (path.relative_to(root).as_posix(), ast.unparse(trigger))
+        for path, trigger in walk_defer_sites(root)
+        if resolve_trigger_constructions(trigger) is None
+    }
+
+
+def find_hand_built_hooks(root: Path) -> list[tuple[str, int, str, list[str]]]:
+    """Collect every hook constructed directly inside a trigger module under 
*root*."""
+    sites: list[tuple[str, int, str, list[str]]] = []
+    for path in sorted((root / "triggers").rglob("*.py")):
+        tree = read_module(path)
+        if tree is None:
+            continue
+        for node in ast.walk(tree):
+            if not isinstance(node, ast.Call):
+                continue
+            # Match a module-qualified ``module.SomeHook(...)`` as well as a 
bare name.
+            hook = node.func.attr if isinstance(node.func, ast.Attribute) else 
getattr(node.func, "id", "")
+            if not hook.endswith("Hook"):
+                continue
+            passed = {keyword.arg for keyword in node.keywords if keyword.arg}
+            # AwsGenericHook names the botocore config ``config``.
+            if "config" in passed:
+                passed.add("botocore_config")
+            sites.append(
+                (
+                    path.relative_to(root).as_posix(),
+                    node.lineno,
+                    hook,
+                    [parameter for parameter in HOOK_CONFIGURATION if 
parameter not in passed],
+                )
+            )
+    return sites
+
+
+def collect_errors(root: Path) -> list[str]:
+    """Return one message per violation, empty when the provider is clean."""
+    errors: list[str] = []
+
+    for source in find_unparseable_modules(root):
+        errors.append(f"{source} could not be parsed, so the check cannot see 
what it defers to.")
+
+    defer_sites = find_defer_sites(root)
+    if not defer_sites:
+        errors.append(f"No self.defer(trigger=...) calls found under {root}; 
the check is not looking at it.")
+
+    unreadable = find_unreadable_defer_sites(root)
+    for source, expression in sorted(unreadable - UNREADABLE_DEFER_SITES):
+        errors.append(
+            f"{source} defers to '{expression}', which cannot be read 
statically. "
+            f"Add it to UNREADABLE_DEFER_SITES so the site is acknowledged 
rather than skipped."
+        )
+    for source, expression in sorted(UNREADABLE_DEFER_SITES - unreadable):
+        errors.append(
+            f"{source} no longer defers to the unreadable '{expression}'. "
+            f"Drop it from UNREADABLE_DEFER_SITES."
+        )
+
+    for source, line, trigger, missing in defer_sites:
+        if (source, trigger) in PENDING_MIGRATION:
+            if not missing:
+                errors.append(
+                    f"{source}:{line} now passes its hook configuration to 
{trigger}. "
+                    f"Drop it from PENDING_MIGRATION so the site stays 
covered."
+                )
+            continue
+        if missing:
+            errors.append(
+                f"{source}:{line} defers to {trigger} without passing {', 
'.join(missing)}. "
+                f"The triggerer builds its own hook, so anything not passed 
here is lost."
+            )
+
+    for source, line, hook, missing in find_hand_built_hooks(root):
+        if (source, hook) in HAND_BUILT_HOOK_EXCEPTIONS:
+            if not missing:
+                errors.append(
+                    f"{source}:{line} now configures {hook}. "
+                    f"Drop it from HAND_BUILT_HOOK_EXCEPTIONS so the site 
stays covered."
+                )
+            continue
+        if missing:
+            errors.append(
+                f"{source}:{line} builds {hook} without {', '.join(missing)}. "
+                f"It reaches AWS with boto3 defaults while the trigger's own 
hook does not."
+            )
+
+    return errors
+
+
+def main() -> int:
+    errors = collect_errors(AWS_ROOT)
+    for error in errors:
+        console.print(f"[red]{error}[/]")
+    if errors:
+        console.print(
+            f"\n[red]Found {len(errors)} deferred hook configuration 
problem(s).[/]\n"
+            "A deferrable task builds its hook again in the triggerer, from 
what the operator "
+            "passed to the trigger. Anything not passed there is lost.\n"
+        )
+    return 1 if errors else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/tests/ci/prek/test_check_deferred_hook_configuration.py 
b/scripts/tests/ci/prek/test_check_deferred_hook_configuration.py
new file mode 100644
index 00000000000..89e7fc90385
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_deferred_hook_configuration.py
@@ -0,0 +1,373 @@
+# 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 ast
+import textwrap
+from pathlib import Path
+
+import pytest
+from check_deferred_hook_configuration import (
+    collect_errors,
+    find_defer_sites,
+    find_hand_built_hooks,
+    find_unparseable_modules,
+    find_unreadable_defer_sites,
+    resolve_trigger_constructions,
+)
+
+CONFIGURED = "region_name=self.region_name, verify=self.verify, 
botocore_config=self.botocore_config"
+
+
[email protected]
+def aws_tree(tmp_path):
+    """Factory fixture: write modules into an aws-package-shaped tree and 
return its root."""
+
+    def _write(modules: dict[str, str]) -> Path:
+        root = tmp_path / "aws"
+        root.mkdir(parents=True, exist_ok=True)
+        for relative_path, source in modules.items():
+            path = root / relative_path
+            path.parent.mkdir(parents=True, exist_ok=True)
+            path.write_text(textwrap.dedent(source))
+        return root
+
+    return _write
+
+
[email protected](autouse=True)
+def empty_allowlists(monkeypatch):
+    """Start every case from empty allowlists; the real entries describe the 
real tree, not a fixture."""
+    for name in ("UNREADABLE_DEFER_SITES", "PENDING_MIGRATION", 
"HAND_BUILT_HOOK_EXCEPTIONS"):
+        monkeypatch.setattr(f"check_deferred_hook_configuration.{name}", 
frozenset())
+
+
[email protected](
+    ("expression", "expected"),
+    [
+        pytest.param("SomeTrigger(x=1)", 1, id="call"),
+        pytest.param("A() if flag else B()", 2, 
id="conditional-both-readable"),
+        pytest.param("trigger", None, id="bare-name"),
+        pytest.param("self._trigger", None, id="attribute"),
+        pytest.param("triggers[kind]", None, id="subscript"),
+        pytest.param("A() if flag else self._trigger", None, 
id="conditional-one-unreadable"),
+        pytest.param("TRIGGERS[kind](x=1)", None, id="unnameable-callee"),
+        pytest.param("module.SomeTrigger(x=1)", 1, 
id="module-qualified-callee"),
+    ],
+)
+def test_unreadable_trigger_expressions_resolve_to_none(expression, expected):
+    """Anything the sweep cannot resolve must report None so the site is 
forced onto the allowlist."""
+    constructions = resolve_trigger_constructions(ast.parse(expression, 
mode="eval").body)
+
+    assert (constructions if constructions is None else len(constructions)) == 
expected
+
+
+def test_find_defer_sites_reports_the_parameters_not_passed(aws_tree):
+    root = aws_tree(
+        {
+            "operators/glue.py": """
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger(job_name=self.job_name, 
region_name=self.region_name))
+        """,
+        }
+    )
+
+    assert find_defer_sites(root) == [("operators/glue.py", 4, "GlueTrigger", 
["verify", "botocore_config"])]
+
+
+def test_find_defer_sites_is_satisfied_by_a_fully_configured_site(aws_tree):
+    root = aws_tree(
+        {
+            "operators/glue.py": f"""
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger({CONFIGURED}))
+        """,
+        }
+    )
+
+    assert find_defer_sites(root) == [("operators/glue.py", 4, "GlueTrigger", 
[])]
+
+
+def test_find_defer_sites_covers_both_branches_of_a_conditional(aws_tree):
+    root = aws_tree(
+        {
+            "operators/emr.py": f"""
+        class O:
+            def execute(self, context):
+                self.defer(trigger=A({CONFIGURED}) if self.x else 
B(waiter_delay=1))
+        """,
+        }
+    )
+
+    assert [(name, missing) for _, _, name, missing in find_defer_sites(root)] 
== [
+        ("A", []),
+        ("B", ["region_name", "verify", "botocore_config"]),
+    ]
+
+
+def test_find_defer_sites_skips_triggers_that_take_no_configuration(aws_tree):
+    root = aws_tree(
+        {
+            "operators/eks.py": """
+        class O:
+            def execute(self, context):
+                self.defer(trigger=EksPodTrigger(pod_name=self.pod_name))
+        """,
+        }
+    )
+
+    assert find_defer_sites(root) == []
+
+
+def test_walk_finds_defer_sites_outside_operators_and_sensors(aws_tree):
+    """``defer`` is a BaseOperator method, so a nested subpackage must not be 
skipped."""
+    root = aws_tree(
+        {
+            "nested/deeper/thing.py": """
+        class O:
+            def execute(self, context):
+                self.defer(trigger=NestedTrigger(job_id=1))
+        """,
+        }
+    )
+
+    assert [source for source, _, _, _ in find_defer_sites(root)] == 
["nested/deeper/thing.py"]
+
+
+def test_find_unreadable_defer_sites_records_the_expression(aws_tree):
+    root = aws_tree(
+        {
+            "operators/eks.py": """
+        class O:
+            def execute(self, context):
+                self.defer(trigger=self._trigger)
+        """,
+        }
+    )
+
+    assert find_unreadable_defer_sites(root) == {("operators/eks.py", 
"self._trigger")}
+
+
+def test_find_hand_built_hooks_accepts_the_config_spelling(aws_tree):
+    """``AwsGenericHook`` names the botocore config ``config``, not 
``botocore_config``."""
+    root = aws_tree(
+        {
+            "triggers/glue.py": """
+        class T:
+            def logs(self):
+                return AwsLogsHook(region_name=self.region_name, 
verify=self.verify, config=self.config)
+        """,
+        }
+    )
+
+    assert find_hand_built_hooks(root) == [("triggers/glue.py", 4, 
"AwsLogsHook", [])]
+
+
+def test_find_hand_built_hooks_keys_on_the_path_not_the_basename(aws_tree):
+    """Two trigger modules may share a basename, so an exception must not 
cover both."""
+    root = aws_tree(
+        {
+            "triggers/eks.py": """
+        class T:
+            def logs(self):
+                return EksHook(aws_conn_id=self.aws_conn_id)
+        """,
+            "triggers/nested/eks.py": """
+        class T:
+            def logs(self):
+                return EksHook(aws_conn_id=self.aws_conn_id)
+        """,
+        }
+    )
+
+    assert sorted(source for source, _, _, _ in find_hand_built_hooks(root)) 
== [
+        "triggers/eks.py",
+        "triggers/nested/eks.py",
+    ]
+
+
+def test_collect_errors_is_empty_for_a_fully_configured_tree(aws_tree):
+    root = aws_tree(
+        {
+            "operators/glue.py": f"""
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger({CONFIGURED}))
+        """,
+        }
+    )
+
+    assert collect_errors(root) == []
+
+
+def test_collect_errors_flags_a_tree_with_no_defer_sites(aws_tree):
+    root = aws_tree({"operators/glue.py": "class O:\n    pass\n"})
+
+    assert "the check is not looking at it" in "\n".join(collect_errors(root))
+
+
+def test_collect_errors_flags_an_unconfigured_defer_site(aws_tree):
+    root = aws_tree(
+        {
+            "operators/glue.py": """
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger(job_name=self.job_name))
+        """,
+        }
+    )
+
+    (error,) = collect_errors(root)
+    assert "defers to GlueTrigger without passing region_name, verify, 
botocore_config" in error
+
+
+def test_collect_errors_flags_an_unacknowledged_unreadable_site(aws_tree):
+    root = aws_tree(
+        {
+            "operators/glue.py": """
+        class O:
+            def execute(self, context):
+                self.defer(trigger=self._trigger)
+        """,
+        }
+    )
+
+    errors = "\n".join(collect_errors(root))
+    assert "cannot be read statically" in errors
+    assert "Add it to UNREADABLE_DEFER_SITES" in errors
+
+
+def test_collect_errors_flags_a_stale_unreadable_entry(aws_tree, monkeypatch):
+    monkeypatch.setattr(
+        "check_deferred_hook_configuration.UNREADABLE_DEFER_SITES",
+        frozenset({("operators/glue.py", "self._gone")}),
+    )
+    root = aws_tree(
+        {
+            "operators/glue.py": f"""
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger({CONFIGURED}))
+        """,
+        }
+    )
+
+    assert "Drop it from UNREADABLE_DEFER_SITES" in 
"\n".join(collect_errors(root))
+
+
+def 
test_collect_errors_flags_a_pending_migration_entry_that_is_now_fixed(aws_tree, 
monkeypatch):
+    """The allowlist cannot outlive the work it tracks."""
+    monkeypatch.setattr(
+        "check_deferred_hook_configuration.PENDING_MIGRATION",
+        frozenset({("sensors/batch.py", "BatchJobTrigger")}),
+    )
+    root = aws_tree(
+        {
+            "sensors/batch.py": f"""
+        class S:
+            def execute(self, context):
+                self.defer(trigger=BatchJobTrigger({CONFIGURED}))
+        """,
+        }
+    )
+
+    (error,) = collect_errors(root)
+    assert "Drop it from PENDING_MIGRATION" in error
+
+
+def 
test_collect_errors_stays_quiet_for_a_pending_migration_entry_still_needed(aws_tree,
 monkeypatch):
+    monkeypatch.setattr(
+        "check_deferred_hook_configuration.PENDING_MIGRATION",
+        frozenset({("sensors/batch.py", "BatchJobTrigger")}),
+    )
+    root = aws_tree(
+        {
+            "sensors/batch.py": """
+        class S:
+            def execute(self, context):
+                self.defer(trigger=BatchJobTrigger(job_id=self.job_id))
+        """,
+        }
+    )
+
+    assert collect_errors(root) == []
+
+
+def test_collect_errors_flags_an_unconfigured_hand_built_hook(aws_tree):
+    root = aws_tree(
+        {
+            "operators/glue.py": f"""
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger({CONFIGURED}))
+        """,
+            "triggers/glue.py": """
+        class T:
+            def logs(self):
+                return AwsLogsHook(aws_conn_id=self.aws_conn_id)
+        """,
+        }
+    )
+
+    (error,) = collect_errors(root)
+    assert "builds AwsLogsHook without region_name, verify, botocore_config" 
in error
+
+
+def 
test_collect_errors_flags_a_hand_built_exception_that_is_now_fixed(aws_tree, 
monkeypatch):
+    monkeypatch.setattr(
+        "check_deferred_hook_configuration.HAND_BUILT_HOOK_EXCEPTIONS",
+        frozenset({("triggers/glue.py", "AwsLogsHook")}),
+    )
+    root = aws_tree(
+        {
+            "operators/glue.py": f"""
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger({CONFIGURED}))
+        """,
+            "triggers/glue.py": f"""
+        class T:
+            def logs(self):
+                return AwsLogsHook({CONFIGURED})
+        """,
+        }
+    )
+
+    (error,) = collect_errors(root)
+    assert "Drop it from HAND_BUILT_HOOK_EXCEPTIONS" in error
+
+
+def test_a_module_that_cannot_be_parsed_is_reported_not_skipped(aws_tree):
+    """prek does not guarantee ruff runs first, so the sweep must survive a 
half-edited file."""
+    root = aws_tree(
+        {
+            "operators/broken.py": "class O:\n    def execute(self:\n",
+            "operators/glue.py": f"""
+        class O:
+            def execute(self, context):
+                self.defer(trigger=GlueTrigger({CONFIGURED}))
+        """,
+        }
+    )
+
+    assert find_unparseable_modules(root) == ["operators/broken.py"]
+    # The readable file is still swept rather than the whole run dying on its 
neighbour.
+    assert [name for _, _, name, _ in find_defer_sites(root)] == 
["GlueTrigger"]
+    (error,) = collect_errors(root)
+    assert "operators/broken.py could not be parsed" in error

Reply via email to