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

shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this 
push:
     new 232b25b72da [airflow-ctl/v0-1-test] Report missing required airflowctl 
options as readable errors (#75177) (#76089)
232b25b72da is described below

commit 232b25b72daebb28b6ac70dff7d6233b6c723203
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 30 21:04:48 2026 +0000

    [airflow-ctl/v0-1-test] Report missing required airflowctl options as 
readable errors (#75177) (#76089)
    
    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.
    (cherry picked from commit fbc18bccfb71b0532f343fddbe9569610758433d)
    
    Co-authored-by: Rishabh <[email protected]>
---
 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 e5108aefd59..d6325678f52 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -36,6 +36,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
@@ -807,6 +808,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."""
 
@@ -851,11 +879,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 8955e6ab406..5addd3d2a55 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -30,7 +30,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 (
@@ -1000,6 +1004,66 @@ class TestCliConfigMethods:
         with pytest.raises(SystemExit, match="Invalid JSON list for 
--task-ids"):
             command_factory._apply_datamodel_defaults(ClearTaskInstancesBody, 
{"task_ids": '["oops'})
 
+    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