This is an automated email from the ASF dual-hosted git repository.
henry3260 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 ddb905f0ee0 Fix airflowctl dags get-tags crashing whenever a Dag has
tags (#73743)
ddb905f0ee0 is described below
commit ddb905f0ee09b423d0b09ef79e62c282de9eb14c
Author: Stefan Wang <[email protected]>
AuthorDate: Wed Oct 7 21:43:01 2026 -0700
Fix airflowctl dags get-tags crashing whenever a Dag has tags (#73743)
* Fix airflowctl dags get-tags crashing whenever a Dag has tags
The dagTags endpoint returns tag names as plain strings, but the printer
behind every generated command only renders records. The command raised
"To tabulate non-dictionary data you need to provide `mapper` function"
for every output format. Print one row per tag instead.
Signed-off-by: 1fanwang <[email protected]>
* Pin airflowctl outputs that keep their single-key shape
Cover pools list printing each pool as a record and dagrun get keeping a
single-key list conf on one row, so dropping either guard fails a test.
Signed-off-by: 1fanwang <[email protected]>
---------
Signed-off-by: 1fanwang <[email protected]>
---
.../airflowctl_tests/test_airflowctl_commands.py | 1 +
airflow-ctl/src/airflowctl/ctl/cli_config.py | 12 ++-
.../tests/airflow_ctl/ctl/test_cli_config.py | 96 ++++++++++++++++++++++
3 files changed, 105 insertions(+), 4 deletions(-)
diff --git
a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
index 377a5d8a87d..0e7ce34ed8f 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
@@ -84,6 +84,7 @@ TEST_COMMANDS = [
"dags get example_bash_operator",
"dags get-details example_bash_operator",
"dags get-stats example_bash_operator",
+ "dags get-tags",
"dags get-version example_bash_operator 1",
"dags list-import-errors",
"dags list-version example_bash_operator",
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 725e75be8d6..d160772eef3 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -40,7 +40,7 @@ import rich
import airflowctl.api.datamodels.generated as generated_datamodels
from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind,
provide_api_client
from airflowctl.api.operations import BaseOperations, ServerResponseError
-from airflowctl.ctl.console_formatting import AirflowConsole
+from airflowctl.ctl.console_formatting import AirflowConsole, is_data_sequence
from airflowctl.ctl.utils.yaml import safe_load
from airflowctl.exceptions import (
AirflowCtlConnectionException,
@@ -905,7 +905,7 @@ class CommandFactory:
return {"operation": api_operation_name, "entity": obj}
return obj
- def check_operation_and_collect_list_of_dict(dict_obj: dict) ->
list:
+ def check_operation_and_collect_list_of_dict(dict_obj: dict,
top_level: bool = False) -> list:
"""Check if the object is a nested dictionary and collect list
of dictionaries."""
def is_dict_nested(obj: dict) -> bool:
@@ -928,13 +928,17 @@ class CommandFactory:
# If dict_obj only have single key return value instead of list
# This can happen since we are excluding some keys from user
such as total_entries from list operations
if len(dict_obj) == 1:
- return dict_obj[next(iter(dict_obj.keys()))]
+ key, value = next(iter(dict_obj.items()))
+ # Printed rows must be records, so plain values such as
Dag tags get one row each.
+ if top_level and isinstance(value, list) and not
is_data_sequence(value):
+ return [{key: item} for item in value]
+ return value
# If not nested, return the object as a list which the result
should be already a dict
return [dict_obj]
AirflowConsole().print_as(
data=check_operation_and_collect_list_of_dict(
- convert_to_dict(method_output, api_operation["name"])
+ dict_obj=convert_to_dict(method_output,
api_operation["name"]), top_level=True
),
output=args.output,
)
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
index f839e4e0970..d092b579570 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -18,10 +18,12 @@
from __future__ import annotations
import argparse
+import json
from argparse import BooleanOptionalAction
from datetime import UTC
from pathlib import Path
from textwrap import dedent
+from typing import Any
from unittest import mock
import httpx
@@ -44,6 +46,7 @@ from airflowctl.ctl.cli_config import (
safe_call_command,
)
from airflowctl.ctl.console_formatting import AirflowConsole
+from airflowctl.ctl.utils.yaml import safe_load
from airflowctl.exceptions import (
AirflowCtlConnectionException,
AirflowCtlCredentialNotFoundException,
@@ -988,3 +991,96 @@ class TestCliConfigMethods:
args.func(args, api_client=mock.MagicMock(spec=Client))
assert mocked_print_as.call_args.kwargs["output"] == "json"
+
+ @pytest.mark.parametrize(
+ ("output", "parse", "expected"),
+ [
+ pytest.param("json", json.loads, [{"tags": "etl"}, {"tags":
"nightly"}], id="json"),
+ pytest.param("yaml", safe_load, [{"tags": "etl"}, {"tags":
"nightly"}], id="yaml"),
+ pytest.param("table", str.split, ["tags", "=======", "etl",
"nightly"], id="table"),
+ pytest.param("plain", str.split, ["tags", "etl", "nightly"],
id="plain"),
+ ],
+ )
+ def test_collection_of_plain_values_prints_one_row_per_value(
+ self, api_client_maker, capsys, output, parse, expected
+ ):
+ """``dags get-tags`` receives plain strings, and the printer only
renders records."""
+ api_client = api_client_maker(
+ path="/api/v2/dagTags",
+ response_json={"tags": ["etl", "nightly"], "total_entries": 2},
+ expected_http_status_code=200,
+ )
+ args = cli_parser.get_parser().parse_args(["dags", "get-tags",
"--output", output])
+
+ args.func(args, api_client=api_client)
+
+ assert parse(capsys.readouterr().out) == expected
+
+ def test_list_of_records_prints_records_unwrapped(self, api_client_maker,
capsys):
+ pools: list[dict[str, Any]] = [
+ {
+ "name": name,
+ "slots": 128,
+ "description": None,
+ "include_deferred": False,
+ "occupied_slots": 0,
+ "running_slots": 0,
+ "queued_slots": 0,
+ "scheduled_slots": 0,
+ "open_slots": 128,
+ "deferred_slots": 0,
+ "team_name": None,
+ }
+ for name in ("default_pool", "etl")
+ ]
+ api_client = api_client_maker(
+ path="/api/v2/pools",
+ response_json={"pools": pools, "total_entries": 2},
+ expected_http_status_code=200,
+ )
+ args = cli_parser.get_parser().parse_args(["pools", "list",
"--output", "json"])
+
+ args.func(args, api_client=api_client)
+
+ rows = json.loads(capsys.readouterr().out)
+ assert [row["name"] for row in rows] == ["default_pool", "etl"]
+ assert all(row.keys() == pools[0].keys() for row in rows)
+
+ def test_nested_single_key_list_is_not_split_into_rows(self,
api_client_maker, capsys):
+ api_client = api_client_maker(
+ path="/api/v2/dags/example/dagRuns/manual_run",
+ response_json={
+ "dag_run_id": "manual_run",
+ "dag_id": "example",
+ "logical_date": None,
+ "queued_at": None,
+ "start_date": None,
+ "end_date": None,
+ "duration": None,
+ "data_interval_start": None,
+ "data_interval_end": None,
+ "run_after": "2025-01-01T00:00:00Z",
+ "last_scheduling_decision": None,
+ "run_type": "manual",
+ "state": "success",
+ "triggered_by": None,
+ "triggering_user_name": None,
+ "conf": {"ids": ["a", "b"]},
+ "note": None,
+ "dag_versions": [],
+ "bundle_version": None,
+ "dag_display_name": "example",
+ "partition_key": None,
+ "partition_date": None,
+ },
+ expected_http_status_code=200,
+ )
+ args = cli_parser.get_parser().parse_args(
+ ["dagrun", "get", "example", "manual_run", "--output", "json"]
+ )
+
+ args.func(args, api_client=api_client)
+
+ rows = json.loads(capsys.readouterr().out)
+ assert [row["dag_run_id"] for row in rows] == ["manual_run"]
+ assert rows[0]["conf"] == ["a", "b"]