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 d1ae40ff1ef [airflow-ctl/v0-1-test] Report missing and invalid fields
in airflowctl connections import (#76090) (#76141)
d1ae40ff1ef is described below
commit d1ae40ff1effb20738faeb134c5cdc43d5e91461
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sat Oct 31 16:22:03 2026 +0200
[airflow-ctl/v0-1-test] Report missing and invalid fields in airflowctl
connections import (#76090) (#76141)
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.
(cherry picked from commit 2e8f6f983b4509493917586c6ce59c7cb98018d1)
Co-authored-by: Shahar Epstein <[email protected]>
---
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 92835d36fae..ca9dd42d12a 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,
@@ -855,17 +856,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 2f2b9274e17..3178a08339a 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
@@ -245,6 +245,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"),
[