This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new ce942ee496e Send Airflow CLI logs to stderr for -o commands so
structured output stays parseable (#68598) (#70747)
ce942ee496e is described below
commit ce942ee496ed3c3a578940a12bff8f34992bc5e7
Author: Rahul Vats <[email protected]>
AuthorDate: Mon Aug 3 10:57:07 2026 +0530
Send Airflow CLI logs to stderr for -o commands so structured output stays
parseable (#68598) (#70747)
(cherry picked from commit cad7d0e2a9cb864f01c19be44d3d2a60406ecd73)
Co-authored-by: Dheeraj Turaga <[email protected]>
---
airflow-core/src/airflow/__main__.py | 6 ++++
airflow-core/src/airflow/cli/utils.py | 15 +++++++++
airflow-core/tests/unit/cli/test_utils.py | 51 ++++++++++++++++++++++++++++++-
3 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/airflow-core/src/airflow/__main__.py
b/airflow-core/src/airflow/__main__.py
index c11d3b5afe9..c49146b6298 100644
--- a/airflow-core/src/airflow/__main__.py
+++ b/airflow-core/src/airflow/__main__.py
@@ -35,6 +35,7 @@ import argcomplete
# any possible import cycles with settings downstream.
from airflow import configuration
from airflow.cli import cli_parser
+from airflow.cli.utils import redirect_stdout_log_handlers_to_stderr
def main():
@@ -45,6 +46,11 @@ def main():
parser = cli_parser.get_parser()
argcomplete.autocomplete(parser)
args = parser.parse_args()
+ # Commands that accept ``-o`` produce structured output on stdout; route
any
+ # console log handler currently writing to stdout to stderr so log lines do
+ # not corrupt that output (e.g. ``airflow ... -o json | jq``).
+ if hasattr(args, "output"):
+ redirect_stdout_log_handlers_to_stderr()
if args.subcommand not in ["lazy_loaded", "version"]:
# Here we ensure that the default configuration is written if needed
before running any command
# that might need it. This used to be done during configuration
initialization but having it
diff --git a/airflow-core/src/airflow/cli/utils.py
b/airflow-core/src/airflow/cli/utils.py
index 35a62c9df91..aef66a88046 100644
--- a/airflow-core/src/airflow/cli/utils.py
+++ b/airflow-core/src/airflow/cli/utils.py
@@ -17,6 +17,7 @@
from __future__ import annotations
+import logging
import sys
from collections.abc import Callable
from typing import TYPE_CHECKING, TypeVar
@@ -78,6 +79,20 @@ def is_stdout(fileio: IOBase) -> bool:
return fileio is sys.stdout
+def redirect_stdout_log_handlers_to_stderr() -> None:
+ """
+ Redirect any root-logger ``StreamHandler`` writing to stdout so it writes
to stderr.
+
+ Called from the CLI entrypoint for commands that emit structured output on
+ stdout (``-o json|yaml|plain|table``), so log lines do not corrupt that
+ output. ``FileHandler`` is a ``StreamHandler`` subclass; the identity check
+ against ``sys.stdout`` correctly skips it.
+ """
+ for handler in logging.getLogger().handlers:
+ if isinstance(handler, logging.StreamHandler) and handler.stream is
sys.stdout:
+ handler.setStream(sys.stderr)
+
+
def print_export_output(command_type: str, exported_items: Collection, file:
TextIOWrapper):
if is_stdout(file):
print(f"\n{len(exported_items)} {command_type} successfully
exported.", file=sys.stderr)
diff --git a/airflow-core/tests/unit/cli/test_utils.py
b/airflow-core/tests/unit/cli/test_utils.py
index f98a9ef6b19..bd1556dd310 100644
--- a/airflow-core/tests/unit/cli/test_utils.py
+++ b/airflow-core/tests/unit/cli/test_utils.py
@@ -17,9 +17,13 @@
# under the License.
from __future__ import annotations
+import logging
+import sys
import warnings
-from airflow.cli.utils import deprecated_for_airflowctl
+import pytest
+
+from airflow.cli.utils import deprecated_for_airflowctl,
redirect_stdout_log_handlers_to_stderr
class TestDeprecatedForAirflowctl:
@@ -48,3 +52,48 @@ class TestDeprecatedForAirflowctl:
assert command.__name__ == "command"
assert command.__doc__ == "Original docstring."
assert command._migrated_to_airflowctl == "airflowctl pools create"
+
+
+class TestRedirectStdoutLogHandlersToStderr:
+ """Tests for the CLI helper that keeps logs off stdout for ``-o``-style
commands."""
+
+ @pytest.fixture
+ def isolated_root_logger(self):
+ """Snapshot and restore root logger handlers so tests don't leak
state."""
+ root = logging.getLogger()
+ original_handlers = root.handlers[:]
+ root.handlers = []
+ try:
+ yield root
+ finally:
+ root.handlers = original_handlers
+
+ @pytest.mark.parametrize(
+ ("make_handler", "expected_stream"),
+ [
+ pytest.param(
+ lambda _tmp_path: logging.StreamHandler(stream=sys.stdout),
+ lambda _original: sys.stderr,
+ id="stdout-stream-handler-redirected",
+ ),
+ pytest.param(
+ lambda _tmp_path: logging.StreamHandler(stream=sys.stderr),
+ lambda _original: sys.stderr,
+ id="stderr-stream-handler-untouched",
+ ),
+ pytest.param(
+ lambda tmp_path: logging.FileHandler(tmp_path / "airflow.log"),
+ lambda original: original,
+ id="file-handler-untouched",
+ ),
+ ],
+ )
+ def test_redirect(self, isolated_root_logger, tmp_path, make_handler,
expected_stream):
+ handler = make_handler(tmp_path)
+ isolated_root_logger.addHandler(handler)
+ original_stream = handler.stream
+ try:
+ redirect_stdout_log_handlers_to_stderr()
+ assert handler.stream is expected_stream(original_stream)
+ finally:
+ handler.close()