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

shahar1 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 1c3b3def869 Fix airflowctl operations create command validation error  
(#69317)
1c3b3def869 is described below

commit 1c3b3def8694cbd96bad9f82af80d532386e1f04
Author: Pratiksha Badheka <[email protected]>
AuthorDate: Sat Oct 31 02:15:04 2026 +0530

    Fix airflowctl operations create command validation error  (#69317)
---
 airflow-ctl/src/airflowctl/ctl/cli_config.py       | 21 +++++++--
 .../tests/airflow_ctl/api/test_operations.py       |  8 +++-
 .../tests/airflow_ctl/ctl/test_cli_config.py       | 50 ++++++++++++++++++++++
 3 files changed, 74 insertions(+), 5 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 1d5212f24a5..4f3bccf2290 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -27,6 +27,7 @@ import inspect
 import json
 import os
 import sys
+import typing
 from argparse import Namespace
 from collections.abc import Callable, Iterable
 from enum import Enum
@@ -733,6 +734,7 @@ class CommandFactory:
         arg_action: argparse.BooleanOptionalAction | None,
         arg_dest: str | None = None,
         arg_default: Any | None = None,
+        arg_required: bool = False,
     ) -> Arg:
         return Arg(
             flags=arg_flags,
@@ -741,6 +743,7 @@ class CommandFactory:
             help=arg_help,
             default=arg_default,
             action=arg_action,
+            required=True if arg_required else _UNSET,
         )
 
     @staticmethod
@@ -763,6 +766,11 @@ class CommandFactory:
             help=arg_help,
         )
 
+    @staticmethod
+    def _allows_none(annotation: Any) -> bool:
+        """Return True if the annotation accepts None, e.g. ``str | None``."""
+        return type(None) in typing.get_args(annotation)
+
     def _find_subgroup_suffix(self, operation_group_name: str, operation_name: 
str) -> str | None:
         """Find the subgroup suffix an operation belongs to, if one exists."""
         for suffix in self.subgroups.get(operation_group_name, set()):
@@ -806,9 +814,16 @@ class CommandFactory:
                     arg_type=self._python_type_from_string(annotation),
                     arg_action=argparse.BooleanOptionalAction if annotation is 
bool else None,  # type: ignore
                     arg_help=f"{field} for {parameter_key} operation",
-                    arg_default=self._get_bool_arg_default(parameter_type, 
field_type.default)
-                    if annotation is bool
-                    else None,
+                    arg_default=(
+                        self._get_bool_arg_default(parameter_type, 
field_type.default)
+                        if annotation is bool
+                        else None
+                    ),
+                    arg_required=(
+                        field_type.is_required()  # no default?
+                        and annotation is not bool  # not a bool flag
+                        and not self._allows_none(field_type.annotation)  # 
None not acceptable
+                    ),
                 )
             )
         return commands
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 1c7b0f45cf7..26a02351abc 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -940,7 +940,9 @@ class TestConnectionsOperations:
             )
 
         client = make_api_client(transport=httpx.MockTransport(handle_request))
-        response = client.connections.create(connection=self.connection)
+        response = client.connections.create(
+            connection=self.connection,
+        )
         assert response == self.connection_response
 
     def test_create_uses_schema_alias_in_request_body(self):
@@ -964,7 +966,9 @@ class TestConnectionsOperations:
             )
 
         client = make_api_client(transport=httpx.MockTransport(handle_request))
-        response = client.connections.create(connection=connection)
+        response = client.connections.create(
+            connection=connection,
+        )
         assert response == self.connection_response
 
     def test_bulk(self):
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 a0249295109..addce9934cd 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -574,6 +574,47 @@ class TestCommandFactory:
         assert limit_arg.flags == ("--limit",)
         assert limit_arg.kwargs["type"] is int
 
+    @pytest.mark.parametrize(
+        ("group", "command", "required_flags"),
+        [
+            pytest.param(
+                "connections", "create", {"--connection-id", "--conn-type"}, 
id="connections-create"
+            ),
+            pytest.param("pools", "create", {"--name", "--slots"}, 
id="pools-create"),
+        ],
+    )
+    def test_required_datamodel_fields_become_required_flags(self, group, 
command, required_flags):
+        """Required fields of a body model are generated as required 
``--flags``."""
+        command_factory = CommandFactory()
+        group_command = next(g for g in command_factory.group_commands if 
g.name == group)
+        sub_command = next(c for c in group_command.subcommands if c.name == 
command)
+
+        required = {arg.flags[0] for arg in sub_command.args if 
arg.kwargs.get("required") is True}
+        assert required == required_flags
+
+        for arg in sub_command.args:
+            action = arg.kwargs.get("action")
+            if action is BooleanOptionalAction:
+                assert not arg.kwargs.get("required"), "bool flags always have 
a default"
+
+    def test_optional_datamodel_fields_default_to_none(self):
+        """Optional non-bool fields stay ``None`` so Pydantic sees them as not 
provided."""
+        command_factory = CommandFactory()
+        group_command = next(g for g in command_factory.group_commands if 
g.name == "connections")
+        sub_command = next(c for c in group_command.subcommands if c.name == 
"create")
+
+        parser = argparse.ArgumentParser()
+        for arg in sub_command.args:
+            arg.add_to_parser(parser)
+
+        namespace = parser.parse_args(["--connection-id", "c1", "--conn-type", 
"http"])
+
+        assert namespace.connection_id == "c1"
+        assert namespace.conn_type == "http"
+        assert namespace.host is None
+        assert namespace.login is None
+        assert namespace.description is None
+
     def test_every_generated_command_accepts_the_output_flag(self):
         """``_get_func`` always prints through ``args.output``, so every 
generated command must declare it."""
         command_factory = CommandFactory()
@@ -598,6 +639,15 @@ class TestCommandFactory:
 
         assert missing == []
 
+    def test_required_but_nullable_datamodel_field_stays_optional(self):
+        """A field with no default that accepts None (e.g. ``logical_date``) 
must not become a required flag."""
+        command_factory = CommandFactory()
+        dags_group = next(g for g in command_factory.group_commands if g.name 
== "dags")
+        trigger = next(c for c in dags_group.subcommands if c.name == 
"trigger")
+
+        logical_date_arg = next(arg for arg in trigger.args if arg.flags == 
("--logical-date",))
+        assert not logical_date_arg.kwargs.get("required")
+
 
 class TestCliConfigMethods:
     @pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "POST", 
"PUT", "PATCH", "DELETE"])

Reply via email to