amoghrajesh commented on code in PR #72546:
URL: https://github.com/apache/airflow/pull/72546#discussion_r4034053225


##########
providers/standard/src/airflow/providers/standard/operators/bash.py:
##########
@@ -261,5 +328,95 @@ def _is_inline_command(cls, bash_command: str) -> bool:
         """Return True if the bash command is an inline string. False if it's 
a bash script file."""
         return not bash_command.endswith(tuple(cls.template_ext))
 
+    @contextlib.contextmanager
+    def _xcom_workspace(self) -> Iterator[tuple[Path, Path | None]]:
+        """
+        Create the ``$AIRFLOW_XCOM_DIR`` workspace for a single run.
+
+        Kept separate from the ``working_directory()`` script cwd tempdir so a 
rendered ``.sh``
+        file can never collide with an XCom key.
+        """
+        with tempfile.TemporaryDirectory(prefix="airflow-bash-xcom-") as 
workspace:
+            xcom_dir = Path(workspace) / "xcom"
+            xcom_dir.mkdir()
+            bin_dir: Path | None = None
+            if self.xcom_helper_name:
+                bin_dir = Path(workspace) / "bin"
+                bin_dir.mkdir()
+                helper_path = bin_dir / self.xcom_helper_name
+                helper_path.write_text(_XCOM_HELPER_SHIM)
+                helper_path.chmod(0o755)
+            yield xcom_dir, bin_dir
+
+    def _read_xcom_file(self, path: Path) -> tuple[str | None, Any, str | 
None]:
+        """Read one file under ``$AIRFLOW_XCOM_DIR`` and return ``(key, value, 
error)``."""
+        size = path.stat().st_size
+        if size > self.max_xcom_file_size:
+            return (
+                None,
+                None,
+                f"XCom file {path} is {size} bytes, exceeding 
max_xcom_file_size "
+                f"({self.max_xcom_file_size} bytes); it was not pushed.",
+            )
+        text = path.read_bytes().decode(self.output_encoding)
+        value = text[:-1] if text.endswith("\n") else text
+        if path.suffix != ".json":
+            return path.name, value, None
+        try:
+            return path.stem, json.loads(text), None
+        except json.JSONDecodeError as exc:
+            # Keep the ".json" suffix on the key so downstream consumers can 
tell this value
+            # was not parsed, and never silently drop the diagnostic content.
+            return (
+                path.name,
+                value,
+                f"XCom file {path} contains invalid JSON ({exc}); the raw 
content was pushed "
+                f"under {path.name!r} instead.",
+            )
+
+    def _read_xcom_dir(self, xcom_dir: Path, errors: list[str]) -> dict[str, 
Any]:
+        result: dict[str, Any] = {}
+        for entry in sorted(os.scandir(xcom_dir), key=lambda e: e.name):
+            # is_symlink() is checked first because is_dir()/is_file() follow 
symlinks.
+            if entry.is_symlink() or not (entry.is_dir() or entry.is_file()):
+                errors.append(
+                    f"XCom directory entry {entry.path!r} is not a regular 
file; it was not pushed."
+                )
+                continue
+            if entry.is_dir():
+                errors.append(
+                    f"XCom directory entry {entry.path!r} is a subdirectory; 
subdirectories are not "
+                    "supported. Use a '.json' file (or `xcom push --json`) for 
structured values."
+                )
+                continue
+            key, value, error = self._read_xcom_file(Path(entry.path))
+            if error is not None:
+                errors.append(error)
+            if key is not None:
+                result[key] = value
+        return result
+
+    def _collect_and_push_xcom(
+        self, context: Context, xcom_dir: Path, exception_in_flight: bool
+    ) -> dict[str, Any]:
+        """
+        Read ``$AIRFLOW_XCOM_DIR`` and push every entry found in it.
+
+        Called from a ``finally`` block, so collection errors are gathered 
rather than raised
+        inline, and are downgraded to a warning when the command already 
failed or skipped --
+        an original Bash failure must never be masked by a bad XCom file.
+        """
+        errors: list[str] = []
+        entries = self._read_xcom_dir(xcom_dir, errors=errors)
+        for key, value in entries.items():
+            context["ti"].xcom_push(key=key, value=value)
+        if errors:
+            message = "; ".join(errors)
+            if exception_in_flight:
+                self.log.warning("Errors while collecting the XCom directory: 
%s", message)
+            else:
+                raise ValueError(f"Errors while collecting the XCom directory: 
{message}")

Review Comment:
   This fails a task whose command passed. If someone runs `mkdir 
"$AIRFLOW_XCOM_DIR/tmp"` for scratch space, or if one file is a byte over 
`max_xcom_file_size`, their green task goes red. Please log a warning and push 
the good entries instead of raising.
   



##########
providers/standard/src/airflow/providers/standard/operators/bash.py:
##########
@@ -261,5 +328,95 @@ def _is_inline_command(cls, bash_command: str) -> bool:
         """Return True if the bash command is an inline string. False if it's 
a bash script file."""
         return not bash_command.endswith(tuple(cls.template_ext))
 
+    @contextlib.contextmanager
+    def _xcom_workspace(self) -> Iterator[tuple[Path, Path | None]]:
+        """
+        Create the ``$AIRFLOW_XCOM_DIR`` workspace for a single run.
+
+        Kept separate from the ``working_directory()`` script cwd tempdir so a 
rendered ``.sh``
+        file can never collide with an XCom key.
+        """
+        with tempfile.TemporaryDirectory(prefix="airflow-bash-xcom-") as 
workspace:
+            xcom_dir = Path(workspace) / "xcom"
+            xcom_dir.mkdir()
+            bin_dir: Path | None = None
+            if self.xcom_helper_name:
+                bin_dir = Path(workspace) / "bin"
+                bin_dir.mkdir()
+                helper_path = bin_dir / self.xcom_helper_name
+                helper_path.write_text(_XCOM_HELPER_SHIM)
+                helper_path.chmod(0o755)
+            yield xcom_dir, bin_dir
+
+    def _read_xcom_file(self, path: Path) -> tuple[str | None, Any, str | 
None]:
+        """Read one file under ``$AIRFLOW_XCOM_DIR`` and return ``(key, value, 
error)``."""
+        size = path.stat().st_size
+        if size > self.max_xcom_file_size:
+            return (
+                None,
+                None,
+                f"XCom file {path} is {size} bytes, exceeding 
max_xcom_file_size "
+                f"({self.max_xcom_file_size} bytes); it was not pushed.",
+            )
+        text = path.read_bytes().decode(self.output_encoding)

Review Comment:
   This runs inside the `finally` block. If a script writes a binary file here, 
decode raises `UnicodeDecodeError`, and that replaces the real 
`AirflowException` from the failed command. Your own docstring says a bad xcom 
file must never mask a Bash failure. This is that case. Wrap the read in a try 
and add the failure to errors, like you already do for bad JSON.
   
   Same problem with `path.stat()` on line 353 and `os.scandir()` on line 379. 
A file the script deletes or chmod 000s after the scan raises `OSError` out of 
the finally.



##########
providers/standard/src/airflow/providers/standard/operators/bash.py:
##########
@@ -214,18 +251,48 @@ def execute(self, context: Context):
         env = self.get_env(context)
 
         self._is_inline_cmd = self._is_inline_command(bash_command=cast("str", 
self.bash_command))
-        if self._is_inline_cmd:
-            result = self._run_inline_command(bash_path=bash_path, env=env)
-        else:
-            result = self._run_rendered_script_file(bash_path=bash_path, 
env=env)
-
-        if result.exit_code in self.skip_on_exit_code:
-            raise AirflowSkipException(f"Bash command returned exit code 
{result.exit_code}. Skipping.")
-        if result.exit_code != 0:
-            raise AirflowException(
-                f"Bash command failed. The command returned a non-zero exit 
code {result.exit_code}."
-            )
 
+        pushed_xcoms: dict[str, Any] = {}
+        with contextlib.ExitStack() as stack:
+            xcom_dir: Path | None = None
+            if self.do_xcom_push:
+                xcom_dir, bin_dir = stack.enter_context(self._xcom_workspace())
+                # get_env() returns self.env itself when append_env is False, 
so copy before
+                # adding per-run vars -- otherwise these temp paths leak into 
the operator's
+                # own env attribute and survive into later runs.
+                env = dict(env)
+                env["AIRFLOW_XCOM_DIR"] = os.fspath(xcom_dir)
+                if bin_dir is not None:
+                    env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH') or 
os.defpath}"
+
+            exception_in_flight = False
+            try:
+                if self._is_inline_cmd:
+                    result = self._run_inline_command(bash_path=bash_path, 
env=env)
+                else:
+                    result = 
self._run_rendered_script_file(bash_path=bash_path, env=env)
+
+                if result.exit_code in self.skip_on_exit_code:
+                    raise AirflowSkipException(
+                        f"Bash command returned exit code {result.exit_code}. 
Skipping."
+                    )
+                if result.exit_code != 0:
+                    raise AirflowException(
+                        f"Bash command failed. The command returned a non-zero 
exit code {result.exit_code}."
+                    )
+            except BaseException:
+                exception_in_flight = True
+                raise
+            finally:
+                # Collecting here (rather than after the exit-code checks 
above) means XComs are
+                # pushed whether the command succeeded, failed, or was skipped.
+                if xcom_dir is not None:
+                    pushed_xcoms = self._collect_and_push_xcom(
+                        context=context, xcom_dir=xcom_dir, 
exception_in_flight=exception_in_flight
+                    )
+
+        if "return_value" in pushed_xcoms:

Review Comment:
   `return_value` as a magic filename causes three problems:
   
   1. It returns before `self.output_processor()` call on line 296, so a user 
with both set gets their processor silently ignored.
   2. On success the key is pushed twice: once on line 412, once by the 
runner's `_push_xcom_if_needed`.
   3. On failure line 412 pushes it anyway, so a failed task ends up with a 
return_value xcom. A downstream `xcom_pull(task_ids="failed_task")` then reads 
it. The docs say this only applies when the command succeeds.
   
   Simplest fix: drop the special case and treat `return_value` as an ordinary 
key. Costs users one line of `output_processor` and removes all three problems.



##########
providers/standard/src/airflow/providers/standard/operators/bash.py:
##########
@@ -162,6 +195,8 @@ def __init__(
         skip_on_exit_code: int | Container[int] | None = 99,
         cwd: str | None = None,
         output_processor: Callable[[str], Any] = lambda result: result,
+        xcom_helper_name: str | None = "xcom",

Review Comment:
   Default this to `None`. As of now, every existing `BashOperator` gets a new 
xcom executable prepended to PATH on upgrade because `do_xcom_push` is already 
True by default. Anyone who calls their own xcom script from a Bash task 
silently gets this shim instead. The `$AIRFLOW_XCOM_DIR` folder is harmless 
always to provide. The PATH write is not.
   
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to