This is an automated email from the ASF dual-hosted git repository.
potiuk 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 e9904306f1a Stop airflowctl from failing on fields added by newer API
servers (#75610)
e9904306f1a is described below
commit e9904306f1a00377ec56a13b0ebe0dfccfbfddfa
Author: Andrew Chang <[email protected]>
AuthorDate: Mon Oct 26 04:35:07 2026 +0900
Stop airflowctl from failing on fields added by newer API servers (#75610)
Seven core API models that only appear in responses, the config models
and the asset reference models, extend StrictBaseModel. So the OpenAPI
spec marks them "additionalProperties: false", and the airflowctl
models generated from it get extra="forbid". When a newer server adds
a field to one of them, an older airflowctl fails to parse the
response. #71446 added the optional team_name field to two asset
reference models for 3.4, and airflowctl from airflow-ctl/v0-1-test
now fails on "assets list" and "assets get" against 3.4.0b2.
StrictBaseModel is meant for request bodies, so that unknown fields
sent by a client are rejected (#44306). Response models are built by
the server, and new fields in them should not break existing clients.
---
.../api_fastapi/core_api/datamodels/assets.py | 8 +--
.../api_fastapi/core_api/datamodels/config.py | 8 +--
.../api_fastapi/core_api/openapi/_private_ui.yaml | 3 --
.../core_api/openapi/v2-rest-api-generated.yaml | 7 ---
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 7 ---
.../src/airflowctl/api/datamodels/generated.py | 21 --------
.../tests/airflow_ctl/api/test_operations.py | 63 ++++++++++++++++++++++
7 files changed, 71 insertions(+), 46 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
index 5fca001c8d1..34aa552d571 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/assets.py
@@ -45,7 +45,7 @@ if TYPE_CHECKING:
from airflow.serialization.definitions.dag import SerializedDAG
-class DagScheduleAssetReference(StrictBaseModel):
+class DagScheduleAssetReference(BaseModel):
"""Dag schedule reference serializer for assets."""
dag_id: str
@@ -54,7 +54,7 @@ class DagScheduleAssetReference(StrictBaseModel):
team_name: str | None = None
-class TaskInletAssetReference(StrictBaseModel):
+class TaskInletAssetReference(BaseModel):
"""Task inlet reference serializer for assets."""
dag_id: str
@@ -63,7 +63,7 @@ class TaskInletAssetReference(StrictBaseModel):
updated_at: datetime
-class TaskOutletAssetReference(StrictBaseModel):
+class TaskOutletAssetReference(BaseModel):
"""Task outlet reference serializer for assets."""
dag_id: str
@@ -178,7 +178,7 @@ class AssetAliasCollectionResponse(BaseModel):
total_entries: int
-class DagRunAssetReference(StrictBaseModel):
+class DagRunAssetReference(BaseModel):
"""DagRun serializer for asset responses."""
run_id: str
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/config.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/config.py
index 42a327431ce..c16aa98093f 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/config.py
@@ -16,10 +16,10 @@
# under the License.
from __future__ import annotations
-from airflow.api_fastapi.core_api.base import StrictBaseModel
+from airflow.api_fastapi.core_api.base import BaseModel
-class ConfigOption(StrictBaseModel):
+class ConfigOption(BaseModel):
"""Config option."""
key: str
@@ -32,7 +32,7 @@ class ConfigOption(StrictBaseModel):
return f"{self.key} = {self.value}"
-class ConfigSection(StrictBaseModel):
+class ConfigSection(BaseModel):
"""Config Section Schema."""
name: str
@@ -53,7 +53,7 @@ class ConfigSection(StrictBaseModel):
return f"[{self.name}]\n" + "\n".join(option.text_format for option in
self.options) + "\n"
-class Config(StrictBaseModel):
+class Config(BaseModel):
"""List of config sections with their options."""
sections: list[ConfigSection]
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 51320cabc1e..60d630f2a90 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -4039,7 +4039,6 @@ components:
- type: string
- type: 'null'
title: Team Name
- additionalProperties: false
type: object
required:
- dag_id
@@ -5808,7 +5807,6 @@ components:
type: string
format: date-time
title: Updated At
- additionalProperties: false
type: object
required:
- dag_id
@@ -6157,7 +6155,6 @@ components:
- type: string
- type: 'null'
title: Team Name
- additionalProperties: false
type: object
required:
- dag_id
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index aecd0bdeaf0..56cd6079999 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -14220,7 +14220,6 @@ components:
$ref: '#/components/schemas/ConfigSection'
type: array
title: Sections
- additionalProperties: false
type: object
required:
- sections
@@ -14241,7 +14240,6 @@ components:
maxItems: 2
minItems: 2
title: Value
- additionalProperties: false
type: object
required:
- key
@@ -14258,7 +14256,6 @@ components:
$ref: '#/components/schemas/ConfigOption'
type: array
title: Options
- additionalProperties: false
type: object
required:
- name
@@ -15872,7 +15869,6 @@ components:
description: Whether this asset event triggered the referenced dag
run.
Only a run's most recent consumed asset event triggers it; earlier
consumed
events are included in the run but did not trigger it.
- additionalProperties: false
type: object
required:
- run_id
@@ -15953,7 +15949,6 @@ components:
- type: string
- type: 'null'
title: Team Name
- additionalProperties: false
type: object
required:
- dag_id
@@ -17799,7 +17794,6 @@ components:
type: string
format: date-time
title: Updated At
- additionalProperties: false
type: object
required:
- dag_id
@@ -18517,7 +18511,6 @@ components:
- type: string
- type: 'null'
title: Team Name
- additionalProperties: false
type: object
required:
- dag_id
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index ccbdf81c024..d121d520836 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -2587,7 +2587,6 @@ export const $Config = {
title: 'Sections'
}
},
- additionalProperties: false,
type: 'object',
required: ['sections'],
title: 'Config',
@@ -2622,7 +2621,6 @@ export const $ConfigOption = {
title: 'Value'
}
},
- additionalProperties: false,
type: 'object',
required: ['key', 'value'],
title: 'ConfigOption',
@@ -2643,7 +2641,6 @@ export const $ConfigSection = {
title: 'Options'
}
},
- additionalProperties: false,
type: 'object',
required: ['name', 'options'],
title: 'ConfigSection',
@@ -5203,7 +5200,6 @@ export const $DagRunAssetReference = {
description: "Whether this asset event triggered the referenced
dag run. Only a run's most recent consumed asset event triggers it; earlier
consumed events are included in the run but did not trigger it."
}
},
- additionalProperties: false,
type: 'object',
required: ['run_id', 'dag_id', 'logical_date', 'start_date', 'end_date',
'state', 'data_interval_start', 'data_interval_end', 'partition_key',
'triggering'],
title: 'DagRunAssetReference',
@@ -5270,7 +5266,6 @@ export const $DagScheduleAssetReference = {
title: 'Team Name'
}
},
- additionalProperties: false,
type: 'object',
required: ['dag_id', 'created_at', 'updated_at'],
title: 'DagScheduleAssetReference',
@@ -7843,7 +7838,6 @@ export const $TaskInletAssetReference = {
title: 'Updated At'
}
},
- additionalProperties: false,
type: 'object',
required: ['dag_id', 'task_id', 'created_at', 'updated_at'],
title: 'TaskInletAssetReference',
@@ -8994,7 +8988,6 @@ export const $TaskOutletAssetReference = {
title: 'Team Name'
}
},
- additionalProperties: false,
type: 'object',
required: ['dag_id', 'task_id', 'created_at', 'updated_at'],
title: 'TaskOutletAssetReference',
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 11f82b3dbf9..586808694c7 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -355,9 +355,6 @@ class ConfigOption(BaseModel):
Config option.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
key: Annotated[str, Field(title="Key")]
value: Annotated[str | Value, Field(title="Value")]
@@ -367,9 +364,6 @@ class ConfigSection(BaseModel):
Config Section Schema.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
name: Annotated[str, Field(title="Name")]
options: Annotated[list[ConfigOption], Field(title="Options")]
@@ -697,9 +691,6 @@ class DagRunAssetReference(BaseModel):
DagRun serializer for asset responses.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
run_id: Annotated[str, Field(title="Run Id")]
dag_id: Annotated[str, Field(title="Dag Id")]
logical_date: Annotated[datetime | None, Field(title="Logical Date")]
@@ -776,9 +767,6 @@ class DagScheduleAssetReference(BaseModel):
Dag schedule reference serializer for assets.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
dag_id: Annotated[str, Field(title="Dag Id")]
created_at: Annotated[datetime, Field(title="Created At")]
updated_at: Annotated[datetime, Field(title="Updated At")]
@@ -1359,9 +1347,6 @@ class TaskInletAssetReference(BaseModel):
Task inlet reference serializer for assets.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
dag_id: Annotated[str, Field(title="Dag Id")]
task_id: Annotated[str, Field(title="Task Id")]
created_at: Annotated[datetime, Field(title="Created At")]
@@ -1444,9 +1429,6 @@ class TaskOutletAssetReference(BaseModel):
Task outlet reference serializer for assets.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
dag_id: Annotated[str, Field(title="Dag Id")]
task_id: Annotated[str, Field(title="Task Id")]
created_at: Annotated[datetime, Field(title="Created At")]
@@ -2218,9 +2200,6 @@ class Config(BaseModel):
List of config sections with their options.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
sections: Annotated[list[ConfigSection], Field(title="Sections")]
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index bc08046279b..7c369038e5b 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -69,6 +69,7 @@ from airflowctl.api.datamodels.generated import (
DagRunState,
DagRunTriggeredByType,
DagRunType,
+ DagScheduleAssetReference,
DagStatsCollectionResponse,
DagStatsResponse,
DagStatsStateResponse,
@@ -97,9 +98,11 @@ from airflowctl.api.datamodels.generated import (
ReprocessBehavior,
TaskDependencyCollectionResponse,
TaskDependencyResponse,
+ TaskInletAssetReference,
TaskInstanceCollectionResponse,
TaskInstanceResponse,
TaskInstanceState,
+ TaskOutletAssetReference,
TriggerDAGRunPostBody,
VariableBody,
VariableCollectionResponse,
@@ -403,6 +406,38 @@ class TestAssetsOperations:
response = client.assets.get(self.asset_id)
assert response == self.asset_response
+ def test_get_asset_ignores_fields_added_by_newer_servers(self):
+ created_at = datetime.datetime(2024, 12, 31, 23, 59, 59)
+ updated_at = datetime.datetime(2025, 1, 1, 0, 0, 0)
+ expected = self.asset_response.model_copy(
+ update={
+ "scheduled_dags": [
+ DagScheduleAssetReference(
+ dag_id=self.dag_id, created_at=created_at,
updated_at=updated_at
+ )
+ ],
+ "producing_tasks": [
+ TaskOutletAssetReference(
+ dag_id=self.dag_id, task_id="task_id",
created_at=created_at, updated_at=updated_at
+ )
+ ],
+ "consuming_tasks": [
+ TaskInletAssetReference(
+ dag_id=self.dag_id, task_id="task_id",
created_at=created_at, updated_at=updated_at
+ )
+ ],
+ }
+ )
+ asset_json = json.loads(expected.model_dump_json())
+ for field in ("scheduled_dags", "producing_tasks", "consuming_tasks"):
+ asset_json[field][0]["field_from_newer_server"] = "value"
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json=asset_json)
+
+ client = make_api_client(transport=httpx.MockTransport(handle_request))
+ assert client.assets.get(self.asset_id) == expected
+
def test_get_alias(self):
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.url.path ==
f"/api/v2/assets/aliases/{self.asset_alias_id}"
@@ -465,6 +500,17 @@ class TestAssetsOperations:
response =
client.assets.create_event(asset_event_body=self.asset_create_event_body)
assert response == asset_event_response
+ def test_create_event_ignores_fields_added_by_newer_servers(self):
+ event_json = json.loads(self.asset_event_response.model_dump_json())
+ event_json["created_dagruns"][0]["field_from_newer_server"] = "value"
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json=event_json)
+
+ client = make_api_client(transport=httpx.MockTransport(handle_request))
+ response =
client.assets.create_event(asset_event_body=self.asset_create_event_body)
+ assert response == self.asset_event_response
+
def test_materialize(self):
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.url.path ==
f"/api/v2/assets/{self.asset_id}/materialize"
@@ -758,6 +804,23 @@ class TestConfigOperations:
response = client.configs.list()
assert response == response_config
+ def test_get_ignores_fields_added_by_newer_servers(self):
+ response_config = Config(
+ sections=[
+ ConfigSection(name=self.section,
options=[ConfigOption(key=self.option, value="config")])
+ ]
+ )
+ config_json = response_config.model_dump()
+ config_json["field_from_newer_server"] = "value"
+ config_json["sections"][0]["field_from_newer_server"] = "value"
+ config_json["sections"][0]["options"][0]["field_from_newer_server"] =
"value"
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json=config_json)
+
+ client = make_api_client(transport=httpx.MockTransport(handle_request))
+ assert client.configs.get(section=self.section, option=self.option) ==
response_config
+
def test_get_masked_value(self):
"""Verify that masked values from API are preserved in get()
operation."""
response_config = Config(