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"""