blob: ae57965e0431a17da3ca264944fcc04460a30f2d [file]
#!/usr/bin/env python
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.10,<3.11"
# dependencies = [
# "rich>=13.6.0",
# ]
# ///
"""
Check that provider ``Trigger`` classes keep ``__init__`` and ``serialize()`` in sync.
``__init__`` and ``serialize()`` are written as a pair: a trigger is instantiated once when the
operator defers, then serialized and re-instantiated on whichever triggerer process runs it. Any
``__init__`` parameter that ``serialize()`` does not return is silently dropped on that round-trip
-- the reconstructed trigger falls back to the parameter's default. See
https://github.com/apache/airflow/blob/main/airflow-core/docs/authoring-and-scheduling/deferring.rst
This check parses each provider trigger module with ``ast`` and, for every trigger class whose
``__init__`` signature *and* ``serialize()`` return dict can both be resolved statically (including
through in-file base classes), flags any ``__init__`` parameter missing from the ``serialize()``
return dict.
Two ``serialize()`` shapes are resolved statically:
1. **Direct dict literal**: ``return "<classpath>", {"key": self.key, ...}``.
2. **Dict via local variable**: a single literal-dict initialization (``data = {...}`` or
``data: dict = {...}``), optionally followed by ``data["k"] = ...`` subscript assignments and
``data.update({...})`` calls with literal dict arguments, then ``return "<classpath>", data``.
All key-adding paths (including conditional branches) are unioned -- a key that *could* appear
in the output is treated as preserved on the round-trip.
Classes whose ``serialize()`` is built in any other dynamic way (``**spread`` of a non-``super()``
value, reassignment of the return variable, ``.update()`` with a non-literal argument, ...) or
that inherit ``__init__``/``serialize()`` from a base class defined in another file cannot be
resolved statically and are skipped -- the check never guesses, so it produces no false positives.
"""
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
DEFERRING_DOC = (
"https://github.com/apache/airflow/blob/main/airflow-core/docs/authoring-and-scheduling/deferring.rst"
)
# Key format: "<path relative to the providers/ directory>::<ClassName>".
# Trigger classes that the check flags but which are correct *by design*: an old/aliased parameter
# is folded into its replacement at construction time, so it does not need to round-trip. These are
# permanent exclusions, not tech debt.
BY_DESIGN_EXCLUSIONS: set[str] = {
# `delta` is converted to an absolute `moment` in __init__ and the class deliberately serializes
# as a DateTimeTrigger (see its docstring) -- there is no `delta` to reconstruct.
"standard/src/airflow/providers/standard/triggers/temporal.py::TimeDeltaTrigger",
# `pod_name` is a deprecated alias folded into `pod_names` in __init__; serializing only
# `pod_names` preserves the value and avoids re-triggering the deprecation path on restart.
"google/src/airflow/providers/google/cloud/triggers/kubernetes_engine.py::GKEJobTrigger",
"cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/job.py::KubernetesJobTrigger",
# `region` is a deprecated alias folded into `region_name` in __init__; serializing only
# `region_name` preserves the value and avoids re-triggering the deprecation path on restart.
"amazon/src/airflow/providers/amazon/aws/triggers/ecs.py::TaskDoneTrigger",
}
def _get_init_param_names(func: ast.FunctionDef) -> set[str]:
"""Return the names of reconstructable __init__ parameters (``*args``/``**kwargs`` excluded)."""
args = func.args
names = {a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)}
names.discard("self")
names.discard("cls")
return names
def _get_base_simple_names(cls: ast.ClassDef) -> list[str]:
"""Return the simple (last-component) names of a class's bases."""
out: list[str] = []
for base in cls.bases:
if isinstance(base, ast.Name):
out.append(base.id)
elif isinstance(base, ast.Attribute):
out.append(base.attr)
return out
def _get_method(cls: ast.ClassDef, name: str) -> ast.FunctionDef | None:
for node in cls.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
return node if isinstance(node, ast.FunctionDef) else None
return None
def _walk_skip_nested(node: ast.AST) -> Iterator[ast.AST]:
"""
Yield descendants of *node* without descending into nested function/class/lambda scopes.
Equivalent to ``ast.walk`` for typical control-flow nodes (``if``/``for``/``with`` bodies)
but stops at boundaries that introduce a new variable scope, so ``data["x"] = ...`` inside a
nested helper is not attributed to a same-named variable in the outer function.
"""
for child in ast.iter_child_nodes(node):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
continue
yield child
yield from _walk_skip_nested(child)
class ModuleAnalyzer:
"""Resolves trigger __init__/serialize() pairs within a single module, following in-file bases."""
def __init__(self, path: Path) -> None:
self.path = path
tree = ast.parse(path.read_text("utf-8"), str(path))
self.classes: dict[str, ast.ClassDef] = {
node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
}
def _in_file_base(self, cls: ast.ClassDef) -> ast.ClassDef | None:
for name in _get_base_simple_names(cls):
if name in self.classes:
return self.classes[name]
return None
def is_trigger(self, cls: ast.ClassDef, _seen: set[str] | None = None) -> bool:
if cls.name.endswith("Trigger"):
return True
_seen = _seen or set()
if cls.name in _seen:
return False
_seen.add(cls.name)
for name in _get_base_simple_names(cls):
if "Trigger" in name:
return True
base = self.classes.get(name)
if base is not None and self.is_trigger(base, _seen):
return True
return False
def _resolve_method(self, cls: ast.ClassDef, name: str) -> tuple[ast.FunctionDef, ast.ClassDef] | None:
"""Walk in-file bases until *name* is found; return (method, defining class)."""
current: ast.ClassDef | None = cls
seen: set[str] = set()
while current is not None and current.name not in seen:
seen.add(current.name)
method = _get_method(current, name)
if method is not None:
return method, current
current = self._in_file_base(current)
return None
def _get_serialize_keys(self, cls: ast.ClassDef, _seen: set[str] | None = None) -> set[str] | None:
"""
Statically resolve the keys of the dict returned by *cls*'s effective ``serialize()``.
Returns ``None`` when the dict cannot be resolved statically (dynamic construction,
external base class, ``**`` spread of a non-``super()`` value, ...).
"""
_seen = _seen or set()
if cls.name in _seen:
return None
_seen.add(cls.name)
resolved = self._resolve_method(cls, "serialize")
if resolved is None:
return None
serialize, defining_cls = resolved
# Only count return statements in ``serialize()``'s own scope -- a return inside a
# nested helper function (or comprehension) belongs to that helper, not to serialize.
returns = [n for n in _walk_skip_nested(serialize) if isinstance(n, ast.Return)]
if len(returns) != 1 or returns[0].value is None:
return None
ret = returns[0].value
if not isinstance(ret, (ast.Tuple, ast.List)) or len(ret.elts) != 2:
return None
payload = ret.elts[1]
if isinstance(payload, ast.Dict):
return self._extract_keys_from_dict_literal(payload, defining_cls, _seen)
if isinstance(payload, ast.Name):
return self._extract_keys_from_local_var(payload.id, serialize, defining_cls, _seen)
return None
def _extract_keys_from_dict_literal(
self, payload: ast.Dict, defining_cls: ast.ClassDef, _seen: set[str]
) -> set[str] | None:
"""Resolve keys from a ``{...}`` literal, accepting ``**super().serialize()[1]`` spreads."""
keys: set[str] = set()
for key, value in zip(payload.keys, payload.values):
if key is None:
# ``**spread`` entry -- only resolvable when it spreads super().serialize().
spread = self._get_super_serialize_keys(value, defining_cls, _seen)
if spread is None:
return None
keys |= spread
continue
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
return None
keys.add(key.value)
return keys
def _extract_keys_from_local_var(
self,
var_name: str,
serialize: ast.FunctionDef,
defining_cls: ast.ClassDef,
_seen: set[str],
) -> set[str] | None:
"""
Resolve keys for the dict-via-variable pattern:
.. code-block:: python
def serialize(self):
data = {"a": ...} # single literal-dict init
data["b"] = ... # subscript assignment, string-constant key
data.update({"c": ..., "d": ...}) # .update() with a literal dict argument
if cond:
data["e"] = ... # conditional branches are unioned
return "<classpath>", data
Returns the union of all keys that *could* appear in ``var_name``. Returns ``None`` if any
statement involving ``var_name`` is too dynamic to resolve (reassignment to a non-dict
expression, multiple literal-dict inits, dynamic subscript keys, ``.update()`` with a
non-literal argument, etc.).
"""
keys: set[str] = set()
init_seen = False
for node in _walk_skip_nested(serialize):
# Init: ``var = {...}`` (Assign with Dict RHS).
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == var_name:
if init_seen:
return None # multiple literal-dict inits -- unresolvable
sub = self._extract_keys_from_dict_literal(node.value, defining_cls, _seen)
if sub is None:
return None
keys |= sub
init_seen = True
# Init: ``var: dict[...] = {...}`` (AnnAssign with Dict RHS).
elif isinstance(node, ast.AnnAssign) and isinstance(node.value, ast.Dict):
if isinstance(node.target, ast.Name) and node.target.id == var_name:
if init_seen:
return None
sub = self._extract_keys_from_dict_literal(node.value, defining_cls, _seen)
if sub is None:
return None
keys |= sub
init_seen = True
# Reassignment to a non-dict expression: ``var = something_else``.
elif isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == var_name:
return None
elif isinstance(node, ast.AnnAssign):
if (
isinstance(node.target, ast.Name)
and node.target.id == var_name
and node.value is not None
):
return None
# Now collect mutations: ``var["k"] = ...`` and ``var.update({...})``.
for node in _walk_skip_nested(serialize):
if isinstance(node, ast.Assign):
for tgt in node.targets:
if (
isinstance(tgt, ast.Subscript)
and isinstance(tgt.value, ast.Name)
and tgt.value.id == var_name
):
if not (isinstance(tgt.slice, ast.Constant) and isinstance(tgt.slice.value, str)):
return None # dynamic / non-string subscript key
keys.add(tgt.slice.value)
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "update"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == var_name
):
if node.keywords or len(node.args) != 1 or not isinstance(node.args[0], ast.Dict):
return None # ``.update()`` with kwargs or non-literal-dict argument
sub = self._extract_keys_from_dict_literal(node.args[0], defining_cls, _seen)
if sub is None:
return None
keys |= sub
if not init_seen:
return None # variable was never initialized to a literal dict in this function
return keys
def _get_super_serialize_keys(
self, value: ast.expr, defining_cls: ast.ClassDef, _seen: set[str]
) -> set[str] | None:
"""Resolve keys for a ``**super().serialize()[1]`` / ``**super().serialize()`` spread."""
node = value
if isinstance(node, ast.Subscript): # super().serialize()[1]
node = node.value
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "serialize"
and isinstance(node.func.value, ast.Call)
and isinstance(node.func.value.func, ast.Name)
and node.func.value.func.id == "super"
):
return None
base = self._in_file_base(defining_cls)
if base is None:
return None
return self._get_serialize_keys(base, _seen)
def get_violations(self) -> list[tuple[str, list[str]]]:
"""Return ``(class_name, [missing params])`` for every resolvable trigger that violates."""
results: list[tuple[str, list[str]]] = []
rel = self.path.relative_to(AIRFLOW_PROVIDERS_ROOT_PATH).as_posix()
for name, cls in self.classes.items():
if f"{rel}::{name}" in BY_DESIGN_EXCLUSIONS:
continue
if not self.is_trigger(cls):
continue
init_resolved = self._resolve_method(cls, "__init__")
if init_resolved is None:
continue # __init__ inherited from an out-of-file base -- cannot resolve.
params = _get_init_param_names(init_resolved[0])
serialize_keys = self._get_serialize_keys(cls)
if serialize_keys is None:
continue # serialize() dynamic or inherited from an out-of-file base.
missing = sorted(params - serialize_keys)
if missing:
results.append((name, missing))
return results
def _iter_files(argv: list[str]) -> list[Path]:
if argv:
return [Path(a).resolve() for a in argv]
return sorted(AIRFLOW_PROVIDERS_ROOT_PATH.glob("*/src/airflow/providers/**/triggers/*.py")) + sorted(
AIRFLOW_PROVIDERS_ROOT_PATH.glob("*/*/src/airflow/providers/**/triggers/*.py")
)
def main(argv: list[str]) -> int:
error_count = 0
for path in _iter_files(argv):
if path.name == "__init__.py":
continue
try:
analyzer = ModuleAnalyzer(path)
except SyntaxError as exc:
console.print(f"[red]Could not parse {path}: {exc}")
error_count += 1
continue
rel = path.relative_to(AIRFLOW_PROVIDERS_ROOT_PATH).as_posix()
for class_name, missing in analyzer.get_violations():
error_count += 1
console.print(
f"[red]{rel}::{class_name}[/] -- __init__ parameter(s) "
f"{', '.join(repr(m) for m in missing)} missing from serialize() return dict"
)
if error_count:
console.print(
f"\n[red]Found {error_count} trigger(s) whose serialize() drops __init__ parameters.[/]\n"
"Every __init__ parameter must appear in the serialize() return dict, otherwise it is "
"silently lost when the triggerer re-instantiates the trigger.\n"
f"See: {DEFERRING_DOC}\n"
)
return 1 if error_count else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))