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

potiuk 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 2e4ca56c1e3 fix(airflowctl): handle print for non-output endpoints 
(#53785)
2e4ca56c1e3 is described below

commit 2e4ca56c1e3bb947d82ac0e2a7dab5b331b9aacf
Author: Bugra Ozturk <[email protected]>
AuthorDate: Sun Jul 27 10:58:54 2025 +0200

    fix(airflowctl): handle print for non-output endpoints (#53785)
---
 airflow-ctl/src/airflowctl/ctl/cli_config.py       | 33 ++++++++++++++-----
 .../tests/airflow_ctl/ctl/test_cli_config.py       | 37 +++++++++++++++++++++-
 2 files changed, 61 insertions(+), 9 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 0d22aceacf9..9ef416b0770 100644
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -32,6 +32,7 @@ from functools import partial
 from pathlib import Path
 from typing import Any, NamedTuple
 
+import httpx
 import rich
 
 import airflowctl.api.datamodels.generated as generated_datamodels
@@ -75,6 +76,18 @@ def safe_call_command(function: Callable, args: 
Iterable[Arg]) -> None:
     except AirflowCtlNotFoundException as e:
         rich.print(f"command failed due to {e}")
         sys.exit(1)
+    except httpx.RemoteProtocolError as e:
+        if "Server disconnected without sending a response." in str(e):
+            rich.print(
+                f"[red]Server response error: {e}. "
+                "Please check if the server is running and the API URL is 
correct.[/red]"
+            )
+    except httpx.ReadTimeout as e:
+        if "timed out" in str(e):
+            rich.print(
+                f"[red]Request timed out: {e}. "
+                "Please check if the server is running and the API ready to 
accept calls.[/red]"
+            )
 
 
 class DefaultHelpParser(argparse.ArgumentParser):
@@ -325,6 +338,7 @@ class CommandFactory:
     func_map: dict[tuple, Callable]
     commands_map: dict[str, list[ActionCommand]]
     group_commands_list: list[CLICommand]
+    ouput_command_list: list[str]
 
     def __init__(self, file_path: str | Path | None = None):
         self.datamodels_extended_map = {}
@@ -336,6 +350,8 @@ class CommandFactory:
         self.file_path = inspect.getfile(BaseOperations) if file_path is None 
else file_path
         # Exclude parameters that are not needed for CLI from datamodels
         self.excluded_parameters = ["schema_"]
+        # This list is used to determine if the command/operation needs to 
output data
+        self.output_command_list = ["list", "get", "create", "delete"]
 
     def _inspect_operations(self) -> None:
         """Parse file and return matching Operation Method with details."""
@@ -524,12 +540,8 @@ class CommandFactory:
                                 parameter_type=parameter_type, 
parameter_key=parameter_key
                             )
                         )
-            # This list is used to determine if the command/operation needs to 
output data
-            output_command_list = [
-                "list",
-                "get",
-            ]
-            if any(operation.get("name").startswith(cmd) for cmd in 
output_command_list):
+
+            if any(operation.get("name").startswith(cmd) for cmd in 
self.output_command_list):
                 args.extend([ARG_OUTPUT])
 
             self.args_map[(operation.get("name"), 
operation.get("parent").name)] = args
@@ -572,10 +584,13 @@ class CommandFactory:
             else:
                 method_output = operation_method_object(**method_params)
 
-            def convert_to_dict(obj: Any) -> dict | Any:
+            def convert_to_dict(obj: Any, api_operation_name: str) -> dict | 
Any:
                 """Recursively convert an object to a dictionary or list of 
dictionaries."""
                 if hasattr(obj, "model_dump"):
                     return obj.model_dump(mode="json")
+                # Handle delete operation which returns a string of the 
deleted entity name
+                if isinstance(obj, str):
+                    return {"operation": api_operation_name, "entity": obj}
                 return obj
 
             def check_operation_and_collect_list_of_dict(dict_obj: dict) -> 
list:
@@ -598,7 +613,9 @@ class CommandFactory:
                 return [dict_obj]
 
             AirflowConsole().print_as(
-                
data=check_operation_and_collect_list_of_dict(convert_to_dict(method_output)),
+                data=check_operation_and_collect_list_of_dict(
+                    convert_to_dict(method_output, api_operation["name"])
+                ),
                 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 3e887b68431..1e4f5728a25 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -163,6 +163,30 @@ def test_args_get():
     ]
 
 
[email protected](scope="module")
+def test_args_delete():
+    return [
+        (
+            "--backfill-id",
+            {
+                "help": "backfill_id for delete operation in 
BackfillsOperations",
+                "default": None,
+                "type": str,
+                "dest": None,
+            },
+        ),
+        (
+            "--output",
+            {
+                "help": "Output format. Allowed values: json, yaml, plain, 
table (default: json)",
+                "default": "json",
+                "type": str,
+                "dest": None,
+            },
+        ),
+    ]
+
+
 class TestCommandFactory:
     @classmethod
     def _save_temp_operations_py(cls, temp_file: str, file_content) -> None:
@@ -183,7 +207,9 @@ class TestCommandFactory:
         except FileNotFoundError:
             pass
 
-    def test_command_factory(self, no_op_method, test_args_create, 
test_args_list, test_args_get):
+    def test_command_factory(
+        self, no_op_method, test_args_create, test_args_list, test_args_get, 
test_args_delete
+    ):
         """
         Test the command factory.
         """
@@ -213,6 +239,9 @@ class TestCommandFactory:
                     def get(self, backfill_id: str) -> BackfillResponse | 
ServerResponseError:
                         self.response = 
self.client.get(f"backfills/{backfill_id}")
                         return 
BackfillResponse.model_validate_json(self.response.content)
+                    def delete(self, backfill_id: str) -> ServerResponseError 
| None:
+                        self.response = 
self.client.delete(f"backfills/{backfill_id}")
+                        return None
             """,
         )
 
@@ -245,6 +274,12 @@ class TestCommandFactory:
                         assert arg.kwargs["help"] == test_arg[1]["help"]
                         assert arg.kwargs["default"] == test_arg[1]["default"]
                         assert arg.kwargs["type"] == test_arg[1]["type"]
+                elif sub_command.name == "delete":
+                    for arg, test_arg in zip(sub_command.args, 
test_args_delete):
+                        assert arg.flags[0] == test_arg[0]
+                        assert arg.kwargs["help"] == test_arg[1]["help"]
+                        assert arg.kwargs["default"] == test_arg[1]["default"]
+                        assert arg.kwargs["type"] == test_arg[1]["type"]
 
 
 class TestCliConfigMethods:

Reply via email to