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(

Reply via email to