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

dheerajturaga 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 2aae2829723 Support multiple_outputs in @task.bash (#71808)
2aae2829723 is described below

commit 2aae2829723826065e99c3de7b68e54fa5eb48ed
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Tue Sep 1 18:07:40 2026 -0500

    Support multiple_outputs in @task.bash (#71808)
---
 providers/standard/docs/operators/bash.rst         | 50 +++++++++++++++++
 .../airflow/providers/standard/decorators/bash.py  | 12 +---
 .../example_dags/example_bash_decorator.py         | 24 ++++++++
 .../standard/example_dags/example_bash_operator.py | 27 +++++++++
 .../tests/unit/standard/decorators/test_bash.py    | 65 ++++++++++++++++++----
 5 files changed, 159 insertions(+), 19 deletions(-)

diff --git a/providers/standard/docs/operators/bash.rst 
b/providers/standard/docs/operators/bash.rst
index d1bc9d13d16..075a20e4336 100644
--- a/providers/standard/docs/operators/bash.rst
+++ b/providers/standard/docs/operators/bash.rst
@@ -226,6 +226,56 @@ Here's how you can use the result_processor with the 
BashOperator:
             )
 
 
+Multiple XCom outputs
+---------------------
+
+Pair ``output_processor`` with ``multiple_outputs=True`` to push more than one 
XCom from a single task. When
+the processed output is a dictionary, each key is pushed as its own XCom, 
which lets downstream tasks pull
+individual values by name instead of pulling the whole dictionary and indexing 
into it.
+
+.. tab-set::
+
+    .. tab-item:: @task.bash
+        :sync: taskflow
+
+        .. exampleinclude:: 
/../src/airflow/providers/standard/example_dags/example_bash_decorator.py
+            :language: python
+            :dedent: 4
+            :start-after: [START howto_decorator_bash_multiple_outputs]
+            :end-before: [END howto_decorator_bash_multiple_outputs]
+
+    .. tab-item:: BashOperator
+        :sync: operator
+
+        .. exampleinclude:: 
/../src/airflow/providers/standard/example_dags/example_bash_operator.py
+            :language: python
+            :dedent: 4
+            :start-after: [START howto_operator_bash_multiple_outputs]
+            :end-before: [END howto_operator_bash_multiple_outputs]
+
+The producing task above pushes an XCom for ``dag_folder`` and one for 
``file_count``. The full dictionary is
+*also* pushed as the task's return value, so ``{{ 
ti.xcom_pull(task_ids="describe_dag_folder") }}`` still
+resolves to ``{"dag_folder": ..., "file_count": ...}``.
+
+.. important::
+
+    Only the **last line** written by the command is captured, so the 
dictionary must be the final thing the
+    command emits. A few consequences worth designing around:
+
+    * A trailing ``echo`` with no arguments emits an empty line, which becomes 
the captured output instead of
+      your dictionary.
+    * ``stderr`` is merged into ``stdout``, so a subcommand that writes to 
``stderr`` last will overwrite the
+      captured value. Redirect noisy subcommands (for example ``2>/dev/null``) 
to avoid this.
+    * The dictionary must fit on a single line. Use ``jq -c`` rather than 
pretty-printed output, and prefer
+      ``printf`` over a multi-line ``printf`` format string.
+
+.. note::
+
+    Building JSON by hand does not escape values, so a value containing a 
double quote produces invalid JSON.
+    When values are not known to be safe, generate the JSON with a tool that 
escapes properly, such as
+    ``jq -nc --arg uri "$uri" '{uri: $uri}'`` (note that ``jq`` is not 
installed in every image).
+
+
 Executing commands from files
 -----------------------------
 Both the ``BashOperator`` and ``@task.bash`` TaskFlow decorator enables you to 
execute Bash commands stored
diff --git 
a/providers/standard/src/airflow/providers/standard/decorators/bash.py 
b/providers/standard/src/airflow/providers/standard/decorators/bash.py
index 8cf704b9f84..77fb9861aac 100644
--- a/providers/standard/src/airflow/providers/standard/decorators/bash.py
+++ b/providers/standard/src/airflow/providers/standard/decorators/bash.py
@@ -17,7 +17,6 @@
 
 from __future__ import annotations
 
-import warnings
 from collections.abc import Callable, Collection, Mapping, Sequence
 from typing import TYPE_CHECKING, Any, ClassVar
 
@@ -44,6 +43,9 @@ class _BashDecoratedOperator(DecoratedOperator, BashOperator):
         in your function (templated).
     :param op_args: A list of positional arguments that will get unpacked when
         calling your callable (templated).
+    :param multiple_outputs: If True, the value returned by 
``output_processor`` must be a
+        dict and each key is pushed as its own XCom, in addition to the whole 
dict being
+        pushed as the return value. Defaults to False.
     """
 
     template_fields: Sequence[str] = (*DecoratedOperator.template_fields, 
*BashOperator.template_fields)
@@ -63,19 +65,11 @@ class _BashDecoratedOperator(DecoratedOperator, 
BashOperator):
         op_kwargs: Mapping[str, Any] | None = None,
         **kwargs,
     ) -> None:
-        if kwargs.pop("multiple_outputs", None):
-            warnings.warn(
-                f"`multiple_outputs=True` is not supported in 
{self.custom_operator_name} tasks. Ignoring.",
-                UserWarning,
-                stacklevel=3,
-            )
-
         super().__init__(
             python_callable=python_callable,
             op_args=op_args,
             op_kwargs=op_kwargs,
             bash_command=SET_DURING_EXECUTION,
-            multiple_outputs=False,
             **kwargs,
         )
 
diff --git 
a/providers/standard/src/airflow/providers/standard/example_dags/example_bash_decorator.py
 
b/providers/standard/src/airflow/providers/standard/example_dags/example_bash_decorator.py
index edf694d061f..a8a2073c646 100644
--- 
a/providers/standard/src/airflow/providers/standard/example_dags/example_bash_decorator.py
+++ 
b/providers/standard/src/airflow/providers/standard/example_dags/example_bash_decorator.py
@@ -17,6 +17,8 @@
 
 from __future__ import annotations
 
+import json
+
 import pendulum
 
 from airflow.providers.common.compat.sdk import TriggerRule
@@ -36,6 +38,7 @@ def example_bash_decorator():
     - Jinja templating and context variables
     - Skip behavior via non-zero exit codes and conditional branching
     - Parameterized environment variables and dynamic command construction
+    - Pushing several named XComs from one task with `multiple_outputs`
 
     For details, see the Bash decorator documentation
     
[here](https://airflow.apache.org/docs/apache-airflow/stable/howto/operator/bash.html).
@@ -114,6 +117,27 @@ def example_bash_decorator():
     get_file_stats()
     # [END howto_decorator_bash_build_cmd]
 
+    # [START howto_decorator_bash_multiple_outputs]
+    @task.bash(multiple_outputs=True, output_processor=json.loads)
+    def describe_dag_folder() -> str:
+        # The dict must be the last line the command writes: only that line is 
captured.
+        return """
+            set -e
+            dag_folder="$AIRFLOW_HOME/dags"
+            file_count=$(find "$dag_folder" -type f -name '*.py' 2>/dev/null | 
wc -l)
+            printf '{"dag_folder": "%s", "file_count": %s}\\n' "$dag_folder" 
"$file_count"
+        """
+
+    dag_stats = describe_dag_folder()
+
+    @task.bash
+    def show_dag_folder_stats(folder: str, count: int) -> str:
+        return f'echo "found {count} Dag file(s) under {folder}"'
+
+    # Each key of the returned dict is available as its own XCom.
+    show_dag_folder_stats(folder=dag_stats["dag_folder"], 
count=dag_stats["file_count"])
+    # [END howto_decorator_bash_multiple_outputs]
+
     chain(run_me_loop, run_this)
     chain([also_this, also_this_again, this_skips, run_this], run_this_last)
 
diff --git 
a/providers/standard/src/airflow/providers/standard/example_dags/example_bash_operator.py
 
b/providers/standard/src/airflow/providers/standard/example_dags/example_bash_operator.py
index 722967cd224..e7e40354722 100644
--- 
a/providers/standard/src/airflow/providers/standard/example_dags/example_bash_operator.py
+++ 
b/providers/standard/src/airflow/providers/standard/example_dags/example_bash_operator.py
@@ -20,6 +20,7 @@
 from __future__ import annotations
 
 import datetime
+import json
 
 import pendulum
 
@@ -46,6 +47,7 @@ with DAG(
     - Defining tasks using `BashOperator`
     - Executing simple bash commands
     - Creating task dependencies, including loops and templated commands
+    - Pushing several named XComs from one task with `multiple_outputs`
 
     This example is intended for beginners who want to understand how Airflow
     interacts with system-level commands using bash.
@@ -79,6 +81,31 @@ with DAG(
     # [END howto_operator_bash_template]
     also_run_this >> run_this_last
 
+    # [START howto_operator_bash_multiple_outputs]
+    describe_dag_folder = BashOperator(
+        task_id="describe_dag_folder",
+        # The dict must be the last line the command writes: only that line is 
captured.
+        bash_command="""
+            set -e
+            dag_folder="$AIRFLOW_HOME/dags"
+            file_count=$(find "$dag_folder" -type f -name '*.py' 2>/dev/null | 
wc -l)
+            printf '{"dag_folder": "%s", "file_count": %s}\\n' "$dag_folder" 
"$file_count"
+        """,
+        multiple_outputs=True,
+        output_processor=json.loads,
+    )
+
+    # Each key of the pushed dict is available as its own XCom.
+    show_dag_folder_stats = BashOperator(
+        task_id="show_dag_folder_stats",
+        bash_command=(
+            "echo \"found {{ ti.xcom_pull(task_ids='describe_dag_folder', 
key='file_count') }}"
+            " Dag file(s) under {{ 
ti.xcom_pull(task_ids='describe_dag_folder', key='dag_folder') }}\""
+        ),
+    )
+    # [END howto_operator_bash_multiple_outputs]
+    describe_dag_folder >> show_dag_folder_stats >> run_this_last
+
 # [START howto_operator_bash_skip]
 this_will_skip = BashOperator(
     task_id="this_will_skip",
diff --git a/providers/standard/tests/unit/standard/decorators/test_bash.py 
b/providers/standard/tests/unit/standard/decorators/test_bash.py
index 9197911f409..d3e6c502fb1 100644
--- a/providers/standard/tests/unit/standard/decorators/test_bash.py
+++ b/providers/standard/tests/unit/standard/decorators/test_bash.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import json
 import os
 import warnings
 from contextlib import nullcontext as no_raise
@@ -412,32 +413,36 @@ class TestBashDecorator:
         else:
             assert ti.task.bash_command == "set -e; 
something-that-isnt-on-path"
 
+    @pytest.mark.skipif(
+        not AIRFLOW_V_3_0_PLUS,
+        reason="Airflow 2 resolves @task.bash to its own bundled decorator, 
which ignores multiple_outputs",
+    )
     def test_multiple_outputs_true(self):
-        """Verify setting `multiple_outputs` for a @task.bash-decorated 
function is ignored."""
+        """Verify `multiple_outputs=True` reaches the operator instead of 
being ignored."""
         with self.dag_maker:
 
-            @task.bash(multiple_outputs=True)
+            @task.bash(multiple_outputs=True, output_processor=json.loads)
             def bash():
-                return "echo"
+                return """echo '{"rows": 42, "uri": "s3://bucket/out"}'"""
 
-            with pytest.warns(
-                UserWarning, match="`multiple_outputs=True` is not supported 
in @task.bash tasks. Ignoring."
-            ):
+            with warnings.catch_warnings():
+                warnings.simplefilter("error", category=UserWarning)
                 bash_task = bash()
 
             assert bash_task.operator.bash_command == SET_DURING_EXECUTION
 
-        ti, _ = self.execute_task(bash_task)
+        ti, return_val = self.execute_task(bash_task)
 
-        assert bash_task.operator.multiple_outputs is False
-        self.validate_bash_command_rtif(ti, "echo")
+        assert bash_task.operator.multiple_outputs is True
+        assert return_val == {"rows": 42, "uri": "s3://bucket/out"}
+        self.validate_bash_command_rtif(ti, """echo '{"rows": 42, "uri": 
"s3://bucket/out"}'""")
 
     @pytest.mark.parametrize(
         "multiple_outputs",
         [False, pytest.param(None, id="none"), 
pytest.param(SET_DURING_EXECUTION, id="not-set")],
     )
     def test_multiple_outputs(self, multiple_outputs):
-        """Verify setting `multiple_outputs` for a @task.bash-decorated 
function is ignored."""
+        """Verify `multiple_outputs` defaults to False when unset or falsy."""
         decorator_kwargs = {}
         if multiple_outputs is not SET_DURING_EXECUTION:
             decorator_kwargs["multiple_outputs"] = multiple_outputs
@@ -458,6 +463,46 @@ class TestBashDecorator:
         assert bash_task.operator.multiple_outputs is False
         self.validate_bash_command_rtif(ti, "echo")
 
+    def test_multiple_outputs_not_inferred_from_str_annotation(self):
+        """A `-> str` annotation must not infer `multiple_outputs=True`; the 
callable returns the command."""
+        with self.dag_maker:
+
+            @task.bash
+            def bash() -> str:
+                return "echo hello"
+
+            bash_task = bash()
+
+        ti, return_val = self.execute_task(bash_task)
+
+        assert bash_task.operator.multiple_outputs is False
+        assert return_val == "hello"
+        self.validate_bash_command_rtif(ti, "echo hello")
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="XCom unrolling 
asserted via the Task SDK runner")
+    def test_multiple_outputs_pushes_one_xcom_per_key(self, session):
+        """Each key of the processed output is pushed as its own XCom, 
alongside the return value."""
+        with self.dag_maker:
+
+            @task.bash(multiple_outputs=True, output_processor=json.loads)
+            def bash():
+                return """echo '{"rows": 42, "uri": "s3://bucket/out"}'"""
+
+            bash_task = bash()
+
+        dag_run = self.dag_maker.create_dagrun(
+            run_id=f"bash_deco_multi_xcom_{DEFAULT_DATE.date()}", 
session=session
+        )
+        ti = dag_run.get_task_instance(bash_task.operator.task_id, 
session=session)
+        run_task_instance(ti, bash_task.operator, session=session)
+
+        assert ti.xcom_pull(task_ids=ti.task_id, key="rows", session=session) 
== 42
+        assert ti.xcom_pull(task_ids=ti.task_id, key="uri", session=session) 
== "s3://bucket/out"
+        assert ti.xcom_pull(task_ids=ti.task_id, session=session) == {
+            "rows": 42,
+            "uri": "s3://bucket/out",
+        }
+
     @pytest.mark.parametrize(
         argnames=("return_val", "expected"),
         argvalues=[

Reply via email to