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

potiuk pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 2c6493b7636 [v3-4-test] Render XCom values on the API server without 
rebuilding objects (#74829) (#75088)
2c6493b7636 is described below

commit 2c6493b76365a5e6f6749c21777429b75bf140e0
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Oct 19 17:00:54 2026 +0200

    [v3-4-test] Render XCom values on the API server without rebuilding objects 
(#74829) (#75088)
    
    * Render XCom values on the API server without rebuilding objects
    
    `GET .../xcomEntries/{key}?deserialize=true` fell back to
    `XComModel.deserialize_value()` for any `airflow.*` class, running full
    serde in the API server only to turn the result back into a string.
    `stringify()` now renders `airflow.*` classes from their serialized
    fields without importing them, producing the same repr-style output for
    attrs/dataclass types, so the fallback is removed from the XCom endpoint
    and the `XComOperatorLink` extra-link lookup.
    
    Generated-by: Claude Opus 5 following the guidelines at 
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    * Render Airflow classes with custom serialization compactly in XCom display
    
    Only asset definitions, which are attrs types, have serialized fields that
    mirror their repr. Other Airflow classes can define serialize() returning
    internal state that their repr never shows, e.g. the storage options kept
    by ObjectStoragePath. Show those by their path or URI only.
    (cherry picked from commit b107f6024385bfbf837755faebdd665a6f43774b)
    
    
    Generated-by: Claude Opus 5 following the guidelines at 
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../api_fastapi/core_api/routes/public/xcom.py     | 23 ++----
 .../serialization/definitions/operatorlink.py      | 14 +---
 .../src/airflow/serialization/stringify.py         | 82 +++++++++++++++----
 .../core_api/routes/public/test_xcom.py            | 65 +++++++++++++++-
 .../tests/unit/serialization/test_operatorlink.py  | 21 +++++
 .../tests/unit/serialization/test_stringify.py     | 91 ++++++++++++++++++++--
 6 files changed, 245 insertions(+), 51 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py
index 0adaefa2de4..5467e87750e 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import json
 from dataclasses import dataclass
 from typing import Annotated
 from uuid import UUID
@@ -67,6 +68,7 @@ from airflow.models.dynamic_region import LOOP_XCOM_PREFIX
 from airflow.models.task_coordinates import TaskCoordinateResolver, 
public_map_index_expression
 from airflow.models.taskinstance import TaskInstance
 from airflow.models.xcom import XComModel, build_xcom_read_query, 
select_producers, xcom_entity
+from airflow.serialization.stringify import stringify as stringify_xcom
 
 xcom_router = AirflowRouter(
     tags=["XCom"], 
prefix="/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries"
@@ -141,28 +143,15 @@ def get_xcom_entry(
     value = result.value
 
     if deserialize:
-        # Custom XCom backends may store references (eg: object storage paths) 
in the database.
-        # The custom XCom backend's deserialize_value() resolves these to 
actual values, but that is only
-        # used on workers during task execution. The API reads directly from 
the database and uses
-        # stringify() to convert DB values (references or serialized data) to 
human readable
-        # format for UI display or for API users.
-        import json
-
-        from airflow.serialization.stringify import (
-            StringifyNotSupportedError,
-            stringify as stringify_xcom,
-        )
-
+        # The value is rendered from what is stored in the database: no XCom 
backend is involved and no
+        # object is reconstructed from it. A custom backend may store a 
reference (eg: an object
+        # storage path) there; only workers resolve it into the actual value.
         try:
             parsed_value = json.loads(result.value)
         except (ValueError, TypeError):
             # Already deserialized (e.g., set via Task Execution API)
             parsed_value = result.value
-
-        try:
-            value = stringify_xcom(parsed_value)
-        except StringifyNotSupportedError:
-            value = XComModel.deserialize_value(result)
+        value = stringify_xcom(parsed_value)
     else:
         # For native format, return the raw serialized value from the database
         # This preserves the JSON string format that the API expects
diff --git a/airflow-core/src/airflow/serialization/definitions/operatorlink.py 
b/airflow-core/src/airflow/serialization/definitions/operatorlink.py
index 51d8d04f39a..d1c6d2f2867 100644
--- a/airflow-core/src/airflow/serialization/definitions/operatorlink.py
+++ b/airflow-core/src/airflow/serialization/definitions/operatorlink.py
@@ -26,6 +26,7 @@ import attrs
 
 from airflow.models.dynamic_region import SENTINEL_REGION_ID
 from airflow.models.xcom import XComModel, xcom_entity
+from airflow.serialization.stringify import stringify as stringify_xcom
 from airflow.utils.log.logging_mixin import LoggingMixin
 from airflow.utils.session import create_session
 
@@ -79,21 +80,10 @@ class XComOperatorLink(LoggingMixin):
             )
             return ""
 
-        from airflow.serialization.stringify import (
-            StringifyNotSupportedError,
-            stringify as stringify_xcom,
-        )
-
         try:
             parsed_value = json.loads(result.value)
         except (ValueError, TypeError):
             # Handling for cases when types do not need to be deserialized 
(e.g. when value is a simple string link)
             parsed_value = result.value
 
-        try:
-            return str(stringify_xcom(parsed_value))
-        except StringifyNotSupportedError:
-            # If stringify doesn't support the type, return the raw value as a 
string.
-            # This avoids the XComModel.deserialize_value() call that could
-            # instantiate arbitrary classes from untrusted XCom data.
-            return str(parsed_value)
+        return str(stringify_xcom(parsed_value))
diff --git a/airflow-core/src/airflow/serialization/stringify.py 
b/airflow-core/src/airflow/serialization/stringify.py
index 74b654aa451..4be7087ecf3 100644
--- a/airflow-core/src/airflow/serialization/stringify.py
+++ b/airflow-core/src/airflow/serialization/stringify.py
@@ -28,14 +28,7 @@ T = TypeVar("T", bool, float, int, dict, list, str, tuple, 
set)
 # but it has no place in the human-readable XCom value rendering.
 _DAGBAG_PREFIX_RE = re.compile(r"unusual_prefix_[a-f0-9]{40}_")
 
-
-class StringifyNotSupportedError(ValueError):
-    """
-    Raised when stringify() cannot handle a serialized object and full 
deserialization is required.
-
-    This typically occurs when trying to stringify Airflow classes that need 
to be fully deserialized
-    using XComModel.deserialize_value() instead.
-    """
+_FIELD_RENDERED_MODULE = "airflow.sdk.definitions.asset."
 
 
 CLASSNAME = "__classname__"
@@ -82,13 +75,74 @@ def _stringify_builtin_collection(classname: str, value: 
Any):
     return None
 
 
+def _render_builtin_collection_field(o: dict) -> str | None:
+    """Render a serialized tuple, set or frozenset field the way ``repr`` 
renders it, else ``None``."""
+    classname, data = o[CLASSNAME], o.get(DATA)
+    if classname not in ("builtins.tuple", "builtins.set", 
"builtins.frozenset") or not isinstance(
+        data, (list, tuple, set, frozenset)
+    ):
+        return None
+    items = ", ".join(_render_field(v) for v in data)
+    if classname == "builtins.tuple":
+        return f"({items},)" if len(data) == 1 else f"({items})"
+    if classname == "builtins.set":
+        return f"{{{items}}}" if data else "set()"
+    return f"frozenset({{{items}}})" if data else "frozenset()"
+
+
+def _render_field(o: Any) -> str:
+    """Render a field value of an Airflow object the way ``repr`` would render 
the deserialized value."""
+    if isinstance(o, str):
+        return repr(o)
+    if isinstance(o, list):
+        return f"[{', '.join(_render_field(v) for v in o)}]"
+    if isinstance(o, dict):
+        o = _convert(o)
+        if CLASSNAME in o and VERSION in o:
+            if (rendered := _render_builtin_collection_field(o)) is not None:
+                return rendered
+            return str(stringify(o))
+        return "{" + ", ".join(f"{k!r}: {_render_field(v)}" for k, v in 
o.items()) + "}"
+    return str(stringify(o))
+
+
+def _stringify_airflow_object(classname: str, value: Any) -> str:
+    """
+    Render an Airflow class from its serialized fields, e.g. 
``AssetAlias(name='x', group='asset')``.
+
+    The class is neither imported nor instantiated, so whether it is an attrs 
type, whose serialized
+    fields mirror its ``repr``, cannot be checked here. Fields are rendered 
only for the asset
+    definitions, which are attrs types. Other Airflow classes may define 
``serialize()`` returning
+    internal state, such as the storage options of ``ObjectStoragePath``; they 
are shown by their
+    ``path`` or ``uri`` field only.
+    """
+    short_name = classname.rsplit(".", 1)[-1]
+    if not classname.startswith(_FIELD_RENDERED_MODULE):
+        locator = next(
+            (value[k] for k in ("path", "uri") if isinstance(value, dict) and 
isinstance(value.get(k), str)),
+            None,
+        )
+        return f"{short_name}({locator!r})" if locator is not None else 
f"{short_name}(...)"
+    if value is None:
+        args = ""
+    elif isinstance(value, dict):
+        args = ", ".join(f"{k}={_render_field(v)}" for k, v in value.items())
+    elif isinstance(value, _builtin_collections):
+        args = ", ".join(_render_field(v) for v in value)
+    else:
+        args = _render_field(value)
+    return f"{short_name}({args})"
+
+
 def stringify(o: T | None) -> object:
     """
-    Convert a serialized object to a human readable representation.
+    Convert a serialized object to a human readable representation without 
importing any class.
 
-    Matches the behavior of deserialize(full=False) exactly:
+    Matches the behavior of deserialize(full=False), except for Airflow 
classes:
     - Returns objects as-is for primitives, collections, plain dicts
     - Only returns string when encountering a serialized object (__classname__)
+    - Asset definitions are rendered like their ``repr``, e.g. 
``AssetAlias(name='x', group='asset')``
+    - Other Airflow classes are rendered by their path or URI only, e.g. 
``ObjectStoragePath('s3://b/k')``
     """
     if o is None:
         return o
@@ -123,12 +177,8 @@ def stringify(o: T | None) -> object:
     if not classname:
         raise TypeError("classname cannot be empty")
 
-    # Early detection for `airflow.` classes. These classes will need full 
deserialization, not just stringification
-    if isinstance(classname, str) and classname.startswith("airflow."):
-        raise StringifyNotSupportedError(
-            f"Cannot stringify Airflow class '{classname}'. "
-            f"Use XComModel.deserialize_value() to deserialize Airflow 
classes."
-        )
+    if classname.startswith("airflow."):
+        return _stringify_airflow_object(classname, value)
 
     result = _stringify_builtin_collection(classname, value)
     if result is not None:
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py
index 3444ca0f015..e24a41f1cbf 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py
@@ -37,7 +37,7 @@ from airflow.models.taskinstance import TaskInstance
 from airflow.models.team import Team
 from airflow.models.xcom import XCOM_RETURN_KEY, XComModel, xcom_entity
 from airflow.providers.standard.operators.empty import EmptyOperator
-from airflow.sdk import DAG, AssetAlias, task_group
+from airflow.sdk import DAG, Asset, AssetAlias, task_group
 from airflow.sdk.bases.xcom import BaseXCom
 from airflow.sdk.definitions._internal.loop import create_loop
 from airflow.sdk.execution_time.xcom import resolve_xcom_backend
@@ -417,8 +417,16 @@ class TestGetXComEntry(TestXComEndpoint):
         ("xcom_value", "expected_return"),
         [
             pytest.param(42, 42, id="jsonable"),
+            pytest.param("text", "text", id="str"),
+            pytest.param({"a": [1, 2], "b": None}, {"a": [1, 2], "b": None}, 
id="dict"),
+            pytest.param([1, "two", 3.0], [1, "two", 3.0], id="list"),
             pytest.param(AssetAlias("x"), "AssetAlias(name='x', 
group='asset')", id="nonjsonable"),
             pytest.param([42, AssetAlias("x")], [42, "AssetAlias(name='x', 
group='asset')"], id="nested"),
+            pytest.param(
+                Asset("s3://bucket/key", extra={"a": 1}),
+                repr(Asset("s3://bucket/key", extra={"a": 1})),
+                id="asset",
+            ),
         ],
     )
     def test_stringify_false(self, test_client, xcom_value, expected_return):
@@ -429,6 +437,61 @@ class TestGetXComEntry(TestXComEndpoint):
         assert response.status_code == 200
         assert response.json()["value"] == expected_return
 
+    @pytest.mark.parametrize(
+        ("xcom_value", "expected_return"),
+        [
+            pytest.param(42, "42", id="int"),
+            pytest.param({"a": [1, 2]}, "{'a': [1, 2]}", id="dict"),
+            pytest.param(AssetAlias("x"), "AssetAlias(name='x', 
group='asset')", id="airflow-class"),
+        ],
+    )
+    def test_stringify_true(self, test_client, xcom_value, expected_return):
+        XComModel.set(TEST_XCOM_KEY, xcom_value, dag_id=TEST_DAG_ID, 
task_id=TEST_TASK_ID, run_id=run_id)
+
+        url = 
f"/dags/{TEST_DAG_ID}/dagRuns/{run_id}/taskInstances/{TEST_TASK_ID}/xcomEntries/{TEST_XCOM_KEY}"
+        response = test_client.get(url, params={"deserialize": True, 
"stringify": True})
+        assert response.status_code == 200
+        assert response.json()["value"] == expected_return
+
+    @mock.patch("airflow.sdk.serde.serializers.deltalake.deserialize", 
autospec=True)
+    @mock.patch("airflow.sdk.serde.deserialize", autospec=True)
+    def test_deserialize_renders_stored_payload_without_building_objects(
+        self, serde_deserialize, deltalake_deserialize, test_client
+    ):
+        """A ``deserialize=true`` read renders the stored payload; it never 
imports or builds the classes it names."""
+        payload = {
+            "__classname__": "airflow.sdk.definitions.asset.AssetAlias",
+            "__version__": 0,
+            "__data__": {
+                "name": "x",
+                "group": {
+                    "__classname__": "deltalake.table.DeltaTable",
+                    "__version__": 1,
+                    "__data__": {"table_uri": "s3://bucket/table", "version": 
0},
+                },
+            },
+        }
+        # Stored as-is, the way the Execution API stores values a task pushes.
+        XComModel.set(
+            TEST_XCOM_KEY,
+            json.dumps(payload),
+            dag_id=TEST_DAG_ID,
+            task_id=TEST_TASK_ID,
+            run_id=run_id,
+            serialize=False,
+        )
+
+        url = 
f"/dags/{TEST_DAG_ID}/dagRuns/{run_id}/taskInstances/{TEST_TASK_ID}/xcomEntries/{TEST_XCOM_KEY}"
+        response = test_client.get(url, params={"deserialize": True})
+
+        assert response.status_code == 200
+        assert response.json()["value"] == (
+            "AssetAlias(name='x', group=deltalake.table.DeltaTable@version=1"
+            "(table_uri='s3://bucket/table', version=0))"
+        )
+        serde_deserialize.assert_not_called()
+        deltalake_deserialize.assert_not_called()
+
 
 class TestGetXComEntries(TestXComEndpoint):
     @pytest.fixture(autouse=True)
diff --git a/airflow-core/tests/unit/serialization/test_operatorlink.py 
b/airflow-core/tests/unit/serialization/test_operatorlink.py
index ef3a399279c..d5497766593 100644
--- a/airflow-core/tests/unit/serialization/test_operatorlink.py
+++ b/airflow-core/tests/unit/serialization/test_operatorlink.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import json
 from types import SimpleNamespace
 from unittest import mock
 
@@ -41,3 +42,23 @@ def test_operator_link_lookup_is_bounded(create_session):
     session.execute.assert_called_once()
     statement = session.execute.call_args.args[0]
     assert "LIMIT 1" in str(statement.compile(compile_kwargs={"literal_binds": 
True}))
+
+
[email protected]("airflow.sdk.serde.deserialize", autospec=True)
[email protected]("airflow.serialization.definitions.operatorlink.create_session", 
autospec=True)
+def test_operator_link_renders_airflow_class_payload(create_session, 
serde_deserialize):
+    session = mock.create_autospec(Session, instance=True)
+    create_session.return_value.__enter__.return_value = session
+    result = mock.create_autospec(Result, instance=True)
+    payload = {
+        "__classname__": "airflow.sdk.definitions.asset.AssetAlias",
+        "__version__": 0,
+        "__data__": {"name": "x", "group": "asset"},
+    }
+    result.first.return_value = SimpleNamespace(value=json.dumps(payload))
+    session.execute.return_value = result
+    link = XComOperatorLink(name="Example", xcom_key="_link_example")
+    ti_key = TaskInstanceKey(dag_id="example", task_id="task", run_id="run", 
try_number=1, map_index=-1)
+
+    assert link.get_link(None, ti_key=ti_key) == "AssetAlias(name='x', 
group='asset')"
+    serde_deserialize.assert_not_called()
diff --git a/airflow-core/tests/unit/serialization/test_stringify.py 
b/airflow-core/tests/unit/serialization/test_stringify.py
index 6f9c817db77..c88d77c8f2e 100644
--- a/airflow-core/tests/unit/serialization/test_stringify.py
+++ b/airflow-core/tests/unit/serialization/test_stringify.py
@@ -19,10 +19,12 @@ from __future__ import annotations
 
 from dataclasses import dataclass
 from typing import ClassVar
+from unittest import mock
 
 import pytest
 
 from airflow._shared.module_loading import qualname
+from airflow.sdk import Asset, AssetAlias
 from airflow.sdk.serde import serialize
 from airflow.serialization.stringify import CLASSNAME, VERSION, stringify
 
@@ -42,6 +44,11 @@ class V:
     c: int
 
 
+@dataclass
+class Thing:
+    f: object
+
+
 class TestStringify:
     # Existing ported over tests
     def test_stringify(self):
@@ -253,9 +260,83 @@ class TestStringify:
         result = stringify(e)
         assert result == {"key1": "(1,2)", "key2": "value"}
 
-    def test_error_thrown_for_airflow_classes(self):
-        from airflow.sdk import AssetAlias
+    @pytest.mark.parametrize(
+        "value",
+        [
+            pytest.param(AssetAlias("x"), id="asset-alias"),
+            pytest.param(Asset("s3://bucket/key", extra={"a": [1, "b"]}), 
id="asset-with-nested-class"),
+        ],
+    )
+    def test_stringify_airflow_class_reads_like_repr(self, value):
+        assert stringify(serialize(value)) == repr(value)
 
-        e = serialize(AssetAlias("x"))
-        with pytest.raises(ValueError, match="Cannot stringify Airflow class"):
-            stringify(e)
+    @pytest.mark.parametrize(
+        ("data", "expected"),
+        [
+            pytest.param(None, "Thing()", id="no-data"),
+            pytest.param("x", "Thing('x')", id="str"),
+            pytest.param(3, "Thing(3)", id="int"),
+            pytest.param([1, "a", {"k": "v"}], "Thing(1, 'a', {'k': 'v'})", 
id="list"),
+            pytest.param(
+                {"inner": {"__type": "dict", "__var": {"k": None}}}, 
"Thing(inner={'k': None})", id="old-dict"
+            ),
+        ],
+    )
+    def test_stringify_airflow_class_data_shapes(self, data, expected):
+        assert (
+            stringify({CLASSNAME: "airflow.sdk.definitions.asset.Thing", 
VERSION: 0, "__data__": data})
+            == expected
+        )
+
+    @pytest.mark.parametrize(
+        "field",
+        [
+            pytest.param(("a", "b"), id="tuple"),
+            pytest.param(("a",), id="single-tuple"),
+            pytest.param((), id="empty-tuple"),
+            pytest.param((1, ("x", (2,))), id="nested-tuple"),
+            pytest.param({"a"}, id="set"),
+            pytest.param(set(), id="empty-set"),
+            pytest.param(frozenset({"a"}), id="frozenset"),
+            pytest.param(frozenset(), id="empty-frozenset"),
+            pytest.param([("a",), {"k": ("v", 1)}], 
id="tuples-in-list-and-dict"),
+        ],
+    )
+    def test_stringify_airflow_class_collection_fields_read_like_repr(self, 
field):
+        e = {
+            CLASSNAME: "airflow.sdk.definitions.asset.Thing",
+            VERSION: 0,
+            "__data__": {"f": serialize(field)},
+        }
+        assert stringify(e) == repr(Thing(field))
+
+    @pytest.mark.parametrize(
+        ("data", "expected"),
+        [
+            pytest.param(
+                {"path": "s3://conn@bucket/key", "conn_id": "conn", "kwargs": 
{"key": "k", "token": "t"}},
+                "ObjectStoragePath('s3://conn@bucket/key')",
+                id="path",
+            ),
+            pytest.param(
+                {"uri": "s3://b", "options": {"token": "t"}}, 
"ObjectStoragePath('s3://b')", id="uri"
+            ),
+            pytest.param(
+                {"conn_id": "conn", "kwargs": {"anon": False}}, 
"ObjectStoragePath(...)", id="no-locator"
+            ),
+            pytest.param("s3://b", "ObjectStoragePath(...)", id="non-dict"),
+        ],
+    )
+    def 
test_stringify_airflow_class_with_custom_serialization_is_compact(self, data, 
expected):
+        e = {CLASSNAME: "airflow.sdk.io.path.ObjectStoragePath", VERSION: 1, 
"__data__": data}
+        assert stringify(e) == expected
+
+    @mock.patch("importlib.import_module", autospec=True)
+    def test_stringify_airflow_class_does_not_import_nested_class(self, 
import_module):
+        e = {
+            CLASSNAME: "airflow.sdk.definitions.asset.Outer",
+            VERSION: 1,
+            "__data__": {"inner": {CLASSNAME: "other.mod.Inner", VERSION: 2, 
"__data__": {"uri": "s3://b"}}},
+        }
+        assert stringify(e) == 
"Outer(inner=other.mod.Inner@version=2(uri='s3://b'))"
+        import_module.assert_not_called()

Reply via email to