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 fbc18bccfb7 Report missing required airflowctl options as readable 
errors (#75177)
fbc18bccfb7 is described below

commit fbc18bccfb71b0532f343fddbe9569610758433d
Author: Rishabh <[email protected]>
AuthorDate: Sat Oct 31 02:28:59 2026 +0530

    Report missing required airflowctl options as readable errors (#75177)
    
    When a generated airflowctl command such as `connections create` is run
    without a datamodel field that the API requires, the unset option reaches
    pydantic as None and the CLI crashes with a raw ValidationError traceback
    ("Input should be a valid string ... input_value=None").
    
    Catch the ValidationError when the request body is built, use pydantic's
    FieldInfo.is_required() to tell missing required fields from invalid values,
    and exit with a message naming the flags, e.g.
    "the following arguments are required: --conn-type". Invalid values are
    reported against their flag. No request is sent in either case.
    
    Part of #57633, addresses #57721.
---
 airflow-ctl/src/airflowctl/ctl/cli_config.py       | 34 ++++++++++-
 .../tests/airflow_ctl/ctl/test_cli_config.py       | 66 +++++++++++++++++++++-
 2 files changed, 96 insertions(+), 4 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 4f3bccf2290..2150872ab7a 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -37,6 +37,7 @@ from typing import Any, NamedTuple
 
 import httpx
 import rich
+from pydantic import BaseModel, ValidationError
 
 import airflowctl.api.datamodels.generated as generated_datamodels
 from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind, 
_request_method, provide_api_client
@@ -926,6 +927,33 @@ class CommandFactory:
 
         return params
 
+    def _validate_datamodel(self, datamodel: type[BaseModel], params: dict) -> 
Any:
+        """
+        Validate CLI input against ``datamodel`` and exit with a readable 
message when it is invalid.
+
+        Optional CLI flags default to ``None``, so a required datamodel field 
the user did not pass
+        reaches pydantic as ``None``. Report those as missing flags instead of 
a raw traceback.
+        """
+        try:
+            return datamodel.model_validate(params)
+        except ValidationError as e:
+            missing: list[str] = []
+            invalid: list[str] = []
+            for error in e.errors():
+                field = str(error["loc"][0]) if error["loc"] else ""
+                field_info = datamodel.model_fields.get(field)
+                flag = f"--{self._sanitize_arg_parameter_key(field)}" if field 
else "input"
+                if field_info is not None and field_info.is_required() and 
error.get("input") is None:
+                    if flag not in missing:
+                        missing.append(flag)
+                else:
+                    invalid.append(f"{flag}: {error['msg']}")
+            messages = []
+            if missing:
+                messages.append(f"the following arguments are required: {', 
'.join(missing)}")
+            messages.extend(invalid)
+            raise SystemExit("Invalid arguments: " + "; ".join(messages))
+
     def _create_func_map_from_operation(self):
         """Create function map from Operation Method checking for parameters 
and return types."""
 
@@ -970,11 +998,11 @@ class CommandFactory:
                     method_params[datamodel_param_name] = 
self._apply_datamodel_defaults(
                         datamodel, method_params[datamodel_param_name]
                     )
-                    method_params[datamodel_param_name] = 
datamodel.model_validate(
-                        method_params[datamodel_param_name]
+                    method_params[datamodel_param_name] = 
self._validate_datamodel(
+                        datamodel, method_params[datamodel_param_name]
                     )
                 else:
-                    method_params = datamodel.model_validate(method_params)
+                    method_params = self._validate_datamodel(datamodel, 
method_params)
                 method_output = operation_method_object(**method_params)
             else:
                 method_output = operation_method_object(**method_params)
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 addce9934cd..70d537c7076 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -31,7 +31,11 @@ import httpx
 import pytest
 
 from airflowctl.api.client import Client
-from airflowctl.api.datamodels.generated import ClearTaskInstancesBody, 
ConnectionTestResponse
+from airflowctl.api.datamodels.generated import (
+    ClearTaskInstancesBody,
+    ConnectionBody,
+    ConnectionTestResponse,
+)
 from airflowctl.api.operations import ConnectionsOperations, DagRunOperations, 
ServerResponseError
 from airflowctl.ctl import cli_parser
 from airflowctl.ctl.cli_config import (
@@ -1153,6 +1157,66 @@ class TestCliConfigMethods:
         assert [str(ti_id) for ti_id in body.task_instance_ids.root] == 
[UUID_A, UUID_B]
         assert [str(ti_id) for ti_id in body.whole_expansion_ids] == [UUID_A]
 
+    def test_validate_datamodel_returns_model_for_valid_params(self):
+        """Test _validate_datamodel returns the validated datamodel when all 
required fields are given."""
+        command_factory = CommandFactory()
+        body = command_factory._validate_datamodel(
+            ConnectionBody, {"connection_id": "my_conn", "conn_type": "http"}
+        )
+
+        assert isinstance(body, ConnectionBody)
+        assert body.connection_id == "my_conn"
+        assert body.conn_type == "http"
+
+    def test_validate_datamodel_reports_missing_required_fields_as_flags(self):
+        """Test missing required fields are reported as CLI flags instead of a 
pydantic traceback."""
+        command_factory = CommandFactory()
+        with pytest.raises(SystemExit) as ctx:
+            command_factory._validate_datamodel(ConnectionBody, 
{"connection_id": None, "conn_type": None})
+
+        message = str(ctx.value)
+        assert "the following arguments are required: --connection-id, 
--conn-type" in message
+        assert "ValidationError" not in message
+
+    def 
test_validate_datamodel_does_not_report_optional_fields_as_missing(self):
+        """Test an unset optional field is not reported as a missing 
argument."""
+        command_factory = CommandFactory()
+        with pytest.raises(SystemExit) as ctx:
+            command_factory._validate_datamodel(
+                ConnectionBody, {"connection_id": "my_conn", "conn_type": 
None, "host": None}
+            )
+
+        message = str(ctx.value)
+        assert "--conn-type" in message
+        assert "--connection-id" not in message
+        assert "--host" not in message
+
+    def test_validate_datamodel_reports_invalid_values_with_flag_name(self):
+        """Test a value of the wrong type is reported against its flag, not as 
a missing argument."""
+        command_factory = CommandFactory()
+        with pytest.raises(SystemExit) as ctx:
+            command_factory._validate_datamodel(
+                ConnectionBody, {"connection_id": "my_conn", "conn_type": 
"http", "port": "not-a-port"}
+            )
+
+        message = str(ctx.value)
+        assert "--port" in message
+        assert "required" not in message
+
+    def 
test_create_command_without_required_option_exits_with_friendly_message(self):
+        """Test ``connections create`` without --conn-type exits cleanly and 
never calls the API."""
+        parser = cli_parser.get_parser()
+        args = parser.parse_args(
+            ["connections", "create", "--api-token", "token", 
"--connection-id", "my_conn"]
+        )
+
+        with mock.patch.object(ConnectionsOperations, "create") as mock_create:
+            with pytest.raises(SystemExit) as ctx:
+                args.func(args)
+
+        assert "the following arguments are required: --conn-type" in 
str(ctx.value)
+        mock_create.assert_not_called()
+
     @pytest.mark.parametrize(
         ("group_name", "subcommand_name", "expected_help"),
         [

Reply via email to