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 c5645e31e8b [airflow-ctl/v0-1-test] Stop airflowctl from failing on
fields added by newer API servers (#75610) (#75754)
c5645e31e8b is described below
commit c5645e31e8b7a42af2b68a81d8ba098c4b28b32c
Author: Andrew Chang <[email protected]>
AuthorDate: Tue Oct 27 22:54:51 2026 +0900
[airflow-ctl/v0-1-test] Stop airflowctl from failing on fields added by
newer API servers (#75610) (#75754)
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.
(cherry picked from commit e9904306f1a00377ec56a13b0ebe0dfccfbfddfa)
---
.../api_fastapi/core_api/datamodels/assets.py | 8 +--
.../api_fastapi/core_api/datamodels/config.py | 8 +--
.../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 ++++++++++++++++++++++
6 files changed, 71 insertions(+), 43 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 f463f3ba5e3..b7f15732dca 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
@@ -33,7 +33,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
@@ -41,7 +41,7 @@ class DagScheduleAssetReference(StrictBaseModel):
updated_at: datetime
-class TaskInletAssetReference(StrictBaseModel):
+class TaskInletAssetReference(BaseModel):
"""Task inlet reference serializer for assets."""
dag_id: str
@@ -50,7 +50,7 @@ class TaskInletAssetReference(StrictBaseModel):
updated_at: datetime
-class TaskOutletAssetReference(StrictBaseModel):
+class TaskOutletAssetReference(BaseModel):
"""Task outlet reference serializer for assets."""
dag_id: str
@@ -119,7 +119,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/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 256cf21a001..b1ff3742ddd 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
@@ -9923,7 +9923,6 @@ components:
$ref: '#/components/schemas/ConfigSection'
type: array
title: Sections
- additionalProperties: false
type: object
required:
- sections
@@ -9944,7 +9943,6 @@ components:
maxItems: 2
minItems: 2
title: Value
- additionalProperties: false
type: object
required:
- key
@@ -9961,7 +9959,6 @@ components:
$ref: '#/components/schemas/ConfigOption'
type: array
title: Options
- additionalProperties: false
type: object
required:
- name
@@ -11110,7 +11107,6 @@ components:
- type: string
- type: 'null'
title: Partition Key
- additionalProperties: false
type: object
required:
- run_id
@@ -11176,7 +11172,6 @@ components:
type: string
format: date-time
title: Updated At
- additionalProperties: false
type: object
required:
- dag_id
@@ -12535,7 +12530,6 @@ components:
type: string
format: date-time
title: Updated At
- additionalProperties: false
type: object
required:
- dag_id
@@ -13149,7 +13143,6 @@ components:
type: string
format: date-time
title: Updated At
- 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 583b7728b62..00c5c22b509 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
@@ -1401,7 +1401,6 @@ export const $Config = {
title: 'Sections'
}
},
- additionalProperties: false,
type: 'object',
required: ['sections'],
title: 'Config',
@@ -1436,7 +1435,6 @@ export const $ConfigOption = {
title: 'Value'
}
},
- additionalProperties: false,
type: 'object',
required: ['key', 'value'],
title: 'ConfigOption',
@@ -1457,7 +1455,6 @@ export const $ConfigSection = {
title: 'Options'
}
},
- additionalProperties: false,
type: 'object',
required: ['name', 'options'],
title: 'ConfigSection',
@@ -3284,7 +3281,6 @@ export const $DagRunAssetReference = {
title: 'Partition Key'
}
},
- additionalProperties: false,
type: 'object',
required: ['run_id', 'dag_id', 'logical_date', 'start_date', 'end_date',
'state', 'data_interval_start', 'data_interval_end', 'partition_key'],
title: 'DagRunAssetReference',
@@ -3333,7 +3329,6 @@ export const $DagScheduleAssetReference = {
title: 'Updated At'
}
},
- additionalProperties: false,
type: 'object',
required: ['dag_id', 'created_at', 'updated_at'],
title: 'DagScheduleAssetReference',
@@ -5278,7 +5273,6 @@ export const $TaskInletAssetReference = {
title: 'Updated At'
}
},
- additionalProperties: false,
type: 'object',
required: ['dag_id', 'task_id', 'created_at', 'updated_at'],
title: 'TaskInletAssetReference',
@@ -6274,7 +6268,6 @@ export const $TaskOutletAssetReference = {
title: 'Updated At'
}
},
- 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 43719cbb096..8824a8484ed 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -184,9 +184,6 @@ class ConfigOption(BaseModel):
Config option.
"""
- model_config = ConfigDict(
- extra="forbid",
- )
key: Annotated[str, Field(title="Key")]
value: Annotated[str | Value, Field(title="Value")]
@@ -196,9 +193,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")]
@@ -342,9 +336,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")] =
None
@@ -403,9 +394,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")]
@@ -795,9 +783,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")]
@@ -879,9 +864,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")]
@@ -1341,9 +1323,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 35f59563310..d00e6d911e0 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -70,6 +70,7 @@ from airflowctl.api.datamodels.generated import (
DagRunState,
DagRunTriggeredByType,
DagRunType,
+ DagScheduleAssetReference,
DagStatsCollectionResponse,
DagStatsResponse,
DagStatsStateResponse,
@@ -96,6 +97,8 @@ from airflowctl.api.datamodels.generated import (
QueuedEventCollectionResponse,
QueuedEventResponse,
ReprocessBehavior,
+ TaskInletAssetReference,
+ TaskOutletAssetReference,
TriggerDAGRunPostBody,
VariableBody,
VariableCollectionResponse,
@@ -391,6 +394,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_by_alias(self):
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.url.path ==
f"/api/v2/assets/aliases/{self.asset_id}"
@@ -437,6 +472,17 @@ class TestAssetsOperations:
response =
client.assets.create_event(asset_event_body=self.asset_create_event_body)
assert response == self.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"
@@ -693,6 +739,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(