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"])