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 35fe4f5f47f Accept Airflow 3.4 Dag warning types and plugin 
destinations in airflowctl (#76119)
35fe4f5f47f is described below

commit 35fe4f5f47ff5454a5abe1dfc9c2e871476561a1
Author: Shahar Epstein <[email protected]>
AuthorDate: Sat Oct 31 09:29:56 2026 +0200

    Accept Airflow 3.4 Dag warning types and plugin destinations in airflowctl 
(#76119)
    
    Airflow 3.4.0rc1 servers emit enum values that the airflowctl client models 
reject, so "airflowctl dags list-warning" and "airflowctl plugins list" crash 
with a pydantic ValidationError. Add the missing DagWarningType members, the 
missing plugin destinations, and accept importer-namespaced warning strings 
(for example "yaml:deprecated_field") in DAGWarningResponse.warning_type.
    
    generated.py was hand-edited to the 3.4.0rc1 spec on purpose. This branch's 
own OpenAPI spec is generated from airflow-core, which this branch does not 
release, and it does not carry the 3.4 values. A datamodel regeneration from 
the branch spec would drop the edit; the new tests guard against that.
    
    datamodel-codegen 0.41.0 run on the 3.4.0rc1 spec would produce 
"DagWarningType | WarningType" with WarningType a RootModel[str]. The plain str 
alternative was chosen instead so that warning_type == "yaml:x" stays a plain 
string comparison.
    
    Destination1 keeps its current name although the 3.4 spec renumbers it.
---
 .../src/airflowctl/api/datamodels/generated.py     | 11 +++-
 .../tests/airflow_ctl/api/test_operations.py       | 75 +++++++++++++++++++++-
 2 files changed, 84 insertions(+), 2 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index e0ed5f45c03..da7cb4d825e 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -443,8 +443,10 @@ class DagWarningType(str, Enum):
     """
 
     ASSET_CONFLICT = "asset conflict"
+    DUPLICATE_DAG_ID = "duplicate dag id"
     NON_EXISTENT_POOL = "non-existent pool"
     RUNTIME_VARYING_VALUE = "runtime varying value"
+    TASK_GROUP_CYCLE = "task group cycle"
 
 
 class DryRunBackfillResponse(BaseModel):
@@ -491,6 +493,7 @@ class Destination(str, Enum):
     DAG_RUN = "dag_run"
     TASK = "task"
     TASK_INSTANCE = "task_instance"
+    ASSET = "asset"
     BASE = "base"
 
 
@@ -716,8 +719,11 @@ class Destination1(str, Enum):
     DAG_RUN = "dag_run"
     TASK = "task"
     TASK_INSTANCE = "task_instance"
+    ASSET = "asset"
     BASE = "base"
     DASHBOARD = "dashboard"
+    DAG_OVERVIEW = "dag_overview"
+    TASK_OVERVIEW = "task_overview"
 
 
 class ReactAppResponse(BaseModel):
@@ -1532,7 +1538,10 @@ class DAGWarningResponse(BaseModel):
     """
 
     dag_id: Annotated[str, Field(title="Dag Id")]
-    warning_type: DagWarningType
+    warning_type: Annotated[
+        DagWarningType | Annotated[str, Field(max_length=50, 
pattern="^[a-z][a-z0-9_]*:[a-z0-9_.\\-]+$")],
+        Field(title="Warning Type"),
+    ]
     message: Annotated[str, Field(title="Message")]
     timestamp: Annotated[datetime, Field(title="Timestamp")]
     dag_display_name: Annotated[str, Field(title="Dag Display Name")]
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index ad701bec997..fb883b2e229 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -26,7 +26,7 @@ from unittest.mock import Mock
 
 import httpx
 import pytest
-from pydantic import BaseModel
+from pydantic import BaseModel, ValidationError
 
 from airflowctl.api.client import Client, ClientKind
 from airflowctl.api.datamodels.asset_state_store import (
@@ -1377,6 +1377,46 @@ class TestDagOperations:
         response = client.dags.list_warning()
         assert response == self.dag_warning_collection_response
 
+    @pytest.mark.parametrize(
+        "warning_type",
+        ["duplicate dag id", "task group cycle", "yaml:deprecated_field"],
+    )
+    def test_list_warning_accepts_airflow_3_4_warning_types(self, 
warning_type):
+        payload = 
json.loads(self.dag_warning_collection_response.model_dump_json())
+        payload["dag_warnings"][0]["warning_type"] = warning_type
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        response = client.dags.list_warning()
+        assert response.dag_warnings[0].warning_type == warning_type
+
+    @pytest.mark.parametrize(
+        ("warning_type", "accepted"),
+        [
+            ("a:" + "x" * 48, True),
+            ("yaml:some.field-1_x", True),
+            ("a:" + "x" * 49, False),
+            ("yaml:Bad", False),
+            ("YAML:x", False),
+            ("Something Else", False),
+        ],
+    )
+    def test_list_warning_namespaced_warning_type_pattern(self, warning_type, 
accepted):
+        payload = 
json.loads(self.dag_warning_collection_response.model_dump_json())
+        payload["dag_warnings"][0]["warning_type"] = warning_type
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        if accepted:
+            assert client.dags.list_warning().dag_warnings[0].warning_type == 
warning_type
+        else:
+            with pytest.raises(ValidationError):
+                client.dags.list_warning()
+
     def test_trigger(self):
         def handle_request(request: httpx.Request) -> httpx.Response:
             assert request.url.path == f"/api/v2/dags/{self.dag_id}/dagRuns"
@@ -2388,6 +2428,39 @@ class TestPluginsOperations:
         response = client.plugins.list()
         assert response == self.plugin_collection_response
 
+    @pytest.mark.parametrize(
+        ("view_kind", "destination"),
+        [
+            ("react_apps", "asset"),
+            ("react_apps", "dag_overview"),
+            ("react_apps", "task_overview"),
+            ("external_views", "asset"),
+        ],
+    )
+    def test_list_accepts_airflow_3_4_destinations(self, view_kind, 
destination):
+        payload = json.loads(self.plugin_collection_response.model_dump_json())
+        view = {"name": "v", "destination": destination}
+        view.update({"bundle_url": "/b.js"} if view_kind == "react_apps" else 
{"href": "/h"})
+        payload["plugins"][0][view_kind] = [view]
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        plugin = client.plugins.list().plugins[0]
+        assert getattr(plugin, view_kind)[0].destination == destination
+
+    def test_list_rejects_react_app_only_destination_on_external_view(self):
+        payload = json.loads(self.plugin_collection_response.model_dump_json())
+        payload["plugins"][0]["external_views"] = [{"name": "v", "href": "/h", 
"destination": "dag_overview"}]
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        with pytest.raises(ValidationError):
+            client.plugins.list()
+
     def test_list_import_errors(self):
         """Test listing plugin import errors"""
 

Reply via email to