This is an automated email from the ASF dual-hosted git repository.
shahar1 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 b30cd9b61be Allow template-field copies into StartTriggerArgs in
operator __init__ (#73040)
b30cd9b61be is described below
commit b30cd9b61be7f39a85b0a3e120ee8cc48617a593
Author: Shahar Epstein <[email protected]>
AuthorDate: Tue Sep 22 17:19:07 2026 +0300
Allow template-field copies into StartTriggerArgs in operator __init__
(#73040)
---
airflow-core/docs/howto/custom-operator.rst | 27 ++++
contributing-docs/05_pull_requests.rst | 25 ++++
scripts/ci/prek/validate_operators_init.py | 84 ++++++++++++-
.../ci/prek/validate_operators_init_exemptions.txt | 1 -
.../tests/ci/prek/test_validate_operators_init.py | 136 +++++++++++++++++++++
5 files changed, 270 insertions(+), 3 deletions(-)
diff --git a/airflow-core/docs/howto/custom-operator.rst
b/airflow-core/docs/howto/custom-operator.rst
index d12892355b1..9c65e9efbf5 100644
--- a/airflow-core/docs/howto/custom-operator.rst
+++ b/airflow-core/docs/howto/custom-operator.rst
@@ -354,6 +354,33 @@ still belongs in ``execute()``:
self.foo = foo
self.bar = bar
+5. Operators that support ``start_from_trigger`` may copy a templated field
verbatim into
+``start_trigger_args.trigger_kwargs`` under the field's own name. The
scheduler sends the task straight to
+the triggerer, which renders those entries itself, and ``execute()`` never
runs. The key has to match the
+field name and be an attribute of the trigger; nothing else in
``StartTriggerArgs`` is rendered, so a
+transformed value, or a key that is not a template field, is still invalid.
Storing one field's value
+under another template field's key is rendered, under that key, but is invalid
too — the entry no
+longer holds the field its key names:
+
+.. code-block:: python
+
+ class HelloOperator(BaseOperator):
+ template_fields = ("foo",)
+ start_trigger_args = StartTriggerArgs(
+ trigger_cls="my_package.triggers.HelloTrigger",
+ trigger_kwargs={},
+ next_method="execute_complete",
+ )
+
+ def __init__(self, foo, start_from_trigger=False) -> None:
+ self.foo = foo
+ self.start_from_trigger = start_from_trigger
+ if start_from_trigger:
+ self.start_trigger_args = dataclasses.replace(
+ self.start_trigger_args,
+ trigger_kwargs={"foo": self.foo}, # allowed: verbatim
copy under the field's name
+ )
+
When an operator inherits from a base operator and does not have a constructor
defined on its own, the limitations above
do not apply. However, the templated fields must be set properly in the parent
according to those limitations.
diff --git a/contributing-docs/05_pull_requests.rst
b/contributing-docs/05_pull_requests.rst
index b59d6bc362b..f8889d4eb13 100644
--- a/contributing-docs/05_pull_requests.rst
+++ b/contributing-docs/05_pull_requests.rst
@@ -464,6 +464,31 @@ Converting an existing truthiness check is not neutral: a
guard that raises when
passing, and that half is a value check. ``not exactly_one(a, b)`` carries
both, since it also
requires at least one.
+Operators that support ``start_from_trigger`` are the third exception. The
scheduler sends the task
+straight to the triggerer and ``execute`` never runs; the triggerer renders
the ``trigger_kwargs``
+entries whose key is both an operator template field and an attribute of the
trigger. Copying such
+a field verbatim into ``start_trigger_args`` under its own name is therefore
fine in the
+constructor:
+
+.. code-block:: python
+
+ def __init__(self, *, job: dict, start_from_trigger: bool = False,
**kwargs):
+ super().__init__(**kwargs)
+ self.job = job
+ self.start_from_trigger = start_from_trigger
+ if start_from_trigger:
+ self.start_trigger_args = dataclasses.replace(
+ self.start_trigger_args, trigger_kwargs={"job": self.job}
+ )
+
+A transformed value, a copy under a key that is not a template field of the
operator, and a template
+field passed as ``timeout`` or ``next_kwargs`` are never rendered and are
still flagged. A copy under
+the name of a *different* template field is rendered, under that key, but is
flagged as well — the
+entry no longer holds the field its key names.
+
+The hook only verifies the operator half of that pair: it cannot tell whether
the key is also an
+attribute of the trigger class, so a passing hook is not confirmation that the
kwarg will be rendered.
+
The reason for doing it is that we are working on a cleaning up our code to
have
`prek hook <../scripts/ci/prek/validate_operators_init.py>`_
that will make sure all the cases where logic (such as validation and complex
conversion)
diff --git a/scripts/ci/prek/validate_operators_init.py
b/scripts/ci/prek/validate_operators_init.py
index 86d25180508..2a2e85f2a0d 100755
--- a/scripts/ci/prek/validate_operators_init.py
+++ b/scripts/ci/prek/validate_operators_init.py
@@ -303,8 +303,8 @@ def _collect_sanctioned_uses(ctor: ast.FunctionDef,
template_fields: list[str])
``self.field = field``, ``self.field = field or <default>``, the equivalent
value-preserving ternaries, the local rebind ``field = field or
<default>``,
tuple assignments pairing names one-to-one, forwarding via
- ``super().__init__(field=field)``, and ``field is None`` / ``field is not
None``
- provision checks.
+ ``super().__init__(field=field)``, ``field is None`` / ``field is not
None``
+ provision checks, and verbatim copies into ``start_trigger_args``.
:param ctor: The constructor function node.
:param template_fields: The template fields of the class.
@@ -335,6 +335,14 @@ def _collect_sanctioned_uses(ctor: ast.FunctionDef,
template_fields: list[str])
name = _target_name(target)
if name is not None and name in template_fields:
mark(value, name)
+ elif isinstance(value, ast.Call) and
_is_start_trigger_args_assignment(target, value):
+ # The triggerer renders only the trigger_kwargs entries
whose key is both an
+ # operator template field and a trigger attribute
(airflow/triggers/base.py),
+ # so only a verbatim copy under the field's own name is
safe un-rendered.
+ # trigger_cls is a string, so the trigger-attribute half
is the author's to keep.
+ for key, item in _iter_trigger_kwargs_items(value):
+ if _target_name(item) == key:
+ sanctioned.add(id(item))
elif isinstance(node, ast.Call) and _is_super_init_call(node):
for keyword in node.keywords:
if keyword.arg is not None and keyword.arg in template_fields:
@@ -345,6 +353,78 @@ def _collect_sanctioned_uses(ctor: ast.FunctionDef,
template_fields: list[str])
return sanctioned
+def _is_call_to(func: ast.expr, name: str, *, modules: tuple[str, ...] = ())
-> bool:
+ """
+ Check whether a call target is the bare name ``name`` or
``<module>.<name>`` for a listed module.
+
+ Any other qualifier (``helper.replace``, ``factory.StartTriggerArgs``) is
rejected so an
+ unrelated callable cannot borrow a sanctioned name.
+
+ :param func: The ``func`` node of the call.
+ :param name: The callable name to match.
+ :param modules: Module names allowed as the attribute qualifier.
+ :return: True if the call target matches.
+ """
+ if isinstance(func, ast.Name):
+ return func.id == name
+ return (
+ isinstance(func, ast.Attribute)
+ and func.attr == name
+ and isinstance(func.value, ast.Name)
+ and func.value.id in modules
+ )
+
+
+def _is_start_trigger_args_assignment(target: ast.expr, value: ast.Call) ->
bool:
+ """
+ Check whether an assignment rebuilds ``self.start_trigger_args``.
+
+ Matches ``self.start_trigger_args = StartTriggerArgs(...)`` and
+ ``self.start_trigger_args = dataclasses.replace(self.start_trigger_args,
...)`` (also a bare
+ ``replace`` or ``copy.replace``). Aliased imports and other module
qualifiers are deliberately
+ not matched.
+
+ :param target: The assignment target.
+ :param value: The assigned call.
+ :return: True if the assignment constructs or copies ``StartTriggerArgs``.
+ """
+ if not (isinstance(target, ast.Attribute) and _target_name(target) ==
"start_trigger_args"):
+ return False
+ if _is_call_to(value.func, "StartTriggerArgs"):
+ return True
+ return (
+ _is_call_to(value.func, "replace", modules=("dataclasses", "copy"))
+ and bool(value.args)
+ and isinstance(value.args[0], ast.Attribute)
+ and _target_name(value.args[0]) == "start_trigger_args"
+ )
+
+
+def _iter_trigger_kwargs_items(call: ast.Call) -> Iterator[tuple[str | None,
ast.expr]]:
+ """
+ Yield the ``(key, value)`` pairs of a call's ``trigger_kwargs=`` argument.
+
+ Supports a ``{...}`` literal and a ``dict(...)`` call; ``**`` unpacking
yields a None key.
+ Only the ``trigger_kwargs=`` keyword is read: other keywords (``timeout``,
``next_kwargs``, ...)
+ are never rendered, and positional arguments are deliberately not matched.
+
+ :param call: The call node.
+ :return: Iterator over the key/value pairs passed as ``trigger_kwargs``.
+ """
+ for keyword in call.keywords:
+ if keyword.arg != "trigger_kwargs":
+ continue
+ if isinstance(keyword.value, ast.Dict):
+ for key, item in zip(keyword.value.keys, keyword.value.values):
+ yield (
+ (key.value if isinstance(key, ast.Constant) and
isinstance(key.value, str) else None),
+ item,
+ )
+ elif isinstance(keyword.value, ast.Call) and
_is_call_to(keyword.value.func, "dict"):
+ for inner in keyword.value.keywords:
+ yield inner.arg, inner.value
+
+
def _check_constructor_field_logic(
class_node: ast.ClassDef, template_fields: list[str], source_lines:
list[str]
) -> int:
diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt
b/scripts/ci/prek/validate_operators_init_exemptions.txt
index 117ee0886e5..45b4d646fc3 100644
--- a/scripts/ci/prek/validate_operators_init_exemptions.txt
+++ b/scripts/ci/prek/validate_operators_init_exemptions.txt
@@ -10,7 +10,6 @@
providers/amazon/src/airflow/providers/amazon/aws/operators/neptune.py::NeptuneS
providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py::CloudBuildCreateBuildOperator
providers/google/src/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py::CloudDataTransferServiceCreateJobOperator
providers/google/src/airflow/providers/google/cloud/operators/dataproc.py::DataprocCreateClusterOperator
-providers/google/src/airflow/providers/google/cloud/operators/dataproc.py::DataprocSubmitJobOperator
providers/google/src/airflow/providers/google/cloud/operators/functions.py::CloudFunctionDeployFunctionOperator
providers/google/src/airflow/providers/google/cloud/sensors/bigquery_dts.py::BigQueryDataTransferServiceTransferRunSensor
providers/google/src/airflow/providers/google/cloud/sensors/cloud_composer.py::CloudComposerExternalTaskSensor
diff --git a/scripts/tests/ci/prek/test_validate_operators_init.py
b/scripts/tests/ci/prek/test_validate_operators_init.py
index 45302516bb1..53dc2a2db3d 100644
--- a/scripts/tests/ci/prek/test_validate_operators_init.py
+++ b/scripts/tests/ci/prek/test_validate_operators_init.py
@@ -127,6 +127,129 @@ class TestConstructorFieldLogic:
0,
id="provision-check-passed-to-a-helper",
),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
StartTriggerArgs(trigger_kwargs={'foo': self.foo})",
+ 0,
+ id="verbatim-copy-into-start-trigger-args",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args =
dataclasses.replace(self.start_trigger_args,
trigger_kwargs=dict(foo=self.foo))",
+ 0,
+ id="verbatim-copy-via-replace-of-start-trigger-args",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
StartTriggerArgs(trigger_kwargs={'foo': self.foo.upper()})",
+ 1,
+ id="transformation-inside-trigger-kwargs",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
StartTriggerArgs(trigger_kwargs={'bar': self.foo})",
+ 1,
+ id="trigger-kwargs-key-differs-from-field-name",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
StartTriggerArgs(trigger_kwargs={**self.foo})",
+ 1,
+ id="trigger-kwargs-unpacking",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
StartTriggerArgs(trigger_kwargs={}, timeout=foo)",
+ 1,
+
id="bare-field-name-in-a-non-rendered-start-trigger-args-field",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args = StartTriggerArgs(trigger_kwargs={},
next_kwargs={'foo': self.foo})",
+ 1,
+ id="other-start-trigger-args-fields-are-not-rendered",
+ ),
+ pytest.param(
+ "self.foo = foo\nunused =
StartTriggerArgs(trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="start-trigger-args-not-assigned-to-self",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.other = dataclasses.replace(self.other,
foo=self.foo)",
+ 1,
+ id="replace-of-another-object-is-not-sanctioned",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args = replace(self.start_trigger_args,
trigger_kwargs={'foo': self.foo})",
+ 0,
+ id="verbatim-copy-via-bare-replace",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args =
copy.replace(self.start_trigger_args, trigger_kwargs={'foo': self.foo})",
+ 0,
+ id="verbatim-copy-via-copy-replace",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
factory.StartTriggerArgs(trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="start-trigger-args-constructor-must-be-a-bare-name",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args =
helper.replace(self.start_trigger_args, trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="replace-must-come-from-dataclasses-or-copy",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
StartTriggerArgs(trigger_kwargs=helper.dict(foo=self.foo))",
+ 1,
+ id="trigger-kwargs-dict-must-be-the-builtin",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
StartTriggerArgs('cls', {'foo': self.foo})",
+ 1,
+ id="positional-trigger-kwargs-are-not-inspected",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
make_args(trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="bare-start-trigger-args-constructor-name-must-match",
+ ),
+ pytest.param(
+ "self.foo = foo\nstart_trigger_args =
StartTriggerArgs(trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="start-trigger-args-must-be-assigned-to-an-attribute",
+ ),
+ pytest.param(
+ "self.foo = foo\nself._sta =
StartTriggerArgs(trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="start-trigger-args-attribute-name-must-match",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args = dataclasses.replace(self.other,
trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="replace-must-copy-start-trigger-args-itself",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args =
StartTriggerArgs(trigger_kwargs=OrderedDict(foo=self.foo))",
+ 1,
+ id="bare-trigger-kwargs-dict-name-must-match",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
replace(trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="replace-without-a-positional-argument",
+ ),
+ pytest.param(
+ "self.foo = foo\n"
+ "self.start_trigger_args =
a.b.replace(self.start_trigger_args, trigger_kwargs={'foo': self.foo})",
+ 1,
+ id="replace-with-a-nested-attribute-qualifier",
+ ),
+ pytest.param(
+ "self.foo = foo\nself.start_trigger_args =
BASE_ARGS[self.foo]",
+ 1,
+ id="non-call-start-trigger-args-assignment",
+ ),
],
)
def test_flags_logic_but_not_sanctioned_patterns(self, ctor_body: str,
expected: int):
@@ -156,6 +279,19 @@ class TestConstructorFieldLogic:
"""
assert _logic_findings(code, ["conf"]) == 0
+ def test_trigger_kwargs_copy_of_another_template_field_is_flagged(self):
+ # "bar" is rendered under the key "bar", so the value copied here is
the wrong field.
+ code = """
+ class MyOperator(BaseOperator):
+ template_fields = ("foo", "bar")
+
+ def __init__(self, foo=None, bar=None, **kwargs):
+ self.foo = foo
+ self.bar = bar
+ self.start_trigger_args =
StartTriggerArgs(trigger_kwargs={"foo": self.bar})
+ """
+ assert _logic_findings(code, ["foo", "bar"]) == 1
+
def test_unbound_module_name_matching_field_is_not_flagged(self):
code = """
class MyOperator(BaseOperator):