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 2e8f6f983b4 Report missing and invalid fields in airflowctl 
connections import (#76090)
2e8f6f983b4 is described below

commit 2e8f6f983b4509493917586c6ce59c7cb98018d1
Author: Shahar Epstein <[email protected]>
AuthorDate: Sat Oct 31 15:57:27 2026 +0200

    Report missing and invalid fields in airflowctl connections import (#76090)
    
    A connection entry without conn_type, or with a bad value such as a
    non-numeric port, made the import fail with a raw pydantic dump. The
    dump did not say which connection in the file was broken, and a missing
    conn_type showed up as "Input should be a valid string", not as a
    missing field. Generated commands got readable messages in #75177; the
    hand-built import path did not.
---
 airflow-ctl/src/airflowctl/ctl/cli_config.py       | 15 ++-----
 .../airflowctl/ctl/commands/connection_command.py  | 41 +++++++++++-------
 airflow-ctl/src/airflowctl/ctl/utils/validation.py | 48 ++++++++++++++++++++++
 .../ctl/commands/test_connections_command.py       | 33 +++++++++++++++
 4 files changed, 112 insertions(+), 25 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index e35953f6dae..b2ed042ab93 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -44,6 +44,7 @@ import airflowctl.api.datamodels.generated as 
generated_datamodels
 from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind, 
_request_method, provide_api_client
 from airflowctl.api.operations import BaseOperations, ServerResponseError
 from airflowctl.ctl.console_formatting import AirflowConsole, is_data_sequence
+from airflowctl.ctl.utils.validation import split_validation_errors
 from airflowctl.ctl.utils.yaml import safe_load
 from airflowctl.exceptions import (
     AirflowCtlConnectionException,
@@ -959,17 +960,9 @@ class CommandFactory:
         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']}")
+            missing, invalid = split_validation_errors(
+                datamodel, e, lambda field: 
f"--{self._sanitize_arg_parameter_key(field)}"
+            )
             messages = []
             if missing:
                 messages.append(f"the following arguments are required: {', 
'.join(missing)}")
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py
index 4c4067ffcb7..f99bd81cc7b 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py
@@ -19,8 +19,10 @@ from __future__ import annotations
 
 import json
 from pathlib import Path
+from typing import Any
 
 import rich
+from pydantic import ValidationError
 
 from airflowctl.api.client import NEW_API_CLIENT, ClientKind, 
provide_api_client
 from airflowctl.api.datamodels.generated import (
@@ -29,6 +31,30 @@ from airflowctl.api.datamodels.generated import (
     BulkCreateActionConnectionBody,
     ConnectionBody,
 )
+from airflowctl.ctl.utils.validation import split_validation_errors
+
+
+def _build_connection_body(connection_id: str, details: dict[str, Any], 
filename: str) -> ConnectionBody:
+    """Build a ``ConnectionBody``, exiting with a readable message naming the 
connection if it is invalid."""
+    try:
+        return ConnectionBody(
+            connection_id=connection_id,
+            conn_type=details.get("conn_type"),  # type: ignore[arg-type]
+            host=details.get("host"),
+            login=details.get("login"),
+            password=details.get("password"),
+            port=details.get("port"),
+            extra=details.get("extra"),
+            description=details.get("description", ""),
+            **({"schema": details["schema"]} if "schema" in details else {}),
+        )
+    except ValidationError as e:
+        missing, invalid = split_validation_errors(ConnectionBody, e, str)
+        messages = []
+        if missing:
+            messages.append(f"missing required field(s): {', '.join(missing)}")
+        messages.extend(invalid)
+        raise SystemExit(f"Invalid connection {connection_id!r} in {filename}: 
" + "; ".join(messages))
 
 
 @provide_api_client(kind=ClientKind.CLI)
@@ -56,21 +82,8 @@ def import_(args, api_client=NEW_API_CLIENT) -> None:
                 f"Invalid connection {connection_id!r} in {args.file}: 
expected a JSON object "
                 f"of connection details, got 
{type(connection_details).__name__}"
             )
+    connections_data = {k: _build_connection_body(k, v, args.file) for k, v in 
connections_json.items()}
     try:
-        connections_data = {
-            k: ConnectionBody(
-                connection_id=k,
-                conn_type=v.get("conn_type"),
-                host=v.get("host"),
-                login=v.get("login"),
-                password=v.get("password"),
-                port=v.get("port"),
-                extra=v.get("extra"),
-                description=v.get("description", ""),
-                **({"schema": v["schema"]} if "schema" in v else {}),
-            )
-            for k, v in connections_json.items()
-        }
         connection_create_action = BulkCreateActionConnectionBody(
             action="create",
             entities=list(connections_data.values()),
diff --git a/airflow-ctl/src/airflowctl/ctl/utils/validation.py 
b/airflow-ctl/src/airflowctl/ctl/utils/validation.py
new file mode 100644
index 00000000000..5342a377830
--- /dev/null
+++ b/airflow-ctl/src/airflowctl/ctl/utils/validation.py
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Helpers for reporting pydantic validation errors as readable CLI 
messages."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+    from pydantic import BaseModel, ValidationError
+
+
+def split_validation_errors(
+    datamodel: type[BaseModel], error: ValidationError, display_name: 
Callable[[str], str]
+) -> tuple[list[str], list[str]]:
+    """
+    Split a ``ValidationError`` into missing required fields and ``<name>: 
<msg>`` invalid values.
+
+    Callers pass absent inputs as ``None``, so ``None`` on a required field 
means missing (or an explicit null).
+    """
+    missing: list[str] = []
+    invalid: list[str] = []
+    for err in error.errors():
+        field = str(err["loc"][0]) if err["loc"] else ""
+        field_info = datamodel.model_fields.get(field)
+        name = display_name(field) if field else "input"
+        if field_info is not None and field_info.is_required() and 
err.get("input") is None:
+            if name not in missing:
+                missing.append(name)
+        else:
+            invalid.append(f"{name}: {err['msg']}")
+    return missing, invalid
diff --git 
a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
index f400f07dcfd..bd0658cab9f 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
@@ -247,6 +247,39 @@ class TestCliConnectionCommands:
         assert "has no attribute" not in message
         mock_client.connections.bulk.assert_not_called()
 
+    @pytest.mark.parametrize(
+        ("details", "expected_reason"),
+        [
+            ({"host": "h"}, "missing required field(s): conn_type"),
+            (
+                {"conn_type": "postgres", "port": "not-a-port"},
+                "port: Input should be a valid integer, unable to parse string 
as an integer",
+            ),
+            (
+                {"port": "not-a-port"},
+                "missing required field(s): conn_type; "
+                "port: Input should be a valid integer, unable to parse string 
as an integer",
+            ),
+        ],
+        ids=["missing-conn-type", "invalid-port", "missing-and-invalid"],
+    )
+    def test_import_reports_invalid_connection(self, tmp_path, monkeypatch, 
details, expected_reason):
+        monkeypatch.chdir(tmp_path)
+        json_path = tmp_path / self.export_file_name
+        json_path.write_text(json.dumps({"my_conn": details}))
+        mock_client = mock.MagicMock(spec=Client)
+
+        with pytest.raises(SystemExit) as exc_info:
+            connection_command.import_(
+                self.parser.parse_args(["connections", "import", 
json_path.as_posix()]),
+                api_client=mock_client,
+            )
+
+        assert str(exc_info.value) == (
+            f"Invalid connection 'my_conn' in {json_path.as_posix()}: 
{expected_reason}"
+        )
+        mock_client.connections.bulk.assert_not_called()
+
     @pytest.mark.parametrize(
         ("action_on_existing_key", "expected_enum"),
         [

Reply via email to