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 da77f374684 feat(serialization): implement truncation logic for 
rendered values (#61878)
da77f374684 is described below

commit da77f37468483e0616446f80698455aa6017f84d
Author: Richard Wu <[email protected]>
AuthorDate: Fri Mar 13 06:49:21 2026 -0700

    feat(serialization): implement truncation logic for rendered values (#61878)
    
    * feat(serialization): implement truncation logic for rendered values in 
template fields
    
    Added a new function to truncate rendered values based on a specified 
maximum length, ensuring that truncation messages are prioritized. This 
functionality is integrated into the serialization of template fields, 
enhancing the handling of long strings in the system.
    
    * fix(test): update assertion for rendered fields in task runner tests
    
    Modified the test for runtime task instances to dynamically retrieve the 
rendered fields from the mock supervisor communications, ensuring accurate 
assertions for the SetRenderedFields message type. This change enhances the 
robustness of the test by adapting to varying truncation formats based on 
configuration.
    
    * refactor(tests): update large string and object assertions to use 
serialization
    
    Replaced direct truncation messages in test assertions with calls to the 
new serialize_template_field function. This change ensures consistency in how 
large strings and objects are handled in the rendered task instance fields 
tests, leveraging the updated serialization logic for better clarity and 
maintainability.
    
    * refactor: simplify _truncate_rendered_value function and remove redundant 
safe wrapper
    
    * fix: correct expected output for quoted string in test case
    
    * refactor: move _truncate_rendered_value function to utils.helpers and 
clean up code
    
    * refactor: improve _truncate_rendered_value logic for better readability 
and efficiency
    
    * fix: adjust available space calculation in _truncate_rendered_value for 
accurate truncation
    
    * fix: adjust available space calculation in _truncate_rendered_value for 
accurate truncation
    
    * Clean up comments
    
    Removed comments about returning empty string and truncation message.
    
    * refactor: rename _truncate_rendered_value to truncate_rendered_value and 
update references
    
    * feat: add shared template rendering module with truncate_rendered_value 
function
    
    * refactor: cleanup
    
    * feat: enhance truncation functionality with configurable constants and 
improved tests
    
    * remove dups
    
    * test: update truncation tests with dynamic boundary calculations
---
 airflow-core/pyproject.toml                        |   1 +
 .../src/airflow/_shared/template_rendering         |   1 +
 airflow-core/src/airflow/serialization/helpers.py  |  11 +--
 .../tests/unit/models/test_renderedtifields.py     |   8 +-
 .../tests/unit/serialization/test_helpers.py       |  31 ++++++
 pyproject.toml                                     |   3 +
 shared/template_rendering/pyproject.toml           |  46 +++++++++
 .../airflow_shared/template_rendering/__init__.py  |  86 ++++++++++++++++
 .../tests/template_rendering/__init__.py           |  16 +++
 .../test_truncate_rendered_value.py                | 110 +++++++++++++++++++++
 task-sdk/pyproject.toml                            |   1 +
 .../src/airflow/sdk/_shared/template_rendering     |   1 +
 .../src/airflow/sdk/execution_time/task_runner.py  |  11 +--
 .../task_sdk/execution_time/test_task_runner.py    |  28 +++---
 14 files changed, 324 insertions(+), 30 deletions(-)

diff --git a/airflow-core/pyproject.toml b/airflow-core/pyproject.toml
index 97ab77007a9..2c6c838b1d2 100644
--- a/airflow-core/pyproject.toml
+++ b/airflow-core/pyproject.toml
@@ -253,6 +253,7 @@ exclude = [
 "../shared/listeners/src/airflow_shared/listeners" = 
"src/airflow/_shared/listeners"
 "../shared/plugins_manager/src/airflow_shared/plugins_manager" = 
"src/airflow/_shared/plugins_manager"
 "../shared/providers_discovery/src/airflow_shared/providers_discovery" = 
"src/airflow/_shared/providers_discovery"
+"../shared/template_rendering/src/airflow_shared/template_rendering" = 
"src/airflow/_shared/template_rendering"
 
 [tool.hatch.build.targets.custom]
 path = "./hatch_build.py"
diff --git a/airflow-core/src/airflow/_shared/template_rendering 
b/airflow-core/src/airflow/_shared/template_rendering
new file mode 120000
index 00000000000..6ff1f831df7
--- /dev/null
+++ b/airflow-core/src/airflow/_shared/template_rendering
@@ -0,0 +1 @@
+../../../../shared/template_rendering/src/airflow_shared/template_rendering
\ No newline at end of file
diff --git a/airflow-core/src/airflow/serialization/helpers.py 
b/airflow-core/src/airflow/serialization/helpers.py
index effca4453dc..e2c8069a116 100644
--- a/airflow-core/src/airflow/serialization/helpers.py
+++ b/airflow-core/src/airflow/serialization/helpers.py
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any
 
 from airflow._shared.module_loading import qualname
 from airflow._shared.secrets_masker import redact
+from airflow._shared.template_rendering import truncate_rendered_value
 from airflow.configuration import conf
 from airflow.settings import json
 
@@ -83,10 +84,7 @@ def serialize_template_field(template_field: Any, name: str) 
-> str | dict | lis
                 serialized = str(template_field)
         if len(serialized) > max_length:
             rendered = redact(serialized, name)
-            return (
-                "Truncated. You can change this behaviour in 
[core]max_templated_field_length. "
-                f"{rendered[: max_length - 79]!r}... "
-            )
+            return truncate_rendered_value(str(rendered), max_length)
         return serialized
     if not template_field and not isinstance(template_field, tuple):
         # Avoid unnecessary serialization steps for empty fields unless they 
are tuples
@@ -100,10 +98,7 @@ def serialize_template_field(template_field: Any, name: 
str) -> str | dict | lis
     serialized = str(template_field)
     if len(serialized) > max_length:
         rendered = redact(serialized, name)
-        return (
-            "Truncated. You can change this behaviour in 
[core]max_templated_field_length. "
-            f"{rendered[: max_length - 79]!r}... "
-        )
+        return truncate_rendered_value(str(rendered), max_length)
     return template_field
 
 
diff --git a/airflow-core/tests/unit/models/test_renderedtifields.py 
b/airflow-core/tests/unit/models/test_renderedtifields.py
index 12eaf27a38d..d42ed06b033 100644
--- a/airflow-core/tests/unit/models/test_renderedtifields.py
+++ b/airflow-core/tests/unit/models/test_renderedtifields.py
@@ -30,6 +30,7 @@ import pytest
 from sqlalchemy import select
 
 from airflow import settings
+from airflow._shared.template_rendering import truncate_rendered_value
 from airflow._shared.timezones.timezone import datetime
 from airflow.configuration import conf
 from airflow.models import DagRun
@@ -124,13 +125,14 @@ class TestRenderedTaskInstanceFields:
             pytest.param(datetime(2018, 12, 6, 10, 55), "2018-12-06 
10:55:00+00:00", id="datetime"),
             pytest.param(
                 "a" * 5000,
-                f"Truncated. You can change this behaviour in 
[core]max_templated_field_length. {('a' * 5000)[: max_length - 79]!r}... ",
+                truncate_rendered_value("a" * 5000, conf.getint("core", 
"max_templated_field_length")),
                 id="large_string",
             ),
             pytest.param(
                 LargeStrObject(),
-                f"Truncated. You can change this behaviour in "
-                f"[core]max_templated_field_length. {str(LargeStrObject())[: 
max_length - 79]!r}... ",
+                truncate_rendered_value(
+                    str(LargeStrObject()), conf.getint("core", 
"max_templated_field_length")
+                ),
                 id="large_object",
             ),
         ],
diff --git a/airflow-core/tests/unit/serialization/test_helpers.py 
b/airflow-core/tests/unit/serialization/test_helpers.py
new file mode 100644
index 00000000000..0ded4f64a86
--- /dev/null
+++ b/airflow-core/tests/unit/serialization/test_helpers.py
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+
+def test_serialize_template_field_with_very_small_max_length(monkeypatch):
+    """Test that truncation message is prioritized even for very small 
max_length."""
+    monkeypatch.setenv("AIRFLOW__CORE__MAX_TEMPLATED_FIELD_LENGTH", "1")
+
+    from airflow.serialization.helpers import serialize_template_field
+
+    result = serialize_template_field("This is a long string", "test")
+
+    # The truncation message should be shown even if it exceeds max_length
+    # This ensures users always see why content is truncated
+    assert result
+    assert "Truncated. You can change this behaviour" in result
diff --git a/pyproject.toml b/pyproject.toml
index c359eda1a9e..4b28ceb88bd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1306,6 +1306,7 @@ dev = [
     "apache-airflow-shared-providers-discovery",
     "apache-airflow-shared-secrets-backend",
     "apache-airflow-shared-secrets-masker",
+    "apache-airflow-shared-template-rendering",
     "apache-airflow-shared-timezones",
 ]
 
@@ -1365,6 +1366,7 @@ apache-airflow-shared-plugins-manager = { workspace = 
true }
 apache-airflow-shared-providers-discovery = { workspace = true }
 apache-airflow-shared-secrets-backend = { workspace = true }
 apache-airflow-shared-secrets-masker = { workspace = true }
+apache-airflow-shared-template-rendering = { workspace = true }
 apache-airflow-shared-timezones = { workspace = true }
 # Automatically generated provider workspace items 
(update_airflow_pyproject_toml.py)
 apache-airflow-providers-airbyte = { workspace = true }
@@ -1495,6 +1497,7 @@ members = [
     "shared/providers_discovery",
     "shared/secrets_backend",
     "shared/secrets_masker",
+    "shared/template_rendering",
     "shared/timezones",
     # Automatically generated provider workspace members 
(update_airflow_pyproject_toml.py)
     "providers/airbyte",
diff --git a/shared/template_rendering/pyproject.toml 
b/shared/template_rendering/pyproject.toml
new file mode 100644
index 00000000000..0b7ad0931b3
--- /dev/null
+++ b/shared/template_rendering/pyproject.toml
@@ -0,0 +1,46 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[project]
+name = "apache-airflow-shared-template-rendering"
+description = "Shared template rendering utilities for Airflow distributions"
+version = "0.0"
+classifiers = [
+    "Private :: Do Not Upload",
+]
+
+dependencies = []
+
+[dependency-groups]
+dev = [
+    "apache-airflow-devel-common",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/airflow_shared"]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
+src = ["src"]
+
+[tool.ruff.lint.per-file-ignores]
+# Ignore Doc rules et al for anything outside of tests
+"!src/*" = ["D", "S101", "TRY002"]
diff --git 
a/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py 
b/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py
new file mode 100644
index 00000000000..ddf01a88098
--- /dev/null
+++ 
b/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import logging
+
+log = logging.getLogger(__name__)
+
+# Public truncation configuration used by ``truncate_rendered_value``.
+# Exposed as module-level constants to avoid duplicating literals in 
callers/tests.
+TRUNCATE_MIN_CONTENT_LENGTH = 7
+TRUNCATE_PREFIX = "Truncated. You can change this behaviour in 
[core]max_templated_field_length. "
+TRUNCATE_SUFFIX = "..."
+
+
+def truncate_rendered_value(rendered: str, max_length: int) -> str:
+    """
+    Truncate a rendered template value to approximately ``max_length`` 
characters.
+
+    Behavior:
+
+    * If ``max_length <= 0``, an empty string is returned.
+    * A fixed prefix (``TRUNCATE_PREFIX``) and suffix (``TRUNCATE_SUFFIX``) 
are always
+      included when truncation occurs. The minimal truncation-only message is::
+
+          f"{TRUNCATE_PREFIX}{TRUNCATE_SUFFIX}"
+
+    * If ``max_length`` is smaller than the length of this truncation-only 
message, that
+      message is returned in full, even though its length may exceed 
``max_length``.
+    * Otherwise, space remaining after the prefix and suffix is allocated to 
the original
+      ``rendered`` content. Content is only appended if at least
+      ``TRUNCATE_MIN_CONTENT_LENGTH`` characters are available; if fewer are 
available,
+      the truncation-only message is returned instead.
+
+    Note: this function is best-effort — the return value is intended to be no 
longer than
+    ``max_length``, but when ``max_length < len(TRUNCATE_PREFIX + 
TRUNCATE_SUFFIX)`` it
+    intentionally returns a longer string to preserve the full truncation 
message.
+    """
+    if max_length <= 0:
+        return ""
+
+    trunc_only = f"{TRUNCATE_PREFIX}{TRUNCATE_SUFFIX}"
+
+    if max_length < len(trunc_only):
+        return trunc_only
+
+    # Compute available space for content
+    overhead = len(TRUNCATE_PREFIX) + len(TRUNCATE_SUFFIX)
+    available = max_length - overhead
+
+    if available < TRUNCATE_MIN_CONTENT_LENGTH:
+        return trunc_only
+
+    # Slice content to fit and construct final string
+    content = rendered[:available].rstrip()
+    result = f"{TRUNCATE_PREFIX}{content}{TRUNCATE_SUFFIX}"
+
+    if len(result) > max_length:
+        log.warning(
+            "Truncated value still exceeds max_length=%d; this should not 
happen.",
+            max_length,
+        )
+
+    return result
+
+
+__all__ = [
+    "TRUNCATE_MIN_CONTENT_LENGTH",
+    "TRUNCATE_PREFIX",
+    "TRUNCATE_SUFFIX",
+    "truncate_rendered_value",
+]
diff --git a/shared/template_rendering/tests/template_rendering/__init__.py 
b/shared/template_rendering/tests/template_rendering/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/shared/template_rendering/tests/template_rendering/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git 
a/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py
 
b/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py
new file mode 100644
index 00000000000..3b401aa177b
--- /dev/null
+++ 
b/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py
@@ -0,0 +1,110 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from airflow_shared.template_rendering import (
+    TRUNCATE_MIN_CONTENT_LENGTH,
+    TRUNCATE_PREFIX,
+    TRUNCATE_SUFFIX,
+    truncate_rendered_value,
+)
+
+
+def test_truncate_rendered_value_prioritizes_message():
+    """Test that truncation message is always shown first, content only if 
space allows."""
+    trunc_only = f"{TRUNCATE_PREFIX}{TRUNCATE_SUFFIX}"
+    trunc_only_len = len(trunc_only)
+    overhead = len(TRUNCATE_PREFIX) + len(TRUNCATE_SUFFIX)
+    min_length_for_content = overhead + TRUNCATE_MIN_CONTENT_LENGTH
+
+    test_cases = [
+        (1, "test", "Minimum value"),
+        (3, "test", "At ellipsis length"),
+        (5, "test", "Very small"),
+        (10, "password123", "Small"),
+        (20, "secret_value", "Small with content"),
+        (50, "This is a test string", "Medium"),
+        (overhead + 1, "Hello World", "At prefix+suffix boundary v1"),
+        (overhead + 2, "Hello World", "Just above boundary v1"),
+        (min_length_for_content - 3, "Hello World", "At overhead boundary v2"),
+        (90, "short", "Normal case - short string"),
+        (100, "This is a longer string", "Normal case"),
+        (
+            150,
+            "x" * 200,
+            "Large max_length, long string",
+        ),
+        (100, "None", "String 'None'"),
+        (100, "True", "String 'True'"),
+        (100, "{'key': 'value'}", "Dict-like string"),
+        (100, "test's", "String with apostrophe"),
+        (90, '"quoted"', "String with quotes"),
+    ]
+
+    for max_length, rendered, description in test_cases:
+        result = truncate_rendered_value(rendered, max_length)
+
+        assert result.startswith(TRUNCATE_PREFIX), (
+            f"Failed for {description}: result should start with prefix"
+        )
+
+        if max_length < trunc_only_len or max_length < min_length_for_content:
+            assert result == trunc_only, (
+                f"Failed for {description}: max_length={max_length}, expected 
message only, got: {result}"
+            )
+        else:
+            assert result.endswith(TRUNCATE_SUFFIX), (
+                f"Failed for {description}: result should end with suffix"
+            )
+            assert len(result) <= max_length, (
+                f"Failed for {description}: result length {len(result)} > 
max_length {max_length}"
+            )
+
+
+def test_truncate_rendered_value_exact_expected_output():
+    """Test that truncation produces exact expected output: message first, 
then content when space allows."""
+    trunc_only = TRUNCATE_PREFIX + TRUNCATE_SUFFIX
+    overhead = len(TRUNCATE_PREFIX) + len(TRUNCATE_SUFFIX)
+    min_length_for_content = overhead + TRUNCATE_MIN_CONTENT_LENGTH
+
+    test_cases = [
+        (1, "test", trunc_only),
+        (3, "test", trunc_only),
+        (5, "test", trunc_only),
+        (10, "password123", trunc_only),
+        (20, "secret_value", trunc_only),
+        (50, "This is a test string", trunc_only),
+        (overhead + 1, "Hello World", trunc_only),
+        (overhead + 2, "Hello World", trunc_only),
+        (min_length_for_content - 3, "Hello World", trunc_only),
+        (90, "short", TRUNCATE_PREFIX + "short" + TRUNCATE_SUFFIX),
+        (100, "This is a longer string", TRUNCATE_PREFIX + "This is a longer 
st" + TRUNCATE_SUFFIX),
+        (150, "x" * 200, TRUNCATE_PREFIX + "x" * 69 + TRUNCATE_SUFFIX),
+        (100, "None", TRUNCATE_PREFIX + "None" + TRUNCATE_SUFFIX),
+        (100, "True", TRUNCATE_PREFIX + "True" + TRUNCATE_SUFFIX),
+        (100, "{'key': 'value'}", TRUNCATE_PREFIX + "{'key': 'value'}" + 
TRUNCATE_SUFFIX),
+        (100, "test's", TRUNCATE_PREFIX + "test's" + TRUNCATE_SUFFIX),
+        (90, '"quoted"', TRUNCATE_PREFIX + '"quoted"' + TRUNCATE_SUFFIX),
+    ]
+
+    for max_length, rendered, expected in test_cases:
+        result = truncate_rendered_value(rendered, max_length)
+        assert result == expected, (
+            f"max_length={max_length}, rendered={rendered!r}:\n"
+            f"  expected: {expected!r}\n"
+            f"  got:      {result!r}"
+        )
diff --git a/task-sdk/pyproject.toml b/task-sdk/pyproject.toml
index e3f128077e2..1fcd6e8817c 100644
--- a/task-sdk/pyproject.toml
+++ b/task-sdk/pyproject.toml
@@ -143,6 +143,7 @@ path = "src/airflow/sdk/__init__.py"
 "../shared/listeners/src/airflow_shared/listeners" = 
"src/airflow/sdk/_shared/listeners"
 "../shared/plugins_manager/src/airflow_shared/plugins_manager" = 
"src/airflow/sdk/_shared/plugins_manager"
 "../shared/providers_discovery/src/airflow_shared/providers_discovery" = 
"src/airflow/sdk/_shared/providers_discovery"
+"../shared/template_rendering/src/airflow_shared/template_rendering" = 
"src/airflow/sdk/_shared/template_rendering"
 
 [tool.hatch.build.targets.wheel]
 packages = ["src/airflow"]
diff --git a/task-sdk/src/airflow/sdk/_shared/template_rendering 
b/task-sdk/src/airflow/sdk/_shared/template_rendering
new file mode 120000
index 00000000000..67f141b29a4
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/_shared/template_rendering
@@ -0,0 +1 @@
+../../../../../shared/template_rendering/src/airflow_shared/template_rendering
\ No newline at end of file
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py 
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index 674935f5eae..e26344a81e5 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -42,6 +42,7 @@ from pydantic import AwareDatetime, ConfigDict, Field, 
JsonValue, TypeAdapter
 from airflow.dag_processing.bundles.base import BaseDagBundle, 
BundleVersionLock
 from airflow.dag_processing.bundles.manager import DagBundlesManager
 from airflow.sdk._shared.observability.metrics.stats import Stats
+from airflow.sdk._shared.template_rendering import truncate_rendered_value
 from airflow.sdk.api.client import get_hostname, getuser
 from airflow.sdk.api.datamodels._generated import (
     AssetProfile,
@@ -1002,10 +1003,7 @@ def _serialize_template_field(template_field: Any, name: 
str) -> str | dict | li
                 serialized = str(template_field)
         if len(serialized) > max_length:
             rendered = redact(serialized, name)
-            return (
-                "Truncated. You can change this behaviour in 
[core]max_templated_field_length. "
-                f"{rendered[: max_length - 79]!r}... "
-            )
+            return truncate_rendered_value(str(rendered), max_length)
         return serialized
     if not template_field and not isinstance(template_field, tuple):
         # Avoid unnecessary serialization steps for empty fields unless they 
are tuples
@@ -1019,10 +1017,7 @@ def _serialize_template_field(template_field: Any, name: 
str) -> str | dict | li
     serialized = str(template_field)
     if len(serialized) > max_length:
         rendered = redact(serialized, name)
-        return (
-            "Truncated. You can change this behaviour in 
[core]max_templated_field_length. "
-            f"{rendered[: max_length - 79]!r}... "
-        )
+        return truncate_rendered_value(str(rendered), max_length)
     return template_field
 
 
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py 
b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index 05191806be8..37d3963146b 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -2806,18 +2806,24 @@ class TestRuntimeTaskInstance:
         runtime_ti = create_runtime_ti(task=task, 
dag_id="test_truncation_masking_dag")
         run(runtime_ti, context=runtime_ti.get_template_context(), 
log=mock.MagicMock())
 
-        assert (
-            call(
-                msg=SetRenderedFields(
-                    rendered_fields={
-                        "env_vars": "Truncated. You can change this behaviour 
in [core]max_templated_field_length. \"{'TEST_URL_0': '***', 'TEST_URL_1': 
'***', 'TEST_URL_10': '***', 'TEST_URL_11': '***', 'TEST_URL_12': '***', 
'TEST_URL_13': '***', 'TEST_URL_14': '***', 'TEST_URL_15': '***', 
'TEST_URL_16': '***', 'TEST_URL_17': '***', 'TEST_URL_18': '***', 
'TEST_URL_19': '***', 'TEST_URL_2': '***', 'TEST_URL_20': '***', 'TEST_URL_21': 
'***', 'TEST_URL_22': '***', 'TEST_URL_23': '***', 'TE [...]
-                        "region": "us-west-2",
-                    },
-                    type="SetRenderedFields",
-                )
-            )
-            in mock_supervisor_comms.send.mock_calls
+        msg = next(
+            c.kwargs["msg"]
+            for c in mock_supervisor_comms.send.mock_calls
+            if c.kwargs.get("msg") and getattr(c.kwargs["msg"], "type", None) 
== "SetRenderedFields"
+        )
+        rendered_fields = msg.rendered_fields
+
+        # region is short enough to not be truncated
+        assert rendered_fields["region"] == "us-west-2"
+
+        # env_vars exceeds max_templated_field_length and must be truncated 
with secrets redacted
+        env_vars_value = rendered_fields["env_vars"]
+        assert isinstance(env_vars_value, str)
+        assert env_vars_value.startswith(
+            "Truncated. You can change this behaviour in 
[core]max_templated_field_length. "
         )
+        assert env_vars_value.endswith("...")
+        assert "***" in env_vars_value  # secrets are redacted before 
truncation
 
     @pytest.mark.enable_redact
     def test_rendered_templates_masks_secrets_in_complex_objects(

Reply via email to