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"),
[