This is an automated email from the ASF dual-hosted git repository.

shahar1 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 740d8bda336 Fix airflowctl commands failing against older Airflow 
servers (#70954)
740d8bda336 is described below

commit 740d8bda336e226d2f12257f2a4e84742453fd07
Author: Wei Lee <[email protected]>
AuthorDate: Sat Oct 31 06:15:49 2026 +0900

    Fix airflowctl commands failing against older Airflow servers (#70954)
    
    The airflowctl datamodels are generated from the newest Airflow API spec,
    so a server on an older Airflow line legitimately omits fields those models
    declare as required, and every command touching such a response aborts on a
    validation error.
    
    A field the server did not send is left unset rather than filled in with a
    type default: a synthesized False, 0 or "" is indistinguishable from a value
    the server really sent and would flow into --output json unnoticed. Fields
    under a union of several models are the exception and stay strict, because
    pydantic picks a union member by shape and an all-optional member would
    absorb a payload meant for a different one. Responses that disagree with the
    models for any other reason still fail, so a genuine defect on either side
    keeps surfacing.
---
 airflow-ctl/src/airflowctl/api/operations.py       | 254 +++++++++------
 .../tests/airflow_ctl/api/test_operations.py       | 353 ++++++++++++++++++++-
 2 files changed, 510 insertions(+), 97 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/api/operations.py 
b/airflow-ctl/src/airflowctl/api/operations.py
index 4e0c9f962ce..8c62ed3d98e 100644
--- a/airflow-ctl/src/airflowctl/api/operations.py
+++ b/airflow-ctl/src/airflowctl/api/operations.py
@@ -18,12 +18,17 @@
 from __future__ import annotations
 
 import datetime
+import functools
 import json
-from typing import TYPE_CHECKING, Any, TypeVar, get_args
+import operator
+import sys
+import types
+import typing
+from typing import TYPE_CHECKING, Any, TypeVar, cast, get_args, get_origin
 
 import httpx
-import structlog
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel, RootModel, ValidationError, create_model
+from pydantic.fields import FieldInfo
 
 from airflowctl.api.datamodels.auth_generated import LoginBody, LoginResponse
 from airflowctl.api.datamodels.generated import (
@@ -95,7 +100,6 @@ from airflowctl.exceptions import 
AirflowCtlConnectionException
 if TYPE_CHECKING:
     from airflowctl.api.client import Client
 
-log = structlog.get_logger(logger_name=__name__)
 
 T = TypeVar("T", bound=BaseModel)
 
@@ -177,41 +181,106 @@ def _check_flag_and_exit_if_server_response_error(func):
     return wrapped
 
 
-TYPE_DEFAULTS = {
-    bool: False,
-    int: 0,
-    float: 0.0,
-    str: "",
-    list: [],
-    dict: {},
-}
+# Cache of tolerant twins, keyed by the strict model they were built from.
+_TOLERANT_MODELS: dict[type[BaseModel], type[BaseModel]] = {}
 
 
-def get_field_default(annotation) -> Any:
+def _is_polymorphic_union(annotation: Any) -> bool:
+    """
+    Return whether ``annotation`` is a union with two or more non-``None`` 
members.
+
+    E.g. ``AssetExpressionAsset | AssetExpressionAlias``. ``X | None`` (one 
real member) is a
+    plain optional and must still be rewritten. A union of two or more models 
is different:
+    pydantic's smart-union picks whichever member the payload merely *fits*, 
and every field
+    of a tolerant twin fits trivially (they are all optional). Put a twin in 
such a union and
+    a payload shaped for member B can silently validate as an empty member A 
instead of
+    raising -- the wrong-shape response is swallowed rather than surfaced. The 
cost of
+    leaving such a union strict is that a field missing anywhere beneath it is 
not
+    tolerated: it surfaces as a validation error rather than degrading to 
``None``.
+    """
+    if get_origin(annotation) not in (typing.Union, types.UnionType):
+        return False
+    return len([arg for arg in get_args(annotation) if arg is not type(None)]) 
>= 2
+
+
+def _tolerate_missing(annotation: Any) -> Any:
+    """Rewrite ``annotation``, swapping any nested response model for its 
tolerant twin."""
+    if _is_polymorphic_union(annotation):
+        return annotation
+    if isinstance(annotation, type) and issubclass(annotation, BaseModel):
+        return _tolerant_model(annotation)
+    origin = get_origin(annotation)
+    if origin is None:
+        return annotation
     args = get_args(annotation)
-    if args:
-        non_none = [a for a in args if a is not type(None)]
-        if non_none:
-            return get_field_default(non_none[0])
-    return TYPE_DEFAULTS.get(annotation, None)
-
-
-def fill_missing_fields(data: dict, model: type[BaseModel]) -> dict:
-    for field_name, field_info in model.model_fields.items():
-        annotation = field_info.annotation
-        args = get_args(annotation)
-        if field_name not in data and field_info.is_required():
-            data[field_name] = get_field_default(annotation)
-        elif field_name in data and isinstance(data[field_name], dict):
-            if isinstance(annotation, type) and issubclass(annotation, 
BaseModel):
-                data[field_name] = fill_missing_fields(data[field_name], 
annotation)
-        elif field_name in data and isinstance(data[field_name], list) and 
args:
-            if isinstance(args[0], type) and issubclass(args[0], BaseModel):
-                data[field_name] = [
-                    fill_missing_fields(item, args[0]) if isinstance(item, 
dict) else item
-                    for item in data[field_name]
-                ]
-    return data
+    rewritten = [_tolerate_missing(arg) for arg in args]
+    if all(new_arg is old_arg for new_arg, old_arg in zip(rewritten, args)):
+        return annotation
+    if origin in (typing.Union, types.UnionType):
+        return functools.reduce(operator.or_, rewritten)
+    return origin[tuple(rewritten)]
+
+
+def _tolerant_model(model: type[BaseModel]) -> type[BaseModel]:
+    """
+    Build (or fetch the cached) tolerant twin of ``model``.
+
+    The twin is a ``pydantic.create_model`` subclass of ``model`` with every 
required field
+    -- including ones nested inside it, recursively -- widened to ``X | None`` 
defaulting to
+    ``None``. A field an older server omits is then left honestly unset 
instead of being
+    synthesized to a type default that would be indistinguishable from a real 
value. Because
+    the twin subclasses the original model, ``isinstance(twin_instance, 
model)`` still holds.
+
+    A ``RootModel`` wraps a single scalar rather than named fields, so "a 
field is missing"
+    has no meaning for it; it is returned unchanged. Polymorphic unions (see
+    ``_is_polymorphic_union``) are never rewritten. The generated datamodels 
contain mutually
+    self-referential schemas (``AssetExpressionAll`` <-> 
``AssetExpressionAny``), but every
+    path back to a model already being built currently runs through such a 
union, so the
+    recursion terminates; the smoke test over all generated models guards this.
+    """
+    if model in _TOLERANT_MODELS:
+        return _TOLERANT_MODELS[model]
+    if issubclass(model, RootModel):
+        return model
+    overrides: dict[str, Any] = {}
+    for field_name, field in model.model_fields.items():
+        annotation = _tolerate_missing(field.annotation)
+        if field.is_required():
+            overrides[field_name] = (annotation | None, 
FieldInfo.merge_field_infos(field, default=None))
+        elif annotation is not field.annotation:
+            overrides[field_name] = (annotation, field)
+    twin = create_model(f"Partial{model.__name__}", __base__=model, 
**overrides) if overrides else model
+    _TOLERANT_MODELS[model] = twin
+    return twin
+
+
+def validate_response(content: bytes, data_model: type[T]) -> T:
+    """
+    Validate a server response, tolerating fields an older server does not 
send.
+
+    The datamodels are generated from the newest Airflow API spec, so a server 
on an
+    older Airflow line can legitimately omit fields the models declare as 
required. Any
+    such field is left ``None`` -- never synthesized to a type default 
(``False``, ``0``,
+    ``""``, ...), which would be indistinguishable from a real value the 
server actually
+    sent. Any other validation error means the response genuinely disagrees 
with the
+    model, which is a defect on one side or the other and must surface instead 
of being
+    papered over.
+    """
+    try:
+        return data_model.model_validate_json(content)
+    except ValidationError as validation_error:
+        errors = validation_error.errors()
+        if any(error["type"] != "missing" for error in errors):
+            raise
+        tolerant = cast("T", 
_tolerant_model(data_model).model_validate_json(content))
+        # stderr, not the logger: structlog is unconfigured here and would 
write to stdout,
+        # corrupting ``--output json``.
+        fields = [".".join(str(part) for part in error["loc"]) for error in 
errors]
+        sys.stderr.write(
+            f"Warning: {data_model.__name__} response omitted fields {fields}; 
leaving them unset (None). "
+            "The server is likely on an older Airflow line than these 
datamodels.\n"
+        )
+        return tolerant
 
 
 class BaseOperations:
@@ -242,17 +311,12 @@ class BaseOperations:
 
         shared_params = {"limit": limit, **(params or {})}
 
-        def safe_validate(content: bytes) -> BaseModel:
-            try:
-                return data_model.model_validate_json(content)
-            except ValidationError:
-                raw = fill_missing_fields(json.loads(content), data_model)
-                return data_model.model_validate(raw)
-
         self.response = self.client.get(path, params={**shared_params, 
"offset": offset})
-        first_pass = safe_validate(self.response.content)
+        first_pass = validate_response(self.response.content, data_model)
         total_entries = first_pass.total_entries
-        if total_entries < limit:
+        # An older server that omits total_entries leaves it None; treat that 
as "no more
+        # pages" rather than raising on the comparison below.
+        if total_entries is None or total_entries < limit:
             return first_pass
         found_key = None
         for key, value in first_pass.model_dump().items():
@@ -263,7 +327,7 @@ class BaseOperations:
         offset = offset + limit
         while offset < total_entries:
             self.response = self.client.get(path, params={**shared_params, 
"offset": offset})
-            entry = safe_validate(self.response.content)
+            entry = validate_response(self.response.content, data_model)
             offset = offset + limit
             entry_list.extend(getattr(entry, found_key))
         return data_model(**{found_key: entry_list, "total_entries": 
total_entries})
@@ -290,12 +354,12 @@ class AssetsOperations(BaseOperations):
     def get(self, asset_id: str) -> AssetResponse | ServerResponseError:
         """Get an asset from the API server."""
         self.response = self.client.get(f"assets/{asset_id}")
-        return AssetResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, AssetResponse)
 
     def get_alias(self, asset_alias_id: str) -> AssetAliasResponse | 
ServerResponseError:
         """Get an asset alias by its ID from the API server."""
         self.response = self.client.get(f"assets/aliases/{asset_alias_id}")
-        return AssetAliasResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, AssetAliasResponse)
 
     def list(self) -> AssetCollectionResponse | ServerResponseError:
         """List all assets from the API server."""
@@ -315,29 +379,29 @@ class AssetsOperations(BaseOperations):
         self.response = self.client.post(
             "assets/events", json=asset_event_body.model_dump(mode="json", 
exclude_none=True)
         )
-        return AssetEventResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, AssetEventResponse)
 
     def materialize(self, asset_id: str) -> DAGRunResponse | 
ServerResponseError:
         """Materialize an asset."""
         self.response = self.client.post(f"assets/{asset_id}/materialize", 
json={})
-        return DAGRunResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, DAGRunResponse)
 
     def get_queued_events(self, asset_id: str) -> 
QueuedEventCollectionResponse | ServerResponseError:
         """Get queued events for an asset."""
         self.response = self.client.get(f"assets/{asset_id}/queuedEvents")
-        return 
QueuedEventCollectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
QueuedEventCollectionResponse)
 
     def get_dag_queued_events(
         self, dag_id: str, before: str
     ) -> QueuedEventCollectionResponse | ServerResponseError:
         """Get queued events for a dag."""
         self.response = self.client.get(f"dags/{dag_id}/assets/queuedEvents", 
params={"before": before})
-        return 
QueuedEventCollectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
QueuedEventCollectionResponse)
 
     def get_dag_queued_event(self, dag_id: str, asset_id: str) -> 
QueuedEventResponse | ServerResponseError:
         """Get a queued event for a dag."""
         self.response = 
self.client.get(f"dags/{dag_id}/assets/{asset_id}/queuedEvents")
-        return QueuedEventResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, QueuedEventResponse)
 
     def delete_queued_events(self, asset_id: str) -> str | ServerResponseError:
         """Delete a queued event for an asset."""
@@ -363,7 +427,7 @@ class AssetsOperations(BaseOperations):
     def get_state_store(self, asset_id: str, key: str) -> 
AssetStateStoreResponse | ServerResponseError:
         """Get a single asset state store entry."""
         self.response = self.client.get(f"assets/{asset_id}/state-store/{key}")
-        return 
AssetStateStoreResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
AssetStateStoreResponse)
 
     def set_state_store(self, asset_id: str, key: str, value: str) -> str | 
ServerResponseError:
         """Set an asset state store value. Creates or overwrites the key."""
@@ -393,7 +457,7 @@ class BackfillOperations(BaseOperations):
         self.response = self.client.post(
             "backfills", json=backfill.model_dump(mode="json", 
exclude_none=True, exclude_defaults=True)
         )
-        return BackfillResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BackfillResponse)
 
     def create_dry_run(self, backfill: BackfillPostBody) -> BackfillResponse | 
ServerResponseError:
         """Create a dry run backfill."""
@@ -401,12 +465,12 @@ class BackfillOperations(BaseOperations):
             "backfills/dry_run",
             json=backfill.model_dump(mode="json", exclude_none=True, 
exclude_defaults=True),
         )
-        return BackfillResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BackfillResponse)
 
     def get(self, backfill_id: str) -> BackfillResponse | ServerResponseError:
         """Get a backfill."""
         self.response = self.client.get(f"backfills/{backfill_id}")
-        return BackfillResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BackfillResponse)
 
     def list(self, dag_id: str) -> BackfillCollectionResponse | 
ServerResponseError:
         """List all backfills."""
@@ -416,17 +480,17 @@ class BackfillOperations(BaseOperations):
     def pause(self, backfill_id: str) -> BackfillResponse | 
ServerResponseError:
         """Pause a backfill."""
         self.response = self.client.put(f"backfills/{backfill_id}/pause")
-        return BackfillResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BackfillResponse)
 
     def unpause(self, backfill_id: str) -> BackfillResponse | 
ServerResponseError:
         """Unpause a backfill."""
         self.response = self.client.put(f"backfills/{backfill_id}/unpause")
-        return BackfillResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BackfillResponse)
 
     def cancel(self, backfill_id: str) -> BackfillResponse | 
ServerResponseError:
         """Cancel a backfill."""
         self.response = self.client.put(f"backfills/{backfill_id}/cancel")
-        return BackfillResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BackfillResponse)
 
 
 class ConfigOperations(BaseOperations):
@@ -435,12 +499,12 @@ class ConfigOperations(BaseOperations):
     def get(self, section: str, option: str) -> Config | ServerResponseError:
         """Get a config from the API server."""
         self.response = 
self.client.get(f"/config/section/{section}/option/{option}")
-        return Config.model_validate_json(self.response.content)
+        return validate_response(self.response.content, Config)
 
     def list(self) -> Config | ServerResponseError:
         """List all configs from the API server."""
         self.response = self.client.get("/config")
-        return Config.model_validate_json(self.response.content)
+        return validate_response(self.response.content, Config)
 
 
 class ConnectionsOperations(BaseOperations):
@@ -449,7 +513,7 @@ class ConnectionsOperations(BaseOperations):
     def get(self, conn_id: str) -> ConnectionResponse | ServerResponseError:
         """Get a connection from the API server."""
         self.response = self.client.get(f"connections/{conn_id}")
-        return ConnectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, ConnectionResponse)
 
     def list(self) -> ConnectionCollectionResponse | ServerResponseError:
         """List all connections from the API server."""
@@ -463,14 +527,14 @@ class ConnectionsOperations(BaseOperations):
         self.response = self.client.post(
             "connections", json=connection.model_dump(mode="json", 
by_alias=True, exclude_none=True)
         )
-        return ConnectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, ConnectionResponse)
 
     def bulk(self, connections: BulkBodyConnectionBody) -> BulkResponse | 
ServerResponseError:
         """CRUD multiple connections."""
         self.response = self.client.patch(
             "connections", json=connections.model_dump(mode="json", 
by_alias=True)
         )
-        return BulkResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BulkResponse)
 
     def create_defaults(self) -> None | ServerResponseError:
         """Create default connections."""
@@ -491,7 +555,7 @@ class ConnectionsOperations(BaseOperations):
             f"connections/{connection.connection_id}",
             json=connection.model_dump(mode="json", by_alias=True, 
exclude_none=True),
         )
-        return ConnectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, ConnectionResponse)
 
     def test(
         self,
@@ -502,7 +566,7 @@ class ConnectionsOperations(BaseOperations):
             "connections/test",
             json=connection.model_dump(mode="json", by_alias=True, 
exclude_none=True),
         )
-        return 
ConnectionTestResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, ConnectionTestResponse)
 
 
 class DagsOperations(BaseOperations):
@@ -511,12 +575,12 @@ class DagsOperations(BaseOperations):
     def get(self, dag_id: str) -> DAGResponse | ServerResponseError:
         """Get a Dag."""
         self.response = self.client.get(f"dags/{dag_id}")
-        return DAGResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, DAGResponse)
 
     def get_details(self, dag_id: str) -> DAGDetailsResponse | 
ServerResponseError:
         """Get a Dag details."""
         self.response = self.client.get(f"dags/{dag_id}/details")
-        return DAGDetailsResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, DAGDetailsResponse)
 
     def get_tags(self) -> DAGTagCollectionResponse | ServerResponseError:
         """Get all Dag tags."""
@@ -528,7 +592,7 @@ class DagsOperations(BaseOperations):
 
     def update(self, dag_id: str, dag_body: DAGPatchBody) -> DAGResponse | 
ServerResponseError:
         self.response = self.client.patch(f"dags/{dag_id}", 
json=dag_body.model_dump(mode="json"))
-        return DAGResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, DAGResponse)
 
     def delete(self, dag_id: str) -> str | ServerResponseError:
         self.client.delete(f"dags/{dag_id}")
@@ -536,18 +600,18 @@ class DagsOperations(BaseOperations):
 
     def get_import_error(self, import_error_id: str) -> ImportErrorResponse | 
ServerResponseError:
         self.response = self.client.get(f"importErrors/{import_error_id}")
-        return ImportErrorResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, ImportErrorResponse)
 
     def list_import_errors(self) -> ImportErrorCollectionResponse | 
ServerResponseError:
         return super().execute_list(path="importErrors", 
data_model=ImportErrorCollectionResponse)
 
     def get_stats(self, dag_ids: list) -> DagStatsCollectionResponse | 
ServerResponseError:  # type: ignore
         self.response = self.client.get("dagStats", params={"dag_ids": 
dag_ids})
-        return 
DagStatsCollectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
DagStatsCollectionResponse)
 
     def get_version(self, dag_id: str, version_number: int) -> 
DagVersionResponse | ServerResponseError:
         self.response = 
self.client.get(f"dags/{dag_id}/dagVersions/{version_number}")
-        return DagVersionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, DagVersionResponse)
 
     def list_version(self, dag_id: str) -> DAGVersionCollectionResponse | 
ServerResponseError:
         return super().execute_list(
@@ -566,7 +630,7 @@ class DagsOperations(BaseOperations):
         self.response = self.client.post(
             f"dags/{dag_id}/dagRuns", 
json=trigger_dag_run.model_dump(mode="json", exclude_defaults=True)
         )
-        return DAGRunResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, DAGRunResponse)
 
 
 class DagRunOperations(BaseOperations):
@@ -580,7 +644,7 @@ class DagRunOperations(BaseOperations):
             f"/dags/{dag_id}/dagRuns/{dag_run_id}",
             extensions={"airflowctl_suppress_error_log": suppress_error_log},
         )
-        return DAGRunResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, DAGRunResponse)
 
     def list(
         self,
@@ -642,7 +706,7 @@ class DagRunOperations(BaseOperations):
             params=params,
             extensions={"airflowctl_suppress_error_log": suppress_error_log},
         )
-        return 
DAGRunCollectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
DAGRunCollectionResponse)
 
     def delete(self, dag_id: str, dag_run_id: str) -> str | 
ServerResponseError:
         """Delete a Dag run."""
@@ -678,7 +742,7 @@ class JobsOperations(BaseOperations):
 
         if limit is not None or offset is not None:
             self.response = self.client.get("jobs", params=params)
-            return 
JobCollectionResponse.model_validate_json(self.response.content)
+            return validate_response(self.response.content, 
JobCollectionResponse)
 
         return super().execute_list(path="jobs", 
data_model=JobCollectionResponse, params=params)
 
@@ -689,7 +753,7 @@ class PoolsOperations(BaseOperations):
     def get(self, pool_name: str) -> PoolResponse | ServerResponseError:
         """Get a pool."""
         self.response = self.client.get(f"pools/{pool_name}")
-        return PoolResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, PoolResponse)
 
     def list(self) -> PoolCollectionResponse | ServerResponseError:
         """List all pools."""
@@ -698,12 +762,12 @@ class PoolsOperations(BaseOperations):
     def create(self, pool: PoolBody) -> PoolResponse | ServerResponseError:
         """Create a pool."""
         self.response = self.client.post("pools", 
json=pool.model_dump(mode="json", exclude_none=True))
-        return PoolResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, PoolResponse)
 
     def bulk(self, pools: BulkBodyPoolBody) -> BulkResponse | 
ServerResponseError:
         """CRUD multiple pools."""
         self.response = self.client.patch("pools", 
json=pools.model_dump(mode="json"))
-        return BulkResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BulkResponse)
 
     def delete(self, pool: str) -> str | ServerResponseError:
         """Delete a pool."""
@@ -715,7 +779,7 @@ class PoolsOperations(BaseOperations):
         self.response = self.client.patch(
             f"pools/{pool_body.pool}", json=pool_body.model_dump(mode="json", 
exclude_none=True)
         )
-        return PoolResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, PoolResponse)
 
 
 class ProvidersOperations(BaseOperations):
@@ -724,7 +788,7 @@ class ProvidersOperations(BaseOperations):
     def get(self, provider_name: str) -> ProviderDetailsResponse | 
ServerResponseError:
         """Get detailed information for a provider."""
         self.response = self.client.get(f"providers/{provider_name}")
-        return 
ProviderDetailsResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
ProviderDetailsResponse)
 
     def list(self) -> ProviderCollectionResponse | ServerResponseError:
         """List all providers."""
@@ -759,7 +823,7 @@ class TaskInstancesOperations(BaseOperations):
             path,
             extensions={"airflowctl_suppress_error_log": suppress_error_log},
         )
-        return TaskInstanceResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, TaskInstanceResponse)
 
     def get_dependencies(
         self,
@@ -778,7 +842,7 @@ class TaskInstancesOperations(BaseOperations):
             f"{path}/dependencies",
             extensions={"airflowctl_suppress_error_log": suppress_error_log},
         )
-        return 
TaskDependencyCollectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
TaskDependencyCollectionResponse)
 
     def list(self, dag_id: str, dag_run_id: str) -> 
TaskInstanceCollectionResponse | ServerResponseError:
         """List task instances for a Dag run."""
@@ -805,7 +869,7 @@ class TaskInstancesOperations(BaseOperations):
             
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}",
             params=_build_query_params(map_index=map_index),
         )
-        return 
TaskStateStoreResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, TaskStateStoreResponse)
 
     def set_state_store(
         self,
@@ -880,7 +944,7 @@ class TasksOperations(BaseOperations):
             f"dags/{dag_id}/clearTaskInstances",
             json=clear_task_instances.model_dump(mode="json", 
exclude_defaults=True),
         )
-        return 
TaskInstanceCollectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
TaskInstanceCollectionResponse)
 
 
 class VariablesOperations(BaseOperations):
@@ -889,7 +953,7 @@ class VariablesOperations(BaseOperations):
     def get(self, variable_key: str) -> VariableResponse | ServerResponseError:
         """Get a variable."""
         self.response = self.client.get(f"variables/{variable_key}")
-        return VariableResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, VariableResponse)
 
     def list(self) -> VariableCollectionResponse | ServerResponseError:
         """List all variables."""
@@ -900,12 +964,12 @@ class VariablesOperations(BaseOperations):
         self.response = self.client.post(
             "variables", json=variable.model_dump(mode="json", 
exclude_none=True)
         )
-        return VariableResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, VariableResponse)
 
     def bulk(self, variables: BulkBodyVariableBody) -> BulkResponse | 
ServerResponseError:
         """CRUD multiple variables."""
         self.response = self.client.patch("variables", 
json=variables.model_dump(mode="json"))
-        return BulkResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, BulkResponse)
 
     def delete(self, variable_key: str) -> str | ServerResponseError:
         """Delete a variable."""
@@ -917,7 +981,7 @@ class VariablesOperations(BaseOperations):
         self.response = self.client.patch(
             f"variables/{variable.key}", json=variable.model_dump(mode="json", 
exclude_none=True)
         )
-        return VariableResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, VariableResponse)
 
 
 class VersionOperations(BaseOperations):
@@ -926,7 +990,7 @@ class VersionOperations(BaseOperations):
     def get(self) -> VersionInfo | ServerResponseError:
         """Get the version."""
         self.response = self.client.get("version")
-        return VersionInfo.model_validate_json(self.response.content)
+        return validate_response(self.response.content, VersionInfo)
 
 
 class XComOperations(BaseOperations):
@@ -948,7 +1012,7 @@ class XComOperations(BaseOperations):
             
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{key}",
             params=params,
         )
-        return XComResponseNative.model_validate_json(self.response.content)
+        return validate_response(self.response.content, XComResponseNative)
 
     def list(
         self,
@@ -990,7 +1054,7 @@ class XComOperations(BaseOperations):
             
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries",
             json=body.model_dump(mode="json", exclude_unset=True, 
exclude_none=True),
         )
-        return XComResponseNative.model_validate_json(self.response.content)
+        return validate_response(self.response.content, XComResponseNative)
 
     def edit(
         self,
@@ -1012,7 +1076,7 @@ class XComOperations(BaseOperations):
             
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{key}",
             json=body.model_dump(mode="json", exclude_unset=True, 
exclude_none=True),
         )
-        return XComResponseNative.model_validate_json(self.response.content)
+        return validate_response(self.response.content, XComResponseNative)
 
     def delete(
         self,
@@ -1043,4 +1107,4 @@ class PluginsOperations(BaseOperations):
     def list_import_errors(self) -> PluginImportErrorCollectionResponse | 
ServerResponseError:
         """List plugin import errors from the API server."""
         self.response = self.client.get("plugins/importErrors")
-        return 
PluginImportErrorCollectionResponse.model_validate_json(self.response.content)
+        return validate_response(self.response.content, 
PluginImportErrorCollectionResponse)
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 26a02351abc..692403dfc8c 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -26,8 +26,9 @@ from unittest.mock import Mock
 
 import httpx
 import pytest
-from pydantic import BaseModel
+from pydantic import BaseModel, RootModel, ValidationError
 
+import airflowctl.api.datamodels.generated as generated_datamodels
 from airflowctl.api.client import Client, ClientKind
 from airflowctl.api.datamodels.auth_generated import LoginBody, LoginResponse
 from airflowctl.api.datamodels.generated import (
@@ -116,7 +117,7 @@ from airflowctl.api.datamodels.generated import (
     XComResponse,
     XComResponseNative,
 )
-from airflowctl.api.operations import BaseOperations, _build_query_params
+from airflowctl.api.operations import BaseOperations, _build_query_params, 
_tolerant_model
 from airflowctl.exceptions import AirflowCtlConnectionException
 
 if TYPE_CHECKING:
@@ -1345,6 +1346,93 @@ class TestDagOperations:
         response = client.dags.get_details("dag_id")
         assert response == self.dag_details_response
 
+    @pytest.mark.parametrize(
+        ("operation", "path", "payload_attr"),
+        [
+            (lambda dags, patch_body: dags.get("dag_id"), 
"/api/v2/dags/dag_id", "dag_response"),
+            (
+                lambda dags, patch_body: dags.get_details("dag_id"),
+                "/api/v2/dags/dag_id/details",
+                "dag_details_response",
+            ),
+            (
+                lambda dags, patch_body: dags.update(dag_id="dag_id", 
dag_body=patch_body),
+                "/api/v2/dags/dag_id",
+                "dag_response",
+            ),
+        ],
+        ids=["get", "get_details", "update"],
+    )
+    @pytest.mark.parametrize(
+        "omitted_field",
+        ["is_backfillable", "timetable_periodic", "owners", "tags"],
+    )
+    def test_single_object_response_tolerates_field_an_older_server_omits(
+        self, operation, path, payload_attr, omitted_field
+    ):
+        """A server on an older Airflow line omits fields the generated models 
declare as required.
+
+        The omitted field must be left honestly ``None`` rather than filled 
with a
+        synthesized type default that would be indistinguishable from a real 
value.
+        """
+        payload = json.loads(getattr(self, payload_attr).model_dump_json())
+        del payload[omitted_field]
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            assert request.url.path == path
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        response = operation(client.dags, self.dag_patch_body)
+
+        assert response.dag_id == self.dag_id
+        assert getattr(response, omitted_field) is None
+
+    def 
test_collection_response_tolerates_field_an_older_server_omits_from_an_item(self):
+        payload = json.loads(self.dag_collection_response.model_dump_json())
+        del payload["dags"][0]["owners"]
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            assert request.url.path == "/api/v2/dags"
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        response = client.dags.list()
+
+        assert response.dags[0].owners is None
+
+    def 
test_single_object_response_warns_on_stderr_about_the_fields_it_leaves_unset(self,
 capsys):
+        payload = json.loads(self.dag_response.model_dump_json())
+        del payload["is_backfillable"]
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        client.dags.get("dag_id")
+
+        captured = capsys.readouterr()
+        assert captured.out == ""
+        assert "DAGResponse" in captured.err
+        assert "is_backfillable" in captured.err
+
+    def 
test_single_object_response_raises_when_a_present_field_mismatches(self):
+        payload = json.loads(self.dag_response.model_dump_json())
+        del payload["is_backfillable"]
+        payload["max_active_tasks"] = "not-an-int"
+
+        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) as exc_info:
+            client.dags.get("dag_id")
+
+        assert {error["loc"] for error in exc_info.value.errors()} == {
+            ("is_backfillable",),
+            ("max_active_tasks",),
+        }
+
     def test_get_tags(self):
         def handle_request(request: httpx.Request) -> httpx.Response:
             assert request.url.path == "/api/v2/dagTags"
@@ -1699,6 +1787,267 @@ class TestDagRunOperations:
         assert response == self.dag_run_id
 
 
+class TestValidateResponseTolerance:
+    """
+    Cross-cutting coverage for ``validate_response``'s tolerant-twin fallback.
+
+    These target failure modes the old type-defaults implementation could not 
handle at
+    all: it had no default for a required enum or datetime field, so it 
produced ``None``
+    for them anyway, and the second (real) ``model_validate`` call then raised 
because
+    ``None`` is not a valid enum member or datetime -- the very omission the 
fallback was
+    supposed to tolerate blew up one call later instead.
+    """
+
+    dag_id = "dag_id"
+    dag_run_id = "dag_run_id"
+
+    def _dag_run_payload(self, dag_run_id: str | None = None) -> dict:
+        return json.loads(
+            DAGRunResponse(
+                dag_display_name=dag_run_id or self.dag_run_id,
+                dag_run_id=dag_run_id or self.dag_run_id,
+                dag_id=self.dag_id,
+                logical_date=datetime.datetime(2025, 1, 1),
+                queued_at=datetime.datetime(2025, 1, 1),
+                start_date=datetime.datetime(2025, 1, 1),
+                end_date=datetime.datetime(2025, 1, 1),
+                data_interval_start=datetime.datetime(2025, 1, 1),
+                data_interval_end=datetime.datetime(2025, 1, 1),
+                last_scheduling_decision=datetime.datetime(2025, 1, 1),
+                run_after=datetime.datetime(2025, 1, 1),
+                run_type=DagRunType.MANUAL,
+                state=DagRunState.RUNNING,
+                triggered_by=DagRunTriggeredByType.UI,
+                conf={},
+                note=None,
+                dag_versions=[],
+                duration=None,
+                triggering_user_name=None,
+                bundle_version=None,
+                partition_key=None,
+                partition_date=None,
+            ).model_dump_json()
+        )
+
+    def test_missing_required_enum_field_is_left_none(self):
+        """``DAGRunResponse.state`` is a required enum with no default -- the 
old
+        type-defaults fallback had no entry for it at all and produced 
``None``, which the
+        second ``model_validate`` call then rejected as not being a valid enum 
member."""
+        payload = self._dag_run_payload()
+        del payload["state"]
+
+        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.dag_runs.get(dag_id=self.dag_id, 
dag_run_id=self.dag_run_id)
+
+        assert response.state is None
+
+    def test_missing_required_datetime_field_is_left_none(self):
+        """``DAGRunResponse.run_after`` is a required datetime with no 
default."""
+        payload = self._dag_run_payload()
+        del payload["run_after"]
+
+        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.dag_runs.get(dag_id=self.dag_id, 
dag_run_id=self.dag_run_id)
+
+        assert response.run_after is None
+
+    def 
test_tolerant_twin_instance_is_still_isinstance_of_the_original_model(self):
+        payload = self._dag_run_payload()
+        del payload["state"]
+
+        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.dag_runs.get(dag_id=self.dag_id, 
dag_run_id=self.dag_run_id)
+
+        assert isinstance(response, DAGRunResponse)
+
+    def _dag_details_payload(self) -> dict:
+        return json.loads(
+            DAGDetailsResponse(
+                dag_id=self.dag_id,
+                dag_display_name=self.dag_id,
+                is_paused=False,
+                is_stale=False,
+                last_parsed_time=None,
+                last_parse_duration=None,
+                last_expired=None,
+                bundle_name=None,
+                bundle_version=None,
+                relative_fileloc=None,
+                fileloc=None,
+                description=None,
+                timetable_summary=None,
+                timetable_description=None,
+                timetable_partitioned=False,
+                timetable_periodic=True,
+                tags=[],
+                max_active_tasks=1,
+                max_active_runs=None,
+                max_consecutive_failed_dag_runs=1,
+                has_task_concurrency_limits=True,
+                has_import_errors=False,
+                next_dagrun_logical_date=None,
+                next_dagrun_data_interval_start=None,
+                next_dagrun_data_interval_end=None,
+                next_dagrun_run_after=None,
+                allowed_run_types=None,
+                owners=[],
+                catchup=False,
+                dag_run_timeout=None,
+                asset_expression=None,
+                doc_md=None,
+                start_date=None,
+                end_date=None,
+                is_paused_upon_creation=False,
+                params={},
+                render_template_as_native_obj=True,
+                template_search_path=None,
+                timezone=None,
+                last_parsed=None,
+                default_args=None,
+                is_backfillable=True,
+                file_token="file_token",
+                concurrency=1,
+                latest_dag_version=None,
+            ).model_dump_json()
+        )
+
+    def 
test_polymorphic_union_member_missing_a_required_field_still_raises(self, 
capsys):
+        """
+        Regression test for a real defect a prior version of the tolerant-twin 
builder had:
+        rewriting every member of a model-valued union (here
+        ``DAGDetailsResponse.asset_expression``) to its all-optional twin let 
pydantic's
+        smart-union match a payload shaped for one member -- but missing that 
member's
+        required subfields -- anyway, silently validating to an empty node 
instead of
+        raising. A polymorphic union (two or more non-``None`` model members) 
must never be
+        rewritten, so no member can swallow a malformed payload this way; only 
the field
+        being missing entirely may still fall back to ``None``.
+        """
+        payload = self._dag_details_payload()
+        del payload["timetable_periodic"]  # an unrelated field an older 
server omits
+        # Shaped like an AssetExpressionAsset leaf but missing its required 
"uri" and
+        # "group" -- no union member actually matches this payload.
+        payload["asset_expression"] = {"asset": {"name": "n"}}
+
+        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.dags.get_details(dag_id=self.dag_id)
+
+        assert capsys.readouterr().err == ""
+
+    def 
test_execute_list_reassembly_tolerates_a_later_page_missing_a_field(self):
+        """
+        ``BaseOperations.execute_list`` reassembles pages by constructing a 
fresh
+        ``data_model`` and re-validating it. If a later page's item needed the 
tolerant
+        fallback for a required, non-Optional field (e.g. a missing ``bool``), 
that
+        reassembly must not re-raise on the very field it just tolerated.
+        """
+
+        class _StrictItem(BaseModel):
+            name: str
+            is_special: bool
+
+        class _StrictItemCollection(BaseModel):
+            items: list[_StrictItem]
+            total_entries: int
+
+        first_page = {"items": [{"name": "a", "is_special": True}], 
"total_entries": 2}
+        second_page_item = {"name": "b"}  # is_special omitted, as an older 
server would.
+
+        pages = [
+            Mock(spec=httpx.Response, content=json.dumps(first_page)),
+            Mock(spec=httpx.Response, content=json.dumps({"items": 
[second_page_item], "total_entries": 2})),
+        ]
+        mock_client = Mock(spec=Client)
+        mock_client.get.side_effect = pages
+        base_operation = BaseOperations(client=mock_client)
+
+        response = base_operation.execute_list(path="items", 
data_model=_StrictItemCollection, limit=1)
+
+        assert [item.name for item in response.items] == ["a", "b"]
+        assert response.items[0].is_special is True
+        assert response.items[1].is_special is None
+
+    def 
test_execute_list_returns_the_first_page_when_total_entries_is_omitted(self):
+        class _Item(BaseModel):
+            name: str
+
+        class _ItemCollection(BaseModel):
+            items: list[_Item]
+            total_entries: int
+
+        mock_client = Mock(spec=Client)
+        mock_client.get.return_value = Mock(
+            spec=httpx.Response, content=json.dumps({"items": [{"name": "a"}]})
+        )
+        base_operation = BaseOperations(client=mock_client)
+
+        response = base_operation.execute_list(path="items", 
data_model=_ItemCollection, limit=1)
+
+        assert [item.name for item in response.items] == ["a"]
+        assert response.total_entries is None
+        mock_client.get.assert_called_once()
+
+
+class TestTolerantModelBuilderCoversAllGeneratedModels:
+    """
+    Structural smoke test for the tolerant-twin builder itself.
+
+    Every non-``RootModel`` model in the generated datamodels must produce a 
tolerant twin
+    that validates an empty payload, proving the builder neither explodes nor 
infinitely
+    recurses on any of them -- including the two that are mutually 
self-referential
+    (``AssetExpressionAll`` <-> ``AssetExpressionAny``), which no longer needs 
a cycle guard
+    now that a polymorphic union (the only path back into either model) is 
never rewritten.
+    """
+
+    def 
test_every_generated_model_builds_a_tolerant_twin_that_validates_an_empty_payload(self):
+        models = [
+            obj
+            for obj in vars(generated_datamodels).values()
+            if isinstance(obj, type)
+            and issubclass(obj, BaseModel)
+            and not issubclass(obj, RootModel)
+            and obj.__module__ == generated_datamodels.__name__
+        ]
+        # A generous floor, not the exact count: guards against a filter bug 
(e.g. the
+        # RootModel exclusion swallowing everything) making this loop -- and 
the assertion
+        # below -- vacuously pass over zero models.
+        assert len(models) > 150
+
+        failures: dict[str, str] = {}
+        for model in models:
+            try:
+                twin = _tolerant_model(model)
+                twin.model_validate({})
+            except Exception as exc:
+                failures[model.__name__] = repr(exc)
+
+        assert failures == {}
+
+    def test_tolerant_twin_preserves_the_original_field_alias(self):
+        """
+        ``FieldInfo.merge_field_infos`` is a semi-public pydantic API; if a 
future pydantic
+        release changes how it copies metadata, this must fail loudly here 
rather than
+        silently drop the alias and only surface when a real server payload 
keyed by the
+        alias (``ConnectionsOperations`` reads/writes ``schema`` on the wire, 
see
+        ``ConnectionResponse.schema_``) fails to bind to the tolerant twin.
+        """
+        twin = _tolerant_model(ConnectionResponse)
+
+        assert twin.model_fields["schema_"].alias == "schema"
+
+
 class TestJobsOperations:
     job_response = JobResponse(
         id=1,

Reply via email to