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 071c5171443 [airflow-ctl/v0-1-test] Report invalid pools in airflowctl 
pools import as readable errors (#76173) (#76204)
071c5171443 is described below

commit 071c5171443ca9f5ad27baef81301979bb74e746
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Nov 1 13:18:49 2026 +0200

    [airflow-ctl/v0-1-test] Report invalid pools in airflowctl pools import as 
readable errors (#76173) (#76204)
    
    * Report invalid pools in airflowctl pools import as readable errors
    
    A pool entry with a bad value, such as non-integer slots or slots below
    -1, crashed `airflowctl pools import` with a raw pydantic traceback that
    did not say which pool was wrong. Report the pool name, the file and the
    reason instead, as `airflowctl connections import` already does.
    
    * Test more invalid pool cases in airflowctl pools import
    
    A pool file with a valid pool followed by an invalid one must not send
    the valid pool, and a pool with several invalid values must report all
    of them. Neither case was covered by the new error handling.
    
    ---------
    (cherry picked from commit 4eba3515cee9cd8a625def72d4aff851fc8e9657)
    
    Co-authored-by: Rishabh <[email protected]>
    Co-authored-by: Shahar Epstein <[email protected]>
---
 .../src/airflowctl/ctl/commands/pool_command.py    | 31 ++++++++----
 .../airflow_ctl/ctl/commands/test_pool_command.py  | 56 ++++++++++++++++++++++
 2 files changed, 78 insertions(+), 9 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
index 8815827ab6f..588656a7bf8 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
@@ -23,6 +23,7 @@ from json import JSONDecodeError
 from pathlib import Path
 
 import rich
+from pydantic import ValidationError
 
 from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind, 
provide_api_client
 from airflowctl.api.datamodels.generated import (
@@ -32,6 +33,7 @@ from airflowctl.api.datamodels.generated import (
     PoolBody,
 )
 from airflowctl.ctl.console_formatting import AirflowConsole
+from airflowctl.ctl.utils.validation import split_validation_errors
 
 
 @provide_api_client(kind=ClientKind.CLI)
@@ -84,6 +86,25 @@ def export(args, api_client: Client = NEW_API_CLIENT) -> 
None:
         raise SystemExit(f"Failed to export pools: {e}")
 
 
+def _build_pool_body(pool_config: dict, filepath: Path) -> PoolBody:
+    """Build a ``PoolBody``, exiting with a readable message naming the pool 
if it is invalid."""
+    try:
+        return PoolBody(
+            name=pool_config["name"],
+            slots=pool_config["slots"],
+            description=pool_config.get("description", ""),
+            include_deferred=pool_config.get("include_deferred", False),
+            team_name=pool_config.get("team_name"),
+        )
+    except ValidationError as e:
+        missing, invalid = split_validation_errors(PoolBody, e, str)
+        messages = []
+        if missing:
+            messages.append(f"missing required field(s): {', '.join(missing)}")
+        messages.extend(invalid)
+        raise SystemExit(f"Invalid pool {pool_config['name']!r} in {filepath}: 
" + "; ".join(messages))
+
+
 def _import_helper(api_client: Client, filepath: Path, action_on_existence: 
BulkActionOnExistence):
     """Help import pools from the json file."""
     try:
@@ -100,15 +121,7 @@ def _import_helper(api_client: Client, filepath: Path, 
action_on_existence: Bulk
         if not isinstance(pool_config, dict) or "name" not in pool_config or 
"slots" not in pool_config:
             raise SystemExit(f"Invalid pool configuration: {pool_config}")
 
-        pools_to_update.append(
-            PoolBody(
-                name=pool_config["name"],
-                slots=pool_config["slots"],
-                description=pool_config.get("description", ""),
-                include_deferred=pool_config.get("include_deferred", False),
-                team_name=pool_config.get("team_name"),
-            )
-        )
+        pools_to_update.append(_build_pool_body(pool_config, filepath))
 
     bulk_body = BulkBodyPoolBody(
         actions=[
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
index 15ce83e7af1..f66350067a1 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
@@ -64,6 +64,62 @@ class TestPoolImportCommand:
         with pytest.raises(SystemExit, match="Invalid pool configuration: 
{'invalid': 'config'}"):
             pool_command.import_(mock.MagicMock(file=invalid_pool, 
action_on_existing_key="fail"))
 
+    @pytest.mark.parametrize(
+        ("pool_config", "expected_reason"),
+        [
+            (
+                {"name": "bad_pool", "slots": "many"},
+                "slots: Input should be a valid integer, unable to parse 
string as an integer",
+            ),
+            ({"name": "bad_pool", "slots": -5}, "slots: Input should be 
greater than or equal to -1"),
+            (
+                {"name": "bad_pool", "slots": 1, "include_deferred": "maybe"},
+                "include_deferred: Input should be a valid boolean, unable to 
interpret input",
+            ),
+            (
+                {"name": "bad_pool", "slots": "many", "include_deferred": 
"maybe"},
+                "slots: Input should be a valid integer, unable to parse 
string as an integer; "
+                "include_deferred: Input should be a valid boolean, unable to 
interpret input",
+            ),
+            ({"name": None, "slots": 1}, "missing required field(s): name"),
+        ],
+        ids=[
+            "non-integer-slots",
+            "slots-below-minimum",
+            "non-boolean-include-deferred",
+            "non-integer-slots-and-non-boolean-include-deferred",
+            "null-name",
+        ],
+    )
+    def test_import_reports_invalid_pool(self, mock_client, tmp_path, 
pool_config, expected_reason):
+        """Test that an invalid pool value is reported as a readable error 
instead of a traceback."""
+        pools_file = tmp_path / "pools.json"
+        pools_file.write_text(json.dumps([pool_config]))
+
+        with pytest.raises(SystemExit) as exc_info:
+            pool_command.import_(mock.MagicMock(file=pools_file, 
action_on_existing_key="fail"))
+
+        assert (
+            str(exc_info.value) == f"Invalid pool {pool_config['name']!r} in 
{pools_file}: {expected_reason}"
+        )
+        mock_client.pools.bulk.assert_not_called()
+
+    def test_import_reports_invalid_pool_after_valid_one(self, mock_client, 
tmp_path):
+        """Test that an invalid pool stops the whole import, so the valid pool 
before it is not sent."""
+        pools_file = tmp_path / "pools.json"
+        pools_file.write_text(
+            json.dumps([{"name": "good_pool", "slots": 1}, {"name": 
"bad_pool", "slots": "many"}])
+        )
+
+        with pytest.raises(SystemExit) as exc_info:
+            pool_command.import_(mock.MagicMock(file=pools_file, 
action_on_existing_key="fail"))
+
+        assert str(exc_info.value) == (
+            f"Invalid pool 'bad_pool' in {pools_file}: "
+            "slots: Input should be a valid integer, unable to parse string as 
an integer"
+        )
+        mock_client.pools.bulk.assert_not_called()
+
     def test_import_success(self, mock_client, tmp_path, capsys):
         """Test successful pool import."""
         pools_file = tmp_path / "pools.json"

Reply via email to