This is an automated email from the ASF dual-hosted git repository.
uranusjr 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 fb51c4d9bd3 Add decorator to mark task as returning dag result (#64563)
fb51c4d9bd3 is described below
commit fb51c4d9bd30146748bc5acd0ab16aacfb9f3446
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Tue Jun 23 16:38:00 2026 +0800
Add decorator to mark task as returning dag result (#64563)
This does not mean result flag works for mapped tasks yet. Something
will probably be returned, but I'm not sure what exactly. We'll still
need to figure out what the exact behavior should be. This might involve
special handling during return-value-XCom-push, or when the result value
is aggregated in /wait API, or both. Not sure.
---
airflow-core/docs/public-airflow-interface.rst | 3 +-
.../unit/serialization/test_dag_serialization.py | 4 ++
task-sdk/docs/api.rst | 1 +
task-sdk/src/airflow/sdk/__init__.py | 4 +-
task-sdk/src/airflow/sdk/__init__.pyi | 7 ++-
task-sdk/src/airflow/sdk/bases/decorator.py | 7 +++
task-sdk/src/airflow/sdk/definitions/dag.py | 24 ++++++--
.../airflow/sdk/definitions/decorators/__init__.py | 26 +++++++-
.../sdk/definitions/decorators/__init__.pyi | 2 +
.../src/airflow/sdk/definitions/mappedoperator.py | 5 +-
task-sdk/src/airflow/sdk/definitions/xcom_arg.py | 4 +-
.../task_sdk/definitions/decorators/test_result.py | 69 ++++++++++++++++++++++
task-sdk/tests/task_sdk/definitions/test_dag.py | 36 +++++++++--
13 files changed, 175 insertions(+), 17 deletions(-)
diff --git a/airflow-core/docs/public-airflow-interface.rst
b/airflow-core/docs/public-airflow-interface.rst
index 4f4c09d66d1..3d9dccf577c 100644
--- a/airflow-core/docs/public-airflow-interface.rst
+++ b/airflow-core/docs/public-airflow-interface.rst
@@ -116,10 +116,11 @@ API Server, etc.), providing a version-agnostic, stable
interface for writing an
* :func:`airflow.sdk.asset`
* :func:`airflow.sdk.dag`
-* :func:`airflow.sdk.setup`
* :func:`airflow.sdk.task`
* :func:`airflow.sdk.task_group`
+* :func:`airflow.sdk.setup`
* :func:`airflow.sdk.teardown`
+* :func:`airflow.sdk.result`
* :func:`airflow.sdk.chain`
* :func:`airflow.sdk.chain_linear`
* :func:`airflow.sdk.cross_downstream`
diff --git a/airflow-core/tests/unit/serialization/test_dag_serialization.py
b/airflow-core/tests/unit/serialization/test_dag_serialization.py
index 5bc5b0ae847..61a66c60fb6 100644
--- a/airflow-core/tests/unit/serialization/test_dag_serialization.py
+++ b/airflow-core/tests/unit/serialization/test_dag_serialization.py
@@ -866,12 +866,16 @@ class TestStringifiedDAGs:
"_is_sensor",
# trigger_kwargs is kept as raw JSON after deserialization;
checked separately
"start_trigger_args",
+ # Only needed at execution time; intentionally excluded
+ "returns_dag_result",
}
else: # Promised to be mapped by the assert above.
assert isinstance(serialized_task, SerializedMappedOperator)
fields_to_check = {f.name for f in attrs.fields(MappedOperator)}
fields_to_check -= {
+ # Only needed at execution time; intentionally excluded
"map_index_template",
+ "returns_dag_result",
# Matching logic in BaseOperator.get_serialized_fields().
"dag",
"task_group",
diff --git a/task-sdk/docs/api.rst b/task-sdk/docs/api.rst
index d289628097e..d31695f486e 100644
--- a/task-sdk/docs/api.rst
+++ b/task-sdk/docs/api.rst
@@ -72,6 +72,7 @@ Task Decorators:
.. autoapifunction:: airflow.sdk.teardown
.. autofunction:: airflow.sdk.task
+.. autofunction:: airflow.sdk.result
.. autofunction:: airflow.sdk.setup
.. autofunction:: airflow.sdk.teardown
.. autofunction:: airflow.sdk.asset
diff --git a/task-sdk/src/airflow/sdk/__init__.py
b/task-sdk/src/airflow/sdk/__init__.py
index 9f7fd1b0484..8e8953a8aca 100644
--- a/task-sdk/src/airflow/sdk/__init__.py
+++ b/task-sdk/src/airflow/sdk/__init__.py
@@ -110,6 +110,7 @@ __all__ = [
"literal",
"lineage",
"macros",
+ "result",
"setup",
"task",
"task_group",
@@ -151,7 +152,7 @@ if TYPE_CHECKING:
from airflow.sdk.definitions.context import Context, get_current_context,
get_parsing_context
from airflow.sdk.definitions.dag import DAG, dag
from airflow.sdk.definitions.deadline import DeadlineAlert,
DeadlineReference
- from airflow.sdk.definitions.decorators import setup, task, teardown
+ from airflow.sdk.definitions.decorators import result, setup, task,
teardown
from airflow.sdk.definitions.decorators.task_group import task_group
from airflow.sdk.definitions.edges import EdgeModifier, Label
from airflow.sdk.definitions.param import Param, ParamsDict
@@ -313,6 +314,7 @@ __lazy_imports: dict[str, str] = {
"literal": ".definitions.template",
"lineage": ".lineage",
"macros": ".execution_time",
+ "result": ".definitions.decorators",
"setup": ".definitions.decorators",
"task": ".definitions.decorators",
"task_group": ".definitions.decorators",
diff --git a/task-sdk/src/airflow/sdk/__init__.pyi
b/task-sdk/src/airflow/sdk/__init__.pyi
index 78e37465379..273b0579f3e 100644
--- a/task-sdk/src/airflow/sdk/__init__.pyi
+++ b/task-sdk/src/airflow/sdk/__init__.pyi
@@ -60,7 +60,12 @@ from airflow.sdk.definitions.context import (
get_parsing_context as get_parsing_context,
)
from airflow.sdk.definitions.dag import DAG as DAG, dag as dag
-from airflow.sdk.definitions.decorators import setup as setup, task as task,
teardown as teardown
+from airflow.sdk.definitions.decorators import (
+ result as result,
+ setup as setup,
+ task as task,
+ teardown as teardown,
+)
from airflow.sdk.definitions.decorators.task_group import task_group as
task_group
from airflow.sdk.definitions.edges import EdgeModifier as EdgeModifier, Label
as Label
from airflow.sdk.definitions.param import Param as Param
diff --git a/task-sdk/src/airflow/sdk/bases/decorator.py
b/task-sdk/src/airflow/sdk/bases/decorator.py
index 8634fbe9964..e45778725cc 100644
--- a/task-sdk/src/airflow/sdk/bases/decorator.py
+++ b/task-sdk/src/airflow/sdk/bases/decorator.py
@@ -468,6 +468,7 @@ class _TaskDecorator(ExpandableFactory, Generic[FParams,
FReturn, OperatorSubcla
_airflow_is_task_decorator: ClassVar[bool] = True
is_setup: bool = False
is_teardown: bool = False
+ returns_dag_result: bool = False
on_failure_fail_dagrun: bool = False
# This is set in __attrs_post_init__ by update_wrapper. Provided here for
type hints.
@@ -524,6 +525,7 @@ class _TaskDecorator(ExpandableFactory, Generic[FParams,
FReturn, OperatorSubcla
op.is_setup = self.is_setup
op.is_teardown = self.is_teardown
op.on_failure_fail_dagrun = on_failure_fail_dagrun
+ op.returns_dag_result = self.returns_dag_result
op_doc_attrs = [op.doc, op.doc_json, op.doc_md, op.doc_rst,
op.doc_yaml]
# Set the task's doc_md to the function's docstring if it exists and
no other doc* args are set.
if self.function.__doc__ and not any(op_doc_attrs):
@@ -685,6 +687,7 @@ class _TaskDecorator(ExpandableFactory, Generic[FParams,
FReturn, OperatorSubcla
expand_input_attr="op_kwargs_expand_input",
start_trigger_args=self.operator_class.start_trigger_args,
start_from_trigger=self.operator_class.start_from_trigger,
+ returns_dag_result=self.returns_dag_result,
)
return XComArg(operator=operator)
@@ -834,3 +837,7 @@ def task_decorator_factory(
)
return cast("TaskDecorator", decorator_factory)
+
+
+def is_decorated_task(f: Callable) -> typing_extensions.TypeIs[_TaskDecorator]:
+ return getattr(f, "_airflow_is_task_decorator", False)
diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py
b/task-sdk/src/airflow/sdk/definitions/dag.py
index fd47de467fe..46794edbe4a 100644
--- a/task-sdk/src/airflow/sdk/definitions/dag.py
+++ b/task-sdk/src/airflow/sdk/definitions/dag.py
@@ -74,6 +74,7 @@ if TYPE_CHECKING:
from airflow.sdk.definitions.edges import EdgeInfoType
from airflow.sdk.definitions.mappedoperator import MappedOperator
from airflow.sdk.definitions.taskgroup import TaskGroup
+ from airflow.sdk.definitions.xcom_arg import PlainXComArg
from airflow.sdk.execution_time.supervisor import TaskRunResult
from airflow.timetables.base import DataInterval, Timetable as
CoreTimetable
@@ -303,6 +304,13 @@ def _default_task_group(instance: DAG) -> TaskGroup:
return TaskGroup.create_root(dag=instance)
+def _is_valid_dag_result(value: Any) -> TypeIs[PlainXComArg]:
+ from airflow.sdk.bases.xcom import BaseXCom
+ from airflow.sdk.definitions.xcom_arg import PlainXComArg
+
+ return isinstance(value, PlainXComArg) and value.key ==
BaseXCom.XCOM_RETURN_KEY
+
+
# TODO: Task-SDK: look at re-enabling slots after we remove pickling
@attrs.define(repr=False, field_transformer=_all_after_dag_id_to_kw_only,
slots=False)
class DAG:
@@ -1116,12 +1124,8 @@ class DAG:
tg._remove(task)
def add_result(self, xcom_arg: X) -> X:
- from airflow.sdk.bases.xcom import BaseXCom
- from airflow.sdk.definitions.xcom_arg import PlainXComArg
-
- if not isinstance(xcom_arg, PlainXComArg) or xcom_arg.key !=
BaseXCom.XCOM_RETURN_KEY:
+ if not _is_valid_dag_result(xcom_arg):
raise ValueError("Only plain return value can be used as dag
result")
-
xcom_arg.operator.returns_dag_result = True
return xcom_arg
@@ -1674,7 +1678,15 @@ def dag(dag_id_or_func=None, __DAG_class=DAG,
__warnings_stacklevel_delta=2, **d
dag_obj.fileloc = back.f_code.co_filename if back else ""
# Invoke function to create operators in the Dag scope.
- f(**f_kwargs)
+ r = f(**f_kwargs)
+
+ if _is_valid_dag_result(r):
+ log.debug(
+ "Automatically adding function return value %r as
result for dag %s",
+ r,
+ dag_obj.dag_id,
+ )
+ dag_obj.add_result(r)
# Return dag object such that it's accessible in Globals.
return dag_obj
diff --git a/task-sdk/src/airflow/sdk/definitions/decorators/__init__.py
b/task-sdk/src/airflow/sdk/definitions/decorators/__init__.py
index d3d99951609..44c4bd717ca 100644
--- a/task-sdk/src/airflow/sdk/definitions/decorators/__init__.py
+++ b/task-sdk/src/airflow/sdk/definitions/decorators/__init__.py
@@ -18,7 +18,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
-from airflow.sdk.bases.decorator import TaskDecorator
+from airflow.sdk.bases.decorator import TaskDecorator, is_decorated_task
from airflow.sdk.definitions.dag import dag
from airflow.sdk.definitions.decorators.condition import run_if, skip_if
from airflow.sdk.definitions.decorators.setup_teardown import setup_task,
teardown_task
@@ -27,12 +27,16 @@ from airflow.sdk.providers_manager_runtime import
ProvidersManagerTaskRuntime
if TYPE_CHECKING:
from collections.abc import Callable
+ from typing import TypeVar
+
+ C = TypeVar("C", bound=Callable)
# Please keep this in sync with the .pyi's __all__.
__all__ = [
"TaskDecorator",
"TaskDecoratorCollection",
"dag",
+ "result",
"task",
"task_group",
"setup",
@@ -63,3 +67,23 @@ class TaskDecoratorCollection:
task = TaskDecoratorCollection()
setup: Callable = setup_task
teardown: Callable = teardown_task
+
+
+def result(t: C) -> C:
+ """
+ Mark a task as returning the dag's result.
+
+ This must be used *on top of* a ``@task`` decorator like this::
+
+ @result
+ @task
+ def emit_values():
+ something = ...
+ return something
+
+ This causes :func:`DAG.add_result` to later be called internally.
+ """
+ if not is_decorated_task(t):
+ raise TypeError("@result must be used on top of a @task-decorated
function")
+ t.returns_dag_result = True
+ return t
diff --git a/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi
b/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi
index 8e84b33a421..7b7c354a9a6 100644
--- a/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi
+++ b/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi
@@ -38,6 +38,7 @@ __all__ = [
"TaskDecorator",
"TaskDecoratorCollection",
"dag",
+ "result",
"task",
"task_group",
"setup",
@@ -948,3 +949,4 @@ class TaskDecoratorCollection:
task: TaskDecoratorCollection
setup: Callable
teardown: Callable
+result: Callable
diff --git a/task-sdk/src/airflow/sdk/definitions/mappedoperator.py
b/task-sdk/src/airflow/sdk/definitions/mappedoperator.py
index 0faa2ab6f18..4c030be4beb 100644
--- a/task-sdk/src/airflow/sdk/definitions/mappedoperator.py
+++ b/task-sdk/src/airflow/sdk/definitions/mappedoperator.py
@@ -315,6 +315,7 @@ class MappedOperator(AbstractOperator):
start_trigger_args: StartTriggerArgs | None
start_from_trigger: bool
_needs_expansion: bool = True
+ returns_dag_result: bool = False
dag: DAG | None
task_group: TaskGroup | None
@@ -361,7 +362,7 @@ class MappedOperator(AbstractOperator):
@classmethod
def get_serialized_fields(cls):
# Not using 'cls' here since we only want to serialize base fields.
- return (frozenset(attrs.fields_dict(MappedOperator))) - {
+ return frozenset(attrs.fields_dict(MappedOperator)) - {
"_is_empty",
"_can_skip_downstream",
"dag",
@@ -376,6 +377,7 @@ class MappedOperator(AbstractOperator):
"_needs_expansion",
"partial_kwargs",
"operator_extra_links",
+ "returns_dag_result",
}
@property
@@ -792,6 +794,7 @@ class MappedOperator(AbstractOperator):
op.is_setup = is_setup
op.is_teardown = is_teardown
op.on_failure_fail_dagrun = on_failure_fail_dagrun
+ op.returns_dag_result = self.returns_dag_result
op.downstream_task_ids = self.downstream_task_ids
op.upstream_task_ids = self.upstream_task_ids
return op
diff --git a/task-sdk/src/airflow/sdk/definitions/xcom_arg.py
b/task-sdk/src/airflow/sdk/definitions/xcom_arg.py
index 88db9009242..59549083259 100644
--- a/task-sdk/src/airflow/sdk/definitions/xcom_arg.py
+++ b/task-sdk/src/airflow/sdk/definitions/xcom_arg.py
@@ -420,8 +420,10 @@ class MapXComArg(XComArg):
callables: MapCallables
def __attrs_post_init__(self) -> None:
+ from airflow.sdk.bases.decorator import is_decorated_task
+
for c in self.callables:
- if getattr(c, "_airflow_is_task_decorator", False):
+ if is_decorated_task(c):
raise ValueError("map() argument must be a plain function, not
a @task operator")
def __repr__(self) -> str:
diff --git a/task-sdk/tests/task_sdk/definitions/decorators/test_result.py
b/task-sdk/tests/task_sdk/definitions/decorators/test_result.py
new file mode 100644
index 00000000000..b68d1f98621
--- /dev/null
+++ b/task-sdk/tests/task_sdk/definitions/decorators/test_result.py
@@ -0,0 +1,69 @@
+#
+# 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 re
+
+import pytest
+
+from airflow.sdk import DAG, result, task
+
+
+def test_result_error_if_not_task():
+ with pytest.raises(
+ TypeError,
+ match=re.escape("@result must be used on top of a @task-decorated
function"),
+ ):
+
+ @result
+ def f():
+ pass
+
+
+def test_result_marks_returns_dag_result():
+ @result
+ @task
+ def foo():
+ pass
+
+ t = foo()
+ assert t.operator.returns_dag_result is True
+
+
+def test_retain_returns_dag_result_when_other_attrs_are_overridden():
+ @result
+ @task
+ def foo():
+ pass
+
+ with DAG("test_retain_returns_dag_result_when_other_attrs_are_overridden")
as dag:
+ foo.override(task_id="foo2")()
+ assert dag.get_task("foo2").returns_dag_result is True
+
+
+def test_retain_returns_dag_result_when_task_is_expanded():
+ @result
+ @task
+ def foo(x):
+ return x
+
+ with DAG("test_retain_returns_dag_result_when_task_is_expanded") as dag:
+ foo.expand(x=[1, 2, 3])
+
+ assert dag.get_task("foo").returns_dag_result is True
diff --git a/task-sdk/tests/task_sdk/definitions/test_dag.py
b/task-sdk/tests/task_sdk/definitions/test_dag.py
index c42eb8dfc80..0e9f6c78b38 100644
--- a/task-sdk/tests/task_sdk/definitions/test_dag.py
+++ b/task-sdk/tests/task_sdk/definitions/test_dag.py
@@ -21,15 +21,14 @@ import warnings
import weakref
from datetime import datetime, timedelta, timezone
from typing import Any
+from unittest import mock
import pytest
-from airflow.sdk import Context, Label, PartitionAtRuntime, TaskGroup
+from airflow.sdk import DAG, Context, Label, Param, PartitionAtRuntime,
TaskGroup, dag as dag_decorator, task
from airflow.sdk.bases.operator import BaseOperator
from airflow.sdk.bases.timetable import BaseTimetable
-from airflow.sdk.definitions.dag import DAG, dag as dag_decorator
-from airflow.sdk.definitions.param import DagParam, Param, ParamsDict
-from airflow.sdk.definitions.timetables import assets, events, interval,
simple, trigger # noqa: F401
+from airflow.sdk.definitions.param import DagParam, ParamsDict
from airflow.sdk.exceptions import AirflowDagCycleException,
DuplicateTaskIdFound, RemovedInAirflow4Warning
from airflow.utils.types import DagRunType
@@ -788,7 +787,6 @@ class TestDagDecorator:
def test_dag_param_resolves(self):
"""Test that dag param is correctly resolved by operator"""
- from airflow.decorators import task
@dag_decorator(schedule=None, default_args=self.DEFAULT_ARGS)
def xcom_pass_to_op(value=self.VALUE):
@@ -805,6 +803,34 @@ class TestDagDecorator:
self.operator.render_template_fields({})
assert self.operator.op_args[0] == 42
+ def test_ignore_function_result(self, monkeypatch):
+ monkeypatch.setattr(DAG, "add_result",
mock.create_autospec(DAG.add_result))
+
+ @dag_decorator
+ def d():
+ @task
+ def return_num(num):
+ return num
+
+ return_num(123)
+ return 123
+
+ dag = d()
+ assert dag.get_task("return_num").returns_dag_result is False
+ assert DAG.add_result.mock_calls == []
+
+ def test_function_result_set_to_xcom_arg(self):
+ @dag_decorator
+ def d():
+ @task
+ def return_num(num):
+ return num
+
+ return return_num(123)
+
+ dag = d()
+ assert dag.get_task("return_num").returns_dag_result is True
+
class DoNothingOperator(BaseOperator):
"""