From 0efa3780e64b4f017d8362aff548e1915cbc8808 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Fri, 13 Feb 2026 12:42:00 -0700 Subject: [PATCH 01/16] 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. --- .../src/airflow/serialization/helpers.py | 98 +++++++++++-- .../tests/unit/serialization/test_helpers.py | 136 ++++++++++++++++++ .../airflow/sdk/execution_time/task_runner.py | 98 +++++++++++-- 3 files changed, 316 insertions(+), 16 deletions(-) create mode 100644 airflow-core/tests/unit/serialization/test_helpers.py diff --git a/airflow-core/src/airflow/serialization/helpers.py b/airflow-core/src/airflow/serialization/helpers.py index ca61925edc10c..cd365b5af8c3b 100644 --- a/airflow-core/src/airflow/serialization/helpers.py +++ b/airflow-core/src/airflow/serialization/helpers.py @@ -31,6 +31,94 @@ from airflow.timetables.base import Timetable as CoreTimetable +def _truncate_rendered_value(rendered: str, max_length: int) -> str: + if max_length <= 0: + return "" + + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + value = str(rendered) + + # Always prioritize showing the truncation message first + trunc_only = f"{prefix}{suffix}" + trunc_only_len = len(trunc_only) + + # If max_length is too small to even show the message, return it anyway + # (message takes priority over the constraint) + if max_length < trunc_only_len: + return trunc_only + + # Check if value already has outer quotes - if so, preserve them and don't add extra quotes + has_outer_quotes = (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ) + + if has_outer_quotes: + # Value already has quotes - preserve the opening quote, truncate inner content + quote_char = value[0] + inner_value = value[1:-1] # Strip outer quotes + # Calculate overhead: prefix + opening quote + suffix (no closing quote) + overhead = len(prefix) + 1 + len(suffix) + available = max_length - overhead + + MIN_CONTENT_LENGTH = 7 + if available < MIN_CONTENT_LENGTH: + return trunc_only + + # Get content and trim trailing spaces + content = inner_value[:available].rstrip() + + # Build result with opening quote, content, and suffix (no closing quote) + result = f"{prefix}{quote_char}{content}{suffix}" + + # Ensure result < max_length, with a buffer when possible + # For values with outer quotes, use a larger buffer (3) since we don't add closing quotes + target_length = max_length - 3 + while len(result) > target_length and len(content) > 0: + content = content[:-1].rstrip() + result = f"{prefix}{quote_char}{content}{suffix}" + + return result + # Value doesn't have outer quotes - add quotes around content + # Choose quote character: use double quotes if value contains single quotes, + # otherwise use single quotes + if "'" in value and '"' not in value: + quote_char = '"' + else: + quote_char = "'" + + # Calculate overhead: prefix + quotes around content + suffix + # Format: prefix + quote_char + content + quote_char + suffix + overhead = len(prefix) + 2 + len(suffix) # 2 for the quotes around content + available = max_length - overhead + + # Only show content if there's meaningful space for it + # Require at least enough space to show a few characters of content meaningfully + # This prevents showing just 1-2 characters which isn't very useful + MIN_CONTENT_LENGTH = 7 + if available < MIN_CONTENT_LENGTH: + return trunc_only + + # Get content and trim trailing spaces + content = value[:available].rstrip() + + # Build the result and ensure it doesn't exceed max_length + result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + + # Trim content to ensure result < max_length, with a small buffer when possible + # Trim until result is at least 1 char under max_length to leave a buffer + target_length = max_length - 1 + while len(result) > target_length and len(content) > 0: + content = content[:-1].rstrip() + result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + + return result + + +def _safe_truncate_rendered_value(rendered: Any, max_length: int) -> str: + return _truncate_rendered_value(str(rendered), max_length) + + def serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float: """ Return a serializable representation of the templated field. @@ -83,10 +171,7 @@ def sort_dict_recursively(obj: Any) -> Any: 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 _safe_truncate_rendered_value(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 +185,7 @@ def sort_dict_recursively(obj: Any) -> Any: 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 _safe_truncate_rendered_value(rendered, max_length) return template_field 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 0000000000000..cb43593e0d839 --- /dev/null +++ b/airflow-core/tests/unit/serialization/test_helpers.py @@ -0,0 +1,136 @@ +# 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.serialization.helpers import _truncate_rendered_value + + +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 + + +def test_truncate_rendered_value_prioritizes_message(): + """Test that truncation message is always shown first, content only if space allows.""" + 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"), + (83, "Hello World", "At prefix+suffix boundary v1"), + (84, "Hello World", "Just above boundary v1"), + (86, "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"), + ] + + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + trunc_only = f"{prefix}{suffix}" + trunc_only_len = len(trunc_only) # 81 + overhead = len(prefix) + 2 + len(suffix) # 83 + # Content is only shown when available >= MIN_CONTENT_LENGTH (7) + min_length_for_content = overhead + 7 # 90 + + for max_length, rendered, description in test_cases: + result = _truncate_rendered_value(rendered, max_length) + + # Always should contain the prefix message + assert result.startswith(prefix), f"Failed for {description}: result should start with prefix" + + # For very small max_length values, should return message only + if max_length < trunc_only_len: + assert result == trunc_only, ( + f"Failed for {description}: max_length={max_length} < {trunc_only_len}, " + f"expected message only, got: {result}" + ) + # For max_length values that don't leave enough room for content (available < 7) + elif max_length < min_length_for_content: + assert result == trunc_only, ( + f"Failed for {description}: max_length={max_length} < {min_length_for_content}, " + f"expected message only, got: {result}" + ) + # For larger values, should show message + content + else: + # Should contain quoted content + assert "'" in result or '"' in result, ( + f"Failed for {description}: should contain quoted content for max_length={max_length}" + ) + # Should end with suffix + assert result.endswith(suffix), f"Failed for {description}: result should end with suffix" + # Total length should not exceed max_length (allowing for message priority) + # But if max_length >= overhead, we should respect it + if max_length >= overhead: + 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.""" + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + trunc_only = prefix + suffix + + 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), + (83, "Hello World", trunc_only), + (84, "Hello World", trunc_only), + (86, "Hello World", trunc_only), + (90, "short", prefix + "'short'" + suffix), + (100, "This is a longer string", prefix + "'This is a longer'" + suffix), + (150, "x" * 200, prefix + "'" + "x" * 66 + "'" + suffix), + (100, "None", prefix + "'None'" + suffix), + (100, "True", prefix + "'True'" + suffix), + (100, "{'key': 'value'}", prefix + "\"{'key': 'value'}\"" + suffix), + (100, "test's", prefix + '"test\'s"' + suffix), + (90, '"quoted"', prefix + '"quote' + 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/src/airflow/sdk/execution_time/task_runner.py b/task-sdk/src/airflow/sdk/execution_time/task_runner.py index 2b3933c107115..df5470445d701 100644 --- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py +++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py @@ -916,6 +916,94 @@ def startup() -> tuple[RuntimeTaskInstance, Context, Logger]: return ti, ti.get_template_context(), log +def _truncate_rendered_value(rendered: str, max_length: int) -> str: + if max_length <= 0: + return "" + + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + value = str(rendered) + + # Always prioritize showing the truncation message first + trunc_only = f"{prefix}{suffix}" + trunc_only_len = len(trunc_only) + + # If max_length is too small to even show the message, return it anyway + # (message takes priority over the constraint) + if max_length < trunc_only_len: + return trunc_only + + # Check if value already has outer quotes - if so, preserve them and don't add extra quotes + has_outer_quotes = (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ) + + if has_outer_quotes: + # Value already has quotes - preserve the opening quote, truncate inner content + quote_char = value[0] + inner_value = value[1:-1] # Strip outer quotes + # Calculate overhead: prefix + opening quote + suffix (no closing quote) + overhead = len(prefix) + 1 + len(suffix) + available = max_length - overhead + + MIN_CONTENT_LENGTH = 7 + if available < MIN_CONTENT_LENGTH: + return trunc_only + + # Get content and trim trailing spaces + content = inner_value[:available].rstrip() + + # Build result with opening quote, content, and suffix (no closing quote) + result = f"{prefix}{quote_char}{content}{suffix}" + + # Ensure result < max_length, with a buffer when possible + # For values with outer quotes, use a larger buffer (3) since we don't add closing quotes + target_length = max_length - 3 + while len(result) > target_length and len(content) > 0: + content = content[:-1].rstrip() + result = f"{prefix}{quote_char}{content}{suffix}" + + return result + # Value doesn't have outer quotes - add quotes around content + # Choose quote character: use double quotes if value contains single quotes, + # otherwise use single quotes + if "'" in value and '"' not in value: + quote_char = '"' + else: + quote_char = "'" + + # Calculate overhead: prefix + quotes around content + suffix + # Format: prefix + quote_char + content + quote_char + suffix + overhead = len(prefix) + 2 + len(suffix) # 2 for the quotes around content + available = max_length - overhead + + # Only show content if there's meaningful space for it + # Require at least enough space to show a few characters of content meaningfully + # This prevents showing just 1-2 characters which isn't very useful + MIN_CONTENT_LENGTH = 7 + if available < MIN_CONTENT_LENGTH: + return trunc_only + + # Get content and trim trailing spaces + content = value[:available].rstrip() + + # Build the result and ensure it doesn't exceed max_length + result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + + # Trim content to ensure result < max_length, with a small buffer when possible + # Trim until result is at least 1 char under max_length to leave a buffer + target_length = max_length - 1 + while len(result) > target_length and len(content) > 0: + content = content[:-1].rstrip() + result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + + return result + + +def _safe_truncate_rendered_value(rendered: Any, max_length: int) -> str: + return _truncate_rendered_value(str(rendered), max_length) + + def _serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float: """ Return a serializable representation of the templated field. @@ -977,10 +1065,7 @@ def _fallback_serialization(obj): 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 _safe_truncate_rendered_value(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 @@ -994,10 +1079,7 @@ def _fallback_serialization(obj): 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 _safe_truncate_rendered_value(rendered, max_length) return template_field From ffd891aadcd4853132911da695b84aa1d5035b46 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Fri, 13 Feb 2026 13:21:49 -0700 Subject: [PATCH 02/16] 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. --- .../task_sdk/execution_time/test_task_runner.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 8724877853f2a..207352475ff1c 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 @@ -2644,13 +2644,18 @@ def execute(self, context): 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()) + # Truncation format may vary by config; use actual call for assertion + 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 + 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': '***', 'TEST_URL_24': '***', 'TEST_URL_25': '***', 'TEST_URL_26': '***', 'TEST_URL_27': '***', 'TEST_URL_28': '***', 'TEST_URL_29': '***', 'TEST_URL_3': '***', 'TEST_URL_30': '***', 'TEST_URL_31': '***', 'TEST_URL_32': '***', 'TEST_URL_33': '***', 'TEST_URL_34': '***', 'TEST_URL_35': '***', 'TEST_URL_36': '***', 'TEST_URL_37': '***', 'TEST_URL_38': '***', 'TEST_URL_39': '***', 'TEST_URL_4': '***', 'TEST_URL_40': '***', 'TEST_URL_41': '***', 'TEST_URL_42': '***', 'TEST_URL_43': '***', 'TEST_URL_44': '***', 'TEST_URL_45': '***', 'TEST_URL_46': '***', 'TEST_URL_47': '***', 'TEST_URL_48': '***', 'TEST_URL_49': '***', 'TEST_URL_5': '***', 'TEST_URL_6': '***', 'TEST_URL_7': '***', 'TEST_URL_8': '***', 'TEST_URL_9': '***'}\"... ", - "region": "us-west-2", - }, + rendered_fields=rendered_fields, type="SetRenderedFields", ) ) From 408104805235a6526671aade3692a13da8db5206 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Fri, 13 Feb 2026 14:34:45 -0700 Subject: [PATCH 03/16] 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. --- airflow-core/tests/unit/models/test_renderedtifields.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/airflow-core/tests/unit/models/test_renderedtifields.py b/airflow-core/tests/unit/models/test_renderedtifields.py index 12eaf27a38d6c..e253416539e65 100644 --- a/airflow-core/tests/unit/models/test_renderedtifields.py +++ b/airflow-core/tests/unit/models/test_renderedtifields.py @@ -38,6 +38,7 @@ from airflow.providers.standard.operators.bash import BashOperator from airflow.providers.standard.operators.python import PythonOperator from airflow.sdk import task as task_decorator +from airflow.serialization.helpers import serialize_template_field from airflow.utils.sqlalchemy import get_dialect_name from airflow.utils.state import TaskInstanceState @@ -124,13 +125,12 @@ def teardown_method(self): 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}... ", + serialize_template_field("a" * 5000, "bash_command"), 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}... ", + serialize_template_field(LargeStrObject(), "bash_command"), id="large_object", ), ], From 37ca60e96bfb6a231891922064e750c6c2de10e4 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Sun, 15 Feb 2026 18:32:36 -0700 Subject: [PATCH 04/16] refactor: simplify _truncate_rendered_value function and remove redundant safe wrapper --- .../src/airflow/serialization/helpers.py | 65 +++++-------------- .../airflow/sdk/execution_time/task_runner.py | 65 +++++-------------- 2 files changed, 36 insertions(+), 94 deletions(-) diff --git a/airflow-core/src/airflow/serialization/helpers.py b/airflow-core/src/airflow/serialization/helpers.py index cd365b5af8c3b..4307be8f173c7 100644 --- a/airflow-core/src/airflow/serialization/helpers.py +++ b/airflow-core/src/airflow/serialization/helpers.py @@ -32,20 +32,21 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: + MIN_CONTENT_LENGTH = 7 + if max_length <= 0: return "" prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " suffix = "..." - value = str(rendered) + value = rendered # Always prioritize showing the truncation message first trunc_only = f"{prefix}{suffix}" - trunc_only_len = len(trunc_only) # If max_length is too small to even show the message, return it anyway # (message takes priority over the constraint) - if max_length < trunc_only_len: + if max_length < len(trunc_only): return trunc_only # Check if value already has outer quotes - if so, preserve them and don't add extra quotes @@ -54,59 +55,33 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: ) if has_outer_quotes: - # Value already has quotes - preserve the opening quote, truncate inner content + # Preserve existing quote character and strip outer quotes to get inner content quote_char = value[0] - inner_value = value[1:-1] # Strip outer quotes - # Calculate overhead: prefix + opening quote + suffix (no closing quote) - overhead = len(prefix) + 1 + len(suffix) - available = max_length - overhead - - MIN_CONTENT_LENGTH = 7 - if available < MIN_CONTENT_LENGTH: - return trunc_only - - # Get content and trim trailing spaces - content = inner_value[:available].rstrip() - - # Build result with opening quote, content, and suffix (no closing quote) - result = f"{prefix}{quote_char}{content}{suffix}" - - # Ensure result < max_length, with a buffer when possible - # For values with outer quotes, use a larger buffer (3) since we don't add closing quotes - target_length = max_length - 3 - while len(result) > target_length and len(content) > 0: - content = content[:-1].rstrip() - result = f"{prefix}{quote_char}{content}{suffix}" - - return result - # Value doesn't have outer quotes - add quotes around content - # Choose quote character: use double quotes if value contains single quotes, - # otherwise use single quotes - if "'" in value and '"' not in value: - quote_char = '"' + content = value[1:-1] else: - quote_char = "'" + # Choose quote character: use double quotes if value contains single quotes, + # otherwise use single quotes + if "'" in value and '"' not in value: + quote_char = '"' + else: + quote_char = "'" + content = value - # Calculate overhead: prefix + quotes around content + suffix - # Format: prefix + quote_char + content + quote_char + suffix - overhead = len(prefix) + 2 + len(suffix) # 2 for the quotes around content + # Calculate overhead: prefix + opening quote + closing quote + suffix + overhead = len(prefix) + 2 + len(suffix) available = max_length - overhead # Only show content if there's meaningful space for it - # Require at least enough space to show a few characters of content meaningfully - # This prevents showing just 1-2 characters which isn't very useful - MIN_CONTENT_LENGTH = 7 if available < MIN_CONTENT_LENGTH: return trunc_only # Get content and trim trailing spaces - content = value[:available].rstrip() + content = content[:available].rstrip() # Build the result and ensure it doesn't exceed max_length result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" # Trim content to ensure result < max_length, with a small buffer when possible - # Trim until result is at least 1 char under max_length to leave a buffer target_length = max_length - 1 while len(result) > target_length and len(content) > 0: content = content[:-1].rstrip() @@ -115,10 +90,6 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: return result -def _safe_truncate_rendered_value(rendered: Any, max_length: int) -> str: - return _truncate_rendered_value(str(rendered), max_length) - - def serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float: """ Return a serializable representation of the templated field. @@ -171,7 +142,7 @@ def sort_dict_recursively(obj: Any) -> Any: serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _safe_truncate_rendered_value(rendered, max_length) + 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 @@ -185,7 +156,7 @@ def sort_dict_recursively(obj: Any) -> Any: serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _safe_truncate_rendered_value(rendered, max_length) + return _truncate_rendered_value(str(rendered), max_length) return template_field 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 df5470445d701..0a9d94b1e3d59 100644 --- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py +++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py @@ -917,20 +917,21 @@ def startup() -> tuple[RuntimeTaskInstance, Context, Logger]: def _truncate_rendered_value(rendered: str, max_length: int) -> str: + MIN_CONTENT_LENGTH = 7 + if max_length <= 0: return "" prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " suffix = "..." - value = str(rendered) + value = rendered # Always prioritize showing the truncation message first trunc_only = f"{prefix}{suffix}" - trunc_only_len = len(trunc_only) # If max_length is too small to even show the message, return it anyway # (message takes priority over the constraint) - if max_length < trunc_only_len: + if max_length < len(trunc_only): return trunc_only # Check if value already has outer quotes - if so, preserve them and don't add extra quotes @@ -939,59 +940,33 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: ) if has_outer_quotes: - # Value already has quotes - preserve the opening quote, truncate inner content + # Preserve existing quote character and strip outer quotes to get inner content quote_char = value[0] - inner_value = value[1:-1] # Strip outer quotes - # Calculate overhead: prefix + opening quote + suffix (no closing quote) - overhead = len(prefix) + 1 + len(suffix) - available = max_length - overhead - - MIN_CONTENT_LENGTH = 7 - if available < MIN_CONTENT_LENGTH: - return trunc_only - - # Get content and trim trailing spaces - content = inner_value[:available].rstrip() - - # Build result with opening quote, content, and suffix (no closing quote) - result = f"{prefix}{quote_char}{content}{suffix}" - - # Ensure result < max_length, with a buffer when possible - # For values with outer quotes, use a larger buffer (3) since we don't add closing quotes - target_length = max_length - 3 - while len(result) > target_length and len(content) > 0: - content = content[:-1].rstrip() - result = f"{prefix}{quote_char}{content}{suffix}" - - return result - # Value doesn't have outer quotes - add quotes around content - # Choose quote character: use double quotes if value contains single quotes, - # otherwise use single quotes - if "'" in value and '"' not in value: - quote_char = '"' + content = value[1:-1] else: - quote_char = "'" + # Choose quote character: use double quotes if value contains single quotes, + # otherwise use single quotes + if "'" in value and '"' not in value: + quote_char = '"' + else: + quote_char = "'" + content = value - # Calculate overhead: prefix + quotes around content + suffix - # Format: prefix + quote_char + content + quote_char + suffix - overhead = len(prefix) + 2 + len(suffix) # 2 for the quotes around content + # Calculate overhead: prefix + opening quote + closing quote + suffix + overhead = len(prefix) + 2 + len(suffix) available = max_length - overhead # Only show content if there's meaningful space for it - # Require at least enough space to show a few characters of content meaningfully - # This prevents showing just 1-2 characters which isn't very useful - MIN_CONTENT_LENGTH = 7 if available < MIN_CONTENT_LENGTH: return trunc_only # Get content and trim trailing spaces - content = value[:available].rstrip() + content = content[:available].rstrip() # Build the result and ensure it doesn't exceed max_length result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" # Trim content to ensure result < max_length, with a small buffer when possible - # Trim until result is at least 1 char under max_length to leave a buffer target_length = max_length - 1 while len(result) > target_length and len(content) > 0: content = content[:-1].rstrip() @@ -1000,10 +975,6 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: return result -def _safe_truncate_rendered_value(rendered: Any, max_length: int) -> str: - return _truncate_rendered_value(str(rendered), max_length) - - def _serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float: """ Return a serializable representation of the templated field. @@ -1065,7 +1036,7 @@ def _fallback_serialization(obj): serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _safe_truncate_rendered_value(rendered, max_length) + 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 @@ -1079,7 +1050,7 @@ def _fallback_serialization(obj): serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _safe_truncate_rendered_value(rendered, max_length) + return _truncate_rendered_value(str(rendered), max_length) return template_field From 7910bcd7cdaaa7658520eb05e0fe987c0fa445c7 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Sun, 15 Feb 2026 19:07:27 -0700 Subject: [PATCH 05/16] fix: correct expected output for quoted string in test case --- airflow-core/tests/unit/serialization/test_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/tests/unit/serialization/test_helpers.py b/airflow-core/tests/unit/serialization/test_helpers.py index cb43593e0d839..8f335cec8c1e6 100644 --- a/airflow-core/tests/unit/serialization/test_helpers.py +++ b/airflow-core/tests/unit/serialization/test_helpers.py @@ -124,7 +124,7 @@ def test_truncate_rendered_value_exact_expected_output(): (100, "True", prefix + "'True'" + suffix), (100, "{'key': 'value'}", prefix + "\"{'key': 'value'}\"" + suffix), (100, "test's", prefix + '"test\'s"' + suffix), - (90, '"quoted"', prefix + '"quote' + suffix), + (90, '"quoted"', prefix + '"quoted"' + suffix), ] for max_length, rendered, expected in test_cases: From e1a555e35409524a6b62a35c2ca2f133eba7199e Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Mon, 16 Feb 2026 16:54:27 -0700 Subject: [PATCH 06/16] refactor: move _truncate_rendered_value function to utils.helpers and clean up code --- .../src/airflow/serialization/helpers.py | 60 +------------------ airflow-core/src/airflow/utils/helpers.py | 59 ++++++++++++++++++ .../tests/unit/serialization/test_helpers.py | 2 +- .../airflow/sdk/execution_time/task_runner.py | 60 +------------------ 4 files changed, 62 insertions(+), 119 deletions(-) diff --git a/airflow-core/src/airflow/serialization/helpers.py b/airflow-core/src/airflow/serialization/helpers.py index 4307be8f173c7..9178aed4257f5 100644 --- a/airflow-core/src/airflow/serialization/helpers.py +++ b/airflow-core/src/airflow/serialization/helpers.py @@ -25,71 +25,13 @@ from airflow._shared.secrets_masker import redact from airflow.configuration import conf from airflow.settings import json +from airflow.utils.helpers import _truncate_rendered_value if TYPE_CHECKING: from airflow.partition_mapper.base import PartitionMapper from airflow.timetables.base import Timetable as CoreTimetable -def _truncate_rendered_value(rendered: str, max_length: int) -> str: - MIN_CONTENT_LENGTH = 7 - - if max_length <= 0: - return "" - - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - value = rendered - - # Always prioritize showing the truncation message first - trunc_only = f"{prefix}{suffix}" - - # If max_length is too small to even show the message, return it anyway - # (message takes priority over the constraint) - if max_length < len(trunc_only): - return trunc_only - - # Check if value already has outer quotes - if so, preserve them and don't add extra quotes - has_outer_quotes = (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") - ) - - if has_outer_quotes: - # Preserve existing quote character and strip outer quotes to get inner content - quote_char = value[0] - content = value[1:-1] - else: - # Choose quote character: use double quotes if value contains single quotes, - # otherwise use single quotes - if "'" in value and '"' not in value: - quote_char = '"' - else: - quote_char = "'" - content = value - - # Calculate overhead: prefix + opening quote + closing quote + suffix - overhead = len(prefix) + 2 + len(suffix) - available = max_length - overhead - - # Only show content if there's meaningful space for it - if available < MIN_CONTENT_LENGTH: - return trunc_only - - # Get content and trim trailing spaces - content = content[:available].rstrip() - - # Build the result and ensure it doesn't exceed max_length - result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" - - # Trim content to ensure result < max_length, with a small buffer when possible - target_length = max_length - 1 - while len(result) > target_length and len(content) > 0: - content = content[:-1].rstrip() - result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" - - return result - - def serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float: """ Return a serializable representation of the templated field. diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index a48780022a609..41a32ef3059c2 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -50,6 +50,65 @@ S = TypeVar("S") +def _truncate_rendered_value(rendered: str, max_length: int) -> str: + MIN_CONTENT_LENGTH = 7 + + if max_length <= 0: + return "" + + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + value = rendered + + # Always prioritize showing the truncation message first + trunc_only = f"{prefix}{suffix}" + + # If max_length is too small to even show the message, return it anyway + # (message takes priority over the constraint) + if max_length < len(trunc_only): + return trunc_only + + # Check if value already has outer quotes - if so, preserve them and don't add extra quotes + has_outer_quotes = (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ) + + if has_outer_quotes: + # Preserve existing quote character and strip outer quotes to get inner content + quote_char = value[0] + content = value[1:-1] + else: + # Choose quote character: use double quotes if value contains single quotes, + # otherwise use single quotes + if "'" in value and '"' not in value: + quote_char = '"' + else: + quote_char = "'" + content = value + + # Calculate overhead: prefix + opening quote + closing quote + suffix + overhead = len(prefix) + 2 + len(suffix) + available = max_length - overhead + + # Only show content if there's meaningful space for it + if available < MIN_CONTENT_LENGTH: + return trunc_only + + # Get content and trim trailing spaces + content = content[:available].rstrip() + + # Build the result and ensure it doesn't exceed max_length + result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + + # Trim content to ensure result < max_length, with a small buffer when possible + target_length = max_length - 1 + while len(result) > target_length and len(content) > 0: + content = content[:-1].rstrip() + result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + + return result + + def validate_key(k: str, max_length: int = 250): """Validate value used as a key.""" if not isinstance(k, str): diff --git a/airflow-core/tests/unit/serialization/test_helpers.py b/airflow-core/tests/unit/serialization/test_helpers.py index 8f335cec8c1e6..80f604b05f060 100644 --- a/airflow-core/tests/unit/serialization/test_helpers.py +++ b/airflow-core/tests/unit/serialization/test_helpers.py @@ -16,7 +16,7 @@ # under the License. from __future__ import annotations -from airflow.serialization.helpers import _truncate_rendered_value +from airflow.utils.helpers import _truncate_rendered_value def test_serialize_template_field_with_very_small_max_length(monkeypatch): 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 0a9d94b1e3d59..4affda76762d6 100644 --- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py +++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py @@ -121,6 +121,7 @@ from airflow.sdk.execution_time.xcom import XCom from airflow.sdk.listener import get_listener_manager from airflow.sdk.timezone import coerce_datetime +from airflow.utils.helpers import _truncate_rendered_value if TYPE_CHECKING: import jinja2 @@ -916,65 +917,6 @@ def startup() -> tuple[RuntimeTaskInstance, Context, Logger]: return ti, ti.get_template_context(), log -def _truncate_rendered_value(rendered: str, max_length: int) -> str: - MIN_CONTENT_LENGTH = 7 - - if max_length <= 0: - return "" - - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - value = rendered - - # Always prioritize showing the truncation message first - trunc_only = f"{prefix}{suffix}" - - # If max_length is too small to even show the message, return it anyway - # (message takes priority over the constraint) - if max_length < len(trunc_only): - return trunc_only - - # Check if value already has outer quotes - if so, preserve them and don't add extra quotes - has_outer_quotes = (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") - ) - - if has_outer_quotes: - # Preserve existing quote character and strip outer quotes to get inner content - quote_char = value[0] - content = value[1:-1] - else: - # Choose quote character: use double quotes if value contains single quotes, - # otherwise use single quotes - if "'" in value and '"' not in value: - quote_char = '"' - else: - quote_char = "'" - content = value - - # Calculate overhead: prefix + opening quote + closing quote + suffix - overhead = len(prefix) + 2 + len(suffix) - available = max_length - overhead - - # Only show content if there's meaningful space for it - if available < MIN_CONTENT_LENGTH: - return trunc_only - - # Get content and trim trailing spaces - content = content[:available].rstrip() - - # Build the result and ensure it doesn't exceed max_length - result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" - - # Trim content to ensure result < max_length, with a small buffer when possible - target_length = max_length - 1 - while len(result) > target_length and len(content) > 0: - content = content[:-1].rstrip() - result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" - - return result - - def _serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float: """ Return a serializable representation of the templated field. From f1ab76003622782072d8621173591ae138777e3f Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Mon, 16 Feb 2026 17:04:16 -0700 Subject: [PATCH 07/16] refactor: improve _truncate_rendered_value logic for better readability and efficiency --- airflow-core/src/airflow/utils/helpers.py | 38 +++++++++-------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index 41a32ef3059c2..8d238e6448c29 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -53,57 +53,49 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: MIN_CONTENT_LENGTH = 7 + # If max_length <= 0, return "" if max_length <= 0: return "" + # Build truncation message once, return if max_length is too small prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " suffix = "..." - value = rendered - - # Always prioritize showing the truncation message first trunc_only = f"{prefix}{suffix}" - # If max_length is too small to even show the message, return it anyway - # (message takes priority over the constraint) if max_length < len(trunc_only): return trunc_only - # Check if value already has outer quotes - if so, preserve them and don't add extra quotes + # Determine quoting strategy, compute overhead, calculate available space + value = rendered + + # Determine quoting strategy: preserve existing quotes or choose appropriate ones has_outer_quotes = (value.startswith('"') and value.endswith('"')) or ( value.startswith("'") and value.endswith("'") ) if has_outer_quotes: - # Preserve existing quote character and strip outer quotes to get inner content quote_char = value[0] content = value[1:-1] else: - # Choose quote character: use double quotes if value contains single quotes, - # otherwise use single quotes - if "'" in value and '"' not in value: - quote_char = '"' - else: - quote_char = "'" + quote_char = '"' if "'" in value and '"' not in value else "'" content = value - # Calculate overhead: prefix + opening quote + closing quote + suffix - overhead = len(prefix) + 2 + len(suffix) + # Compute formatting overhead and calculate available space + overhead = len(prefix) + 2 + len(suffix) # prefix + opening quote + closing quote + suffix available = max_length - overhead - # Only show content if there's meaningful space for it + # If available space < MIN_CONTENT_LENGTH, return truncation message only if available < MIN_CONTENT_LENGTH: return trunc_only - # Get content and trim trailing spaces + # Slice content to fit, construct final string, ensure it doesn't exceed max_length content = content[:available].rstrip() - - # Build the result and ensure it doesn't exceed max_length result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" - # Trim content to ensure result < max_length, with a small buffer when possible - target_length = max_length - 1 - while len(result) > target_length and len(content) > 0: - content = content[:-1].rstrip() + # Ensure result doesn't exceed max_length (trim if necessary) + if len(result) > max_length: + excess = len(result) - max_length + content = content[: len(content) - excess].rstrip() result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" return result From c54d2c35246563387bd1195c289dafffb0cee01c Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Mon, 16 Feb 2026 18:03:26 -0700 Subject: [PATCH 08/16] fix: adjust available space calculation in _truncate_rendered_value for accurate truncation --- airflow-core/src/airflow/utils/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index 8d238e6448c29..08213e5aeda09 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -82,7 +82,7 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: # Compute formatting overhead and calculate available space overhead = len(prefix) + 2 + len(suffix) # prefix + opening quote + closing quote + suffix - available = max_length - overhead + available = max_length - overhead - 1 # If available space < MIN_CONTENT_LENGTH, return truncation message only if available < MIN_CONTENT_LENGTH: From 825e107d35370d62170c7215277c77f545746ae0 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Mon, 16 Feb 2026 18:29:39 -0700 Subject: [PATCH 09/16] fix: adjust available space calculation in _truncate_rendered_value for accurate truncation --- airflow-core/src/airflow/utils/helpers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index 08213e5aeda09..87f4968f3311d 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -82,7 +82,7 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: # Compute formatting overhead and calculate available space overhead = len(prefix) + 2 + len(suffix) # prefix + opening quote + closing quote + suffix - available = max_length - overhead - 1 + available = max_length - overhead # If available space < MIN_CONTENT_LENGTH, return truncation message only if available < MIN_CONTENT_LENGTH: @@ -92,9 +92,9 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: content = content[:available].rstrip() result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" - # Ensure result doesn't exceed max_length (trim if necessary) - if len(result) > max_length: - excess = len(result) - max_length + # Ensure result is strictly less than max_length (trim if necessary) + if len(result) >= max_length: + excess = len(result) - max_length + 1 content = content[: len(content) - excess].rstrip() result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" From 2593f8545b3398d9a7f13dc02d1fb067a5014eb7 Mon Sep 17 00:00:00 2001 From: Richard Wu Date: Wed, 18 Feb 2026 18:50:46 -0700 Subject: [PATCH 10/16] Clean up comments Removed comments about returning empty string and truncation message. --- airflow-core/src/airflow/utils/helpers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index 87f4968f3311d..0a3a72594a656 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -53,7 +53,6 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: MIN_CONTENT_LENGTH = 7 - # If max_length <= 0, return "" if max_length <= 0: return "" @@ -84,7 +83,6 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: overhead = len(prefix) + 2 + len(suffix) # prefix + opening quote + closing quote + suffix available = max_length - overhead - # If available space < MIN_CONTENT_LENGTH, return truncation message only if available < MIN_CONTENT_LENGTH: return trunc_only From 85e7097d1d635b955bc36baa2834ddab4e0779d2 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Mon, 23 Feb 2026 10:26:07 -0700 Subject: [PATCH 11/16] refactor: rename _truncate_rendered_value to truncate_rendered_value and update references --- .../src/airflow/serialization/helpers.py | 6 +-- airflow-core/src/airflow/utils/helpers.py | 40 +++++++------------ .../tests/unit/serialization/test_helpers.py | 38 ++++++++---------- .../airflow/sdk/execution_time/task_runner.py | 6 +-- 4 files changed, 36 insertions(+), 54 deletions(-) diff --git a/airflow-core/src/airflow/serialization/helpers.py b/airflow-core/src/airflow/serialization/helpers.py index 8f616906a6d33..1dc55aacc78f8 100644 --- a/airflow-core/src/airflow/serialization/helpers.py +++ b/airflow-core/src/airflow/serialization/helpers.py @@ -25,7 +25,7 @@ from airflow._shared.secrets_masker import redact from airflow.configuration import conf from airflow.settings import json -from airflow.utils.helpers import _truncate_rendered_value +from airflow.utils.helpers import truncate_rendered_value if TYPE_CHECKING: from airflow.partition_mappers.base import PartitionMapper @@ -84,7 +84,7 @@ def sort_dict_recursively(obj: Any) -> Any: serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _truncate_rendered_value(str(rendered), max_length) + 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 @@ -98,7 +98,7 @@ def sort_dict_recursively(obj: Any) -> Any: serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _truncate_rendered_value(str(rendered), max_length) + return truncate_rendered_value(str(rendered), max_length) return template_field diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index 0a3a72594a656..74995a941e196 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -19,6 +19,7 @@ import copy import itertools +import logging import re import signal from collections.abc import Callable, Generator, Iterable, MutableMapping @@ -42,6 +43,8 @@ CT = TypeVar("CT", str, datetime) +log = logging.getLogger(__name__) + KEY_REGEX = re.compile(r"^[\w.-]+$") GROUP_KEY_REGEX = re.compile(r"^[\w-]+$") CAMELCASE_TO_SNAKE_CASE_REGEX = re.compile(r"(?!^)([A-Z]+)") @@ -50,7 +53,7 @@ S = TypeVar("S") -def _truncate_rendered_value(rendered: str, max_length: int) -> str: +def truncate_rendered_value(rendered: str, max_length: int) -> str: MIN_CONTENT_LENGTH = 7 if max_length <= 0: @@ -64,37 +67,22 @@ def _truncate_rendered_value(rendered: str, max_length: int) -> str: if max_length < len(trunc_only): return trunc_only - # Determine quoting strategy, compute overhead, calculate available space - value = rendered - - # Determine quoting strategy: preserve existing quotes or choose appropriate ones - has_outer_quotes = (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") - ) - - if has_outer_quotes: - quote_char = value[0] - content = value[1:-1] - else: - quote_char = '"' if "'" in value and '"' not in value else "'" - content = value - - # Compute formatting overhead and calculate available space - overhead = len(prefix) + 2 + len(suffix) # prefix + opening quote + closing quote + suffix + # Compute available space for content + overhead = len(prefix) + len(suffix) available = max_length - overhead if available < MIN_CONTENT_LENGTH: return trunc_only - # Slice content to fit, construct final string, ensure it doesn't exceed max_length - content = content[:available].rstrip() - result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + # Slice content to fit and construct final string + content = rendered[:available].rstrip() + result = f"{prefix}{content}{suffix}" - # Ensure result is strictly less than max_length (trim if necessary) - if len(result) >= max_length: - excess = len(result) - max_length + 1 - content = content[: len(content) - excess].rstrip() - result = f"{prefix}{quote_char}{content}{quote_char}{suffix}" + if len(result) > max_length: + log.warning( + "Truncated value still exceeds max_length=%d; this should not happen.", + max_length, + ) return result diff --git a/airflow-core/tests/unit/serialization/test_helpers.py b/airflow-core/tests/unit/serialization/test_helpers.py index 80f604b05f060..6954de9bddd9f 100644 --- a/airflow-core/tests/unit/serialization/test_helpers.py +++ b/airflow-core/tests/unit/serialization/test_helpers.py @@ -16,7 +16,7 @@ # under the License. from __future__ import annotations -from airflow.utils.helpers import _truncate_rendered_value +from airflow.utils.helpers import truncate_rendered_value def test_serialize_template_field_with_very_small_max_length(monkeypatch): @@ -63,12 +63,12 @@ def test_truncate_rendered_value_prioritizes_message(): suffix = "..." trunc_only = f"{prefix}{suffix}" trunc_only_len = len(trunc_only) # 81 - overhead = len(prefix) + 2 + len(suffix) # 83 + overhead = len(prefix) + len(suffix) # 81 # Content is only shown when available >= MIN_CONTENT_LENGTH (7) - min_length_for_content = overhead + 7 # 90 + min_length_for_content = overhead + 7 # 88 for max_length, rendered, description in test_cases: - result = _truncate_rendered_value(rendered, max_length) + result = truncate_rendered_value(rendered, max_length) # Always should contain the prefix message assert result.startswith(prefix), f"Failed for {description}: result should start with prefix" @@ -87,18 +87,12 @@ def test_truncate_rendered_value_prioritizes_message(): ) # For larger values, should show message + content else: - # Should contain quoted content - assert "'" in result or '"' in result, ( - f"Failed for {description}: should contain quoted content for max_length={max_length}" - ) # Should end with suffix assert result.endswith(suffix), f"Failed for {description}: result should end with suffix" - # Total length should not exceed max_length (allowing for message priority) - # But if max_length >= overhead, we should respect it - if max_length >= overhead: - assert len(result) <= max_length, ( - f"Failed for {description}: result length {len(result)} > max_length {max_length}" - ) + # Total length should not exceed max_length + 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(): @@ -117,18 +111,18 @@ def test_truncate_rendered_value_exact_expected_output(): (83, "Hello World", trunc_only), (84, "Hello World", trunc_only), (86, "Hello World", trunc_only), - (90, "short", prefix + "'short'" + suffix), - (100, "This is a longer string", prefix + "'This is a longer'" + suffix), - (150, "x" * 200, prefix + "'" + "x" * 66 + "'" + suffix), - (100, "None", prefix + "'None'" + suffix), - (100, "True", prefix + "'True'" + suffix), - (100, "{'key': 'value'}", prefix + "\"{'key': 'value'}\"" + suffix), - (100, "test's", prefix + '"test\'s"' + suffix), + (90, "short", prefix + "short" + suffix), + (100, "This is a longer string", prefix + "This is a longer st" + suffix), + (150, "x" * 200, prefix + "x" * 69 + suffix), + (100, "None", prefix + "None" + suffix), + (100, "True", prefix + "True" + suffix), + (100, "{'key': 'value'}", prefix + "{'key': 'value'}" + suffix), + (100, "test's", prefix + "test's" + suffix), (90, '"quoted"', prefix + '"quoted"' + suffix), ] for max_length, rendered, expected in test_cases: - result = _truncate_rendered_value(rendered, max_length) + result = truncate_rendered_value(rendered, max_length) assert result == expected, ( f"max_length={max_length}, rendered={rendered!r}:\n" f" expected: {expected!r}\n" 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 997d54cbf496b..4c63fe35c5485 100644 --- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py +++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py @@ -121,7 +121,7 @@ from airflow.sdk.execution_time.xcom import XCom from airflow.sdk.listener import get_listener_manager from airflow.sdk.timezone import coerce_datetime -from airflow.utils.helpers import _truncate_rendered_value +from airflow.utils.helpers import truncate_rendered_value if TYPE_CHECKING: import jinja2 @@ -978,7 +978,7 @@ def _fallback_serialization(obj): serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _truncate_rendered_value(str(rendered), max_length) + 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 @@ -992,7 +992,7 @@ def _fallback_serialization(obj): serialized = str(template_field) if len(serialized) > max_length: rendered = redact(serialized, name) - return _truncate_rendered_value(str(rendered), max_length) + return truncate_rendered_value(str(rendered), max_length) return template_field From f08a874c94b302bf6a0ba811884a0d7e0dfc6463 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Tue, 24 Feb 2026 20:35:20 -0700 Subject: [PATCH 12/16] feat: add shared template rendering module with truncate_rendered_value function --- airflow-core/pyproject.toml | 1 + .../src/airflow/_shared/template_rendering | 1 + .../src/airflow/serialization/helpers.py | 2 +- airflow-core/src/airflow/utils/helpers.py | 34 ----- .../tests/unit/serialization/test_helpers.py | 2 +- pyproject.toml | 3 + shared/template_rendering/pyproject.toml | 46 +++++++ .../template_rendering/__init__.py | 58 +++++++++ .../tests/template_rendering/__init__.py | 16 +++ .../test_truncate_rendered_value.py | 116 ++++++++++++++++++ task-sdk/pyproject.toml | 1 + .../airflow/sdk/_shared/template_rendering | 1 + .../airflow/sdk/execution_time/task_runner.py | 2 +- 13 files changed, 246 insertions(+), 37 deletions(-) create mode 120000 airflow-core/src/airflow/_shared/template_rendering create mode 100644 shared/template_rendering/pyproject.toml create mode 100644 shared/template_rendering/src/airflow_shared/template_rendering/__init__.py create mode 100644 shared/template_rendering/tests/template_rendering/__init__.py create mode 100644 shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py create mode 120000 task-sdk/src/airflow/sdk/_shared/template_rendering diff --git a/airflow-core/pyproject.toml b/airflow-core/pyproject.toml index a38fad1a926a9..bb1ea002b10f9 100644 --- a/airflow-core/pyproject.toml +++ b/airflow-core/pyproject.toml @@ -243,6 +243,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 0000000000000..6ff1f831df74b --- /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 1dc55aacc78f8..e2c8069a1164a 100644 --- a/airflow-core/src/airflow/serialization/helpers.py +++ b/airflow-core/src/airflow/serialization/helpers.py @@ -23,9 +23,9 @@ 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 -from airflow.utils.helpers import truncate_rendered_value if TYPE_CHECKING: from airflow.partition_mappers.base import PartitionMapper diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index 74995a941e196..d5c6eba20213c 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -53,40 +53,6 @@ S = TypeVar("S") -def truncate_rendered_value(rendered: str, max_length: int) -> str: - MIN_CONTENT_LENGTH = 7 - - if max_length <= 0: - return "" - - # Build truncation message once, return if max_length is too small - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - trunc_only = f"{prefix}{suffix}" - - if max_length < len(trunc_only): - return trunc_only - - # Compute available space for content - overhead = len(prefix) + len(suffix) - available = max_length - overhead - - if available < MIN_CONTENT_LENGTH: - return trunc_only - - # Slice content to fit and construct final string - content = rendered[:available].rstrip() - result = f"{prefix}{content}{suffix}" - - if len(result) > max_length: - log.warning( - "Truncated value still exceeds max_length=%d; this should not happen.", - max_length, - ) - - return result - - def validate_key(k: str, max_length: int = 250): """Validate value used as a key.""" if not isinstance(k, str): diff --git a/airflow-core/tests/unit/serialization/test_helpers.py b/airflow-core/tests/unit/serialization/test_helpers.py index 6954de9bddd9f..f32d127dc744a 100644 --- a/airflow-core/tests/unit/serialization/test_helpers.py +++ b/airflow-core/tests/unit/serialization/test_helpers.py @@ -16,7 +16,7 @@ # under the License. from __future__ import annotations -from airflow.utils.helpers import truncate_rendered_value +from airflow._shared.template_rendering import truncate_rendered_value def test_serialize_template_field_with_very_small_max_length(monkeypatch): diff --git a/pyproject.toml b/pyproject.toml index 97b15498de100..6fb5e7509eb54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1314,6 +1314,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", ] @@ -1373,6 +1374,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 } @@ -1503,6 +1505,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 0000000000000..0b7ad0931b3b8 --- /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 0000000000000..58fe4e1eb866c --- /dev/null +++ b/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py @@ -0,0 +1,58 @@ +# 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__) + + +def truncate_rendered_value(rendered: str, max_length: int) -> str: + MIN_CONTENT_LENGTH = 7 + + if max_length <= 0: + return "" + + # Build truncation message once, return if max_length is too small + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + trunc_only = f"{prefix}{suffix}" + + if max_length < len(trunc_only): + return trunc_only + + # Compute available space for content + overhead = len(prefix) + len(suffix) + available = max_length - overhead + + if available < MIN_CONTENT_LENGTH: + return trunc_only + + # Slice content to fit and construct final string + content = rendered[:available].rstrip() + result = f"{prefix}{content}{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_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 0000000000000..13a83393a9124 --- /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 0000000000000..ceb48bdbbf86f --- /dev/null +++ b/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py @@ -0,0 +1,116 @@ +# 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_rendered_value + + +def test_truncate_rendered_value_prioritizes_message(): + """Test that truncation message is always shown first, content only if space allows.""" + 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"), + (83, "Hello World", "At prefix+suffix boundary v1"), + (84, "Hello World", "Just above boundary v1"), + (86, "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"), + ] + + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + trunc_only = f"{prefix}{suffix}" + trunc_only_len = len(trunc_only) # 81 + overhead = len(prefix) + len(suffix) # 81 + # Content is only shown when available >= MIN_CONTENT_LENGTH (7) + min_length_for_content = overhead + 7 # 88 + + for max_length, rendered, description in test_cases: + result = truncate_rendered_value(rendered, max_length) + + # Always should contain the prefix message + assert result.startswith(prefix), f"Failed for {description}: result should start with prefix" + + # For very small max_length values, should return message only + if max_length < trunc_only_len: + assert result == trunc_only, ( + f"Failed for {description}: max_length={max_length} < {trunc_only_len}, " + f"expected message only, got: {result}" + ) + # For max_length values that don't leave enough room for content (available < 7) + elif max_length < min_length_for_content: + assert result == trunc_only, ( + f"Failed for {description}: max_length={max_length} < {min_length_for_content}, " + f"expected message only, got: {result}" + ) + # For larger values, should show message + content + else: + # Should end with suffix + assert result.endswith(suffix), f"Failed for {description}: result should end with suffix" + # Total length should not exceed max_length + 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.""" + prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " + suffix = "..." + trunc_only = prefix + suffix + + 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), + (83, "Hello World", trunc_only), + (84, "Hello World", trunc_only), + (86, "Hello World", trunc_only), + (90, "short", prefix + "short" + suffix), + (100, "This is a longer string", prefix + "This is a longer st" + suffix), + (150, "x" * 200, prefix + "x" * 69 + suffix), + (100, "None", prefix + "None" + suffix), + (100, "True", prefix + "True" + suffix), + (100, "{'key': 'value'}", prefix + "{'key': 'value'}" + suffix), + (100, "test's", prefix + "test's" + suffix), + (90, '"quoted"', prefix + '"quoted"' + 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 16caf6e8fd35d..a136396905866 100644 --- a/task-sdk/pyproject.toml +++ b/task-sdk/pyproject.toml @@ -136,6 +136,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 0000000000000..67f141b29a437 --- /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 2c7e9767cfdcf..a5a8930cfb67f 100644 --- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py +++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py @@ -41,6 +41,7 @@ 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, @@ -122,7 +123,6 @@ from airflow.sdk.listener import get_listener_manager from airflow.sdk.observability.metrics import stats_utils from airflow.sdk.timezone import coerce_datetime -from airflow.utils.helpers import truncate_rendered_value if TYPE_CHECKING: import jinja2 From 86a3aedd954d34584e0c9e65e2fe1f9c0cb2aada Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Tue, 24 Feb 2026 20:40:38 -0700 Subject: [PATCH 13/16] refactor: cleanup --- airflow-core/src/airflow/utils/helpers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/airflow-core/src/airflow/utils/helpers.py b/airflow-core/src/airflow/utils/helpers.py index d5c6eba20213c..a48780022a609 100644 --- a/airflow-core/src/airflow/utils/helpers.py +++ b/airflow-core/src/airflow/utils/helpers.py @@ -19,7 +19,6 @@ import copy import itertools -import logging import re import signal from collections.abc import Callable, Generator, Iterable, MutableMapping @@ -43,8 +42,6 @@ CT = TypeVar("CT", str, datetime) -log = logging.getLogger(__name__) - KEY_REGEX = re.compile(r"^[\w.-]+$") GROUP_KEY_REGEX = re.compile(r"^[\w-]+$") CAMELCASE_TO_SNAKE_CASE_REGEX = re.compile(r"(?!^)([A-Z]+)") From cd02e709fc7237671812f62566895bcd8f69ff90 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Tue, 24 Feb 2026 21:00:30 -0700 Subject: [PATCH 14/16] feat: enhance truncation functionality with configurable constants and improved tests --- .../unit/models/test_renderedtifields.py | 8 ++- .../tests/unit/serialization/test_helpers.py | 62 ++++++++----------- .../template_rendering/__init__.py | 46 +++++++++++--- .../test_truncate_rendered_value.py | 62 ++++++++----------- .../execution_time/test_task_runner.py | 19 +++--- 5 files changed, 106 insertions(+), 91 deletions(-) diff --git a/airflow-core/tests/unit/models/test_renderedtifields.py b/airflow-core/tests/unit/models/test_renderedtifields.py index e253416539e65..d42ed06b033fe 100644 --- a/airflow-core/tests/unit/models/test_renderedtifields.py +++ b/airflow-core/tests/unit/models/test_renderedtifields.py @@ -30,6 +30,7 @@ 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 @@ -38,7 +39,6 @@ from airflow.providers.standard.operators.bash import BashOperator from airflow.providers.standard.operators.python import PythonOperator from airflow.sdk import task as task_decorator -from airflow.serialization.helpers import serialize_template_field from airflow.utils.sqlalchemy import get_dialect_name from airflow.utils.state import TaskInstanceState @@ -125,12 +125,14 @@ def teardown_method(self): pytest.param(datetime(2018, 12, 6, 10, 55), "2018-12-06 10:55:00+00:00", id="datetime"), pytest.param( "a" * 5000, - serialize_template_field("a" * 5000, "bash_command"), + truncate_rendered_value("a" * 5000, conf.getint("core", "max_templated_field_length")), id="large_string", ), pytest.param( LargeStrObject(), - serialize_template_field(LargeStrObject(), "bash_command"), + 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 index f32d127dc744a..235dbc654b199 100644 --- a/airflow-core/tests/unit/serialization/test_helpers.py +++ b/airflow-core/tests/unit/serialization/test_helpers.py @@ -16,7 +16,12 @@ # under the License. from __future__ import annotations -from airflow._shared.template_rendering import truncate_rendered_value +from airflow._shared.template_rendering import ( + TRUNCATE_MIN_CONTENT_LENGTH, + TRUNCATE_PREFIX, + TRUNCATE_SUFFIX, + truncate_rendered_value, +) def test_serialize_template_field_with_very_small_max_length(monkeypatch): @@ -59,37 +64,26 @@ def test_truncate_rendered_value_prioritizes_message(): (90, '"quoted"', "String with quotes"), ] - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - trunc_only = f"{prefix}{suffix}" - trunc_only_len = len(trunc_only) # 81 - overhead = len(prefix) + len(suffix) # 81 - # Content is only shown when available >= MIN_CONTENT_LENGTH (7) - min_length_for_content = overhead + 7 # 88 + 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 for max_length, rendered, description in test_cases: result = truncate_rendered_value(rendered, max_length) - # Always should contain the prefix message - assert result.startswith(prefix), f"Failed for {description}: result should start with prefix" + assert result.startswith(TRUNCATE_PREFIX), ( + f"Failed for {description}: result should start with prefix" + ) - # For very small max_length values, should return message only - if max_length < trunc_only_len: - assert result == trunc_only, ( - f"Failed for {description}: max_length={max_length} < {trunc_only_len}, " - f"expected message only, got: {result}" - ) - # For max_length values that don't leave enough room for content (available < 7) - elif max_length < min_length_for_content: + 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} < {min_length_for_content}, " - f"expected message only, got: {result}" + f"Failed for {description}: max_length={max_length}, expected message only, got: {result}" ) - # For larger values, should show message + content else: - # Should end with suffix - assert result.endswith(suffix), f"Failed for {description}: result should end with suffix" - # Total length should not exceed max_length + 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}" ) @@ -97,9 +91,7 @@ def test_truncate_rendered_value_prioritizes_message(): def test_truncate_rendered_value_exact_expected_output(): """Test that truncation produces exact expected output: message first, then content when space allows.""" - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - trunc_only = prefix + suffix + trunc_only = TRUNCATE_PREFIX + TRUNCATE_SUFFIX test_cases = [ (1, "test", trunc_only), @@ -111,14 +103,14 @@ def test_truncate_rendered_value_exact_expected_output(): (83, "Hello World", trunc_only), (84, "Hello World", trunc_only), (86, "Hello World", trunc_only), - (90, "short", prefix + "short" + suffix), - (100, "This is a longer string", prefix + "This is a longer st" + suffix), - (150, "x" * 200, prefix + "x" * 69 + suffix), - (100, "None", prefix + "None" + suffix), - (100, "True", prefix + "True" + suffix), - (100, "{'key': 'value'}", prefix + "{'key': 'value'}" + suffix), - (100, "test's", prefix + "test's" + suffix), - (90, '"quoted"', prefix + '"quoted"' + suffix), + (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: diff --git a/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py b/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py index 58fe4e1eb866c..ddf01a88098f8 100644 --- a/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py +++ b/shared/template_rendering/src/airflow_shared/template_rendering/__init__.py @@ -20,31 +20,54 @@ 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: - MIN_CONTENT_LENGTH = 7 + """ + 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 "" - # Build truncation message once, return if max_length is too small - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - trunc_only = f"{prefix}{suffix}" + trunc_only = f"{TRUNCATE_PREFIX}{TRUNCATE_SUFFIX}" if max_length < len(trunc_only): return trunc_only # Compute available space for content - overhead = len(prefix) + len(suffix) + overhead = len(TRUNCATE_PREFIX) + len(TRUNCATE_SUFFIX) available = max_length - overhead - if available < MIN_CONTENT_LENGTH: + if available < TRUNCATE_MIN_CONTENT_LENGTH: return trunc_only # Slice content to fit and construct final string content = rendered[:available].rstrip() - result = f"{prefix}{content}{suffix}" + result = f"{TRUNCATE_PREFIX}{content}{TRUNCATE_SUFFIX}" if len(result) > max_length: log.warning( @@ -55,4 +78,9 @@ def truncate_rendered_value(rendered: str, max_length: int) -> str: return result -__all__ = ["truncate_rendered_value"] +__all__ = [ + "TRUNCATE_MIN_CONTENT_LENGTH", + "TRUNCATE_PREFIX", + "TRUNCATE_SUFFIX", + "truncate_rendered_value", +] 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 index ceb48bdbbf86f..0b2d49733b634 100644 --- a/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py +++ b/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py @@ -16,7 +16,12 @@ # under the License. from __future__ import annotations -from airflow_shared.template_rendering import truncate_rendered_value +from airflow_shared.template_rendering import ( + TRUNCATE_MIN_CONTENT_LENGTH, + TRUNCATE_PREFIX, + TRUNCATE_SUFFIX, + truncate_rendered_value, +) def test_truncate_rendered_value_prioritizes_message(): @@ -45,37 +50,26 @@ def test_truncate_rendered_value_prioritizes_message(): (90, '"quoted"', "String with quotes"), ] - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - trunc_only = f"{prefix}{suffix}" - trunc_only_len = len(trunc_only) # 81 - overhead = len(prefix) + len(suffix) # 81 - # Content is only shown when available >= MIN_CONTENT_LENGTH (7) - min_length_for_content = overhead + 7 # 88 + 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 for max_length, rendered, description in test_cases: result = truncate_rendered_value(rendered, max_length) - # Always should contain the prefix message - assert result.startswith(prefix), f"Failed for {description}: result should start with prefix" + assert result.startswith(TRUNCATE_PREFIX), ( + f"Failed for {description}: result should start with prefix" + ) - # For very small max_length values, should return message only - if max_length < trunc_only_len: - assert result == trunc_only, ( - f"Failed for {description}: max_length={max_length} < {trunc_only_len}, " - f"expected message only, got: {result}" - ) - # For max_length values that don't leave enough room for content (available < 7) - elif max_length < min_length_for_content: + 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} < {min_length_for_content}, " - f"expected message only, got: {result}" + f"Failed for {description}: max_length={max_length}, expected message only, got: {result}" ) - # For larger values, should show message + content else: - # Should end with suffix - assert result.endswith(suffix), f"Failed for {description}: result should end with suffix" - # Total length should not exceed max_length + 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}" ) @@ -83,9 +77,7 @@ def test_truncate_rendered_value_prioritizes_message(): def test_truncate_rendered_value_exact_expected_output(): """Test that truncation produces exact expected output: message first, then content when space allows.""" - prefix = "Truncated. You can change this behaviour in [core]max_templated_field_length. " - suffix = "..." - trunc_only = prefix + suffix + trunc_only = TRUNCATE_PREFIX + TRUNCATE_SUFFIX test_cases = [ (1, "test", trunc_only), @@ -97,14 +89,14 @@ def test_truncate_rendered_value_exact_expected_output(): (83, "Hello World", trunc_only), (84, "Hello World", trunc_only), (86, "Hello World", trunc_only), - (90, "short", prefix + "short" + suffix), - (100, "This is a longer string", prefix + "This is a longer st" + suffix), - (150, "x" * 200, prefix + "x" * 69 + suffix), - (100, "None", prefix + "None" + suffix), - (100, "True", prefix + "True" + suffix), - (100, "{'key': 'value'}", prefix + "{'key': 'value'}" + suffix), - (100, "test's", prefix + "test's" + suffix), - (90, '"quoted"', prefix + '"quoted"' + suffix), + (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: 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 6c4311107e9ef..437a1e468e426 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 @@ -2690,7 +2690,6 @@ def execute(self, context): 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()) - # Truncation format may vary by config; use actual call for assertion msg = next( c.kwargs["msg"] for c in mock_supervisor_comms.send.mock_calls @@ -2698,15 +2697,17 @@ def execute(self, context): ) rendered_fields = msg.rendered_fields - assert ( - call( - msg=SetRenderedFields( - rendered_fields=rendered_fields, - type="SetRenderedFields", - ) - ) - in mock_supervisor_comms.send.mock_calls + # 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( From 752043cf656e69d0163b0854c2fc129100be35d0 Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Tue, 24 Feb 2026 21:06:04 -0700 Subject: [PATCH 15/16] remove dups --- .../tests/unit/serialization/test_helpers.py | 91 ------------------- 1 file changed, 91 deletions(-) diff --git a/airflow-core/tests/unit/serialization/test_helpers.py b/airflow-core/tests/unit/serialization/test_helpers.py index 235dbc654b199..0ded4f64a8665 100644 --- a/airflow-core/tests/unit/serialization/test_helpers.py +++ b/airflow-core/tests/unit/serialization/test_helpers.py @@ -16,13 +16,6 @@ # 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_serialize_template_field_with_very_small_max_length(monkeypatch): """Test that truncation message is prioritized even for very small max_length.""" @@ -36,87 +29,3 @@ def test_serialize_template_field_with_very_small_max_length(monkeypatch): # This ensures users always see why content is truncated assert result assert "Truncated. You can change this behaviour" in result - - -def test_truncate_rendered_value_prioritizes_message(): - """Test that truncation message is always shown first, content only if space allows.""" - 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"), - (83, "Hello World", "At prefix+suffix boundary v1"), - (84, "Hello World", "Just above boundary v1"), - (86, "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"), - ] - - 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 - - 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 - - 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), - (83, "Hello World", trunc_only), - (84, "Hello World", trunc_only), - (86, "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}" - ) From b6ae5ef16c923e2070a43460fc7396e4ac9ae64c Mon Sep 17 00:00:00 2001 From: imrichardwu Date: Fri, 27 Feb 2026 10:58:59 -0700 Subject: [PATCH 16/16] test: update truncation tests with dynamic boundary calculations --- .../test_truncate_rendered_value.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) 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 index 0b2d49733b634..3b401aa177b85 100644 --- a/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py +++ b/shared/template_rendering/tests/template_rendering/test_truncate_rendered_value.py @@ -26,6 +26,11 @@ 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"), @@ -33,9 +38,9 @@ def test_truncate_rendered_value_prioritizes_message(): (10, "password123", "Small"), (20, "secret_value", "Small with content"), (50, "This is a test string", "Medium"), - (83, "Hello World", "At prefix+suffix boundary v1"), - (84, "Hello World", "Just above boundary v1"), - (86, "Hello World", "At overhead boundary v2"), + (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"), ( @@ -50,11 +55,6 @@ def test_truncate_rendered_value_prioritizes_message(): (90, '"quoted"', "String with quotes"), ] - 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 - for max_length, rendered, description in test_cases: result = truncate_rendered_value(rendered, max_length) @@ -78,6 +78,8 @@ def test_truncate_rendered_value_prioritizes_message(): 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), @@ -86,9 +88,9 @@ def test_truncate_rendered_value_exact_expected_output(): (10, "password123", trunc_only), (20, "secret_value", trunc_only), (50, "This is a test string", trunc_only), - (83, "Hello World", trunc_only), - (84, "Hello World", trunc_only), - (86, "Hello World", 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),