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(

Reply via email to