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

Lee-W pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 4acd1a9a1a2 Reject mismatched rollup mapper and window pairings at Dag 
parse time (#69456)
4acd1a9a1a2 is described below

commit 4acd1a9a1a2001a0c83c8d3f0cd308f2b5d9dfd3
Author: Wei Lee <[email protected]>
AuthorDate: Tue Jul 7 12:22:28 2026 +0800

    Reject mismatched rollup mapper and window pairings at Dag parse time 
(#69456)
---
 airflow-core/src/airflow/partition_mappers/base.py | 17 +++---
 .../src/airflow/partition_mappers/temporal.py      |  2 +
 .../tests/unit/partition_mappers/test_base.py      | 66 +++++++++++++++++++++-
 .../sdk/definitions/partition_mappers/base.py      |  9 ++-
 .../task_sdk/definitions/test_partition_mappers.py | 53 +++++++++++++----
 5 files changed, 122 insertions(+), 25 deletions(-)

diff --git a/airflow-core/src/airflow/partition_mappers/base.py 
b/airflow-core/src/airflow/partition_mappers/base.py
index b0edd02f645..c773ef7e867 100644
--- a/airflow-core/src/airflow/partition_mappers/base.py
+++ b/airflow-core/src/airflow/partition_mappers/base.py
@@ -38,10 +38,12 @@ class PartitionMapper(ABC):
 
     is_rollup: ClassVar[bool] = False
 
-    # Class-level default so the attribute resolves even on subclasses whose
-    # __init__ (e.g. attrs-generated in plugins) never calls this base 
__init__.
     max_downstream_keys: int | None = None
 
+    # Declared result type of decode_downstream; RollupMapper rejects windows
+    # whose expected_decoded_type does not match. Temporal mappers use 
datetime.
+    expected_decoded_type: ClassVar[type] = str
+
     def __init__(self, *, max_downstream_keys: int | None = None) -> None:
         if max_downstream_keys is not None and (
             not isinstance(max_downstream_keys, int) or max_downstream_keys < 1
@@ -168,16 +170,13 @@ class RollupMapper(PartitionMapper):
         wait_policy: WaitPolicy | None = None,
         max_downstream_keys: int | None = None,
     ) -> None:
-        decode_overridden = type(upstream_mapper).decode_downstream is not 
PartitionMapper.decode_downstream
-        if not decode_overridden and window.expected_decoded_type is not str:
+        if upstream_mapper.expected_decoded_type is not 
window.expected_decoded_type:
             raise TypeError(
                 f"{type(window).__name__} expects decoded values of type "
                 f"{window.expected_decoded_type.__name__!r}, but "
-                f"{type(upstream_mapper).__name__} does not override "
-                f"'decode_downstream' (base default returns str). Pair the 
window "
-                f"with an upstream mapper that decodes to "
-                f"{window.expected_decoded_type.__name__}, or use a window 
whose "
-                f"'expected_decoded_type' accepts str."
+                f"{type(upstream_mapper).__name__} decodes to "
+                f"{upstream_mapper.expected_decoded_type.__name__!r}. Pair a 
window and an "
+                f"upstream mapper whose 'expected_decoded_type' match."
             )
         if wait_policy is None:
             wait_policy = WaitForAll()
diff --git a/airflow-core/src/airflow/partition_mappers/temporal.py 
b/airflow-core/src/airflow/partition_mappers/temporal.py
index c16801c97b1..09f1897a590 100644
--- a/airflow-core/src/airflow/partition_mappers/temporal.py
+++ b/airflow-core/src/airflow/partition_mappers/temporal.py
@@ -155,6 +155,8 @@ def _compile_output_format_regex(
 class _BaseTemporalMapper(PartitionMapper):
     """Base class for Temporal Partition Mappers."""
 
+    expected_decoded_type: ClassVar[type] = datetime
+
     default_output_format: str
 
     def __init__(
diff --git a/airflow-core/tests/unit/partition_mappers/test_base.py 
b/airflow-core/tests/unit/partition_mappers/test_base.py
index 5e730e49dd8..e1aef80909b 100644
--- a/airflow-core/tests/unit/partition_mappers/test_base.py
+++ b/airflow-core/tests/unit/partition_mappers/test_base.py
@@ -22,9 +22,10 @@ from datetime import datetime, timezone
 import pytest
 
 from airflow.partition_mappers.base import PartitionMapper, RollupMapper
+from airflow.partition_mappers.fixed_key import FixedKeyMapper
 from airflow.partition_mappers.identity import IdentityMapper
 from airflow.partition_mappers.temporal import StartOfDayMapper
-from airflow.partition_mappers.window import DayWindow
+from airflow.partition_mappers.window import DayWindow, SegmentWindow
 from airflow.serialization.decoders import decode_partition_mapper
 from airflow.serialization.encoders import encode_partition_mapper
 from airflow.serialization.enums import Encoding
@@ -146,6 +147,69 @@ class TestRollupMapperInit:
         # Should not raise.
         RollupMapper(upstream_mapper=_StringOnlyMapper(), 
window=_AlphaWindow())
 
+    def test_accepts_decode_overriding_str_mapper_with_str_window(self):
+        """Overriding decode/encode while still decoding to str is a legal 
str-window pairing.
+
+        The guard must key off the declared ``expected_decoded_type`` (str by
+        default), not off whether ``decode_downstream`` is overridden.
+        """
+
+        class _CasefoldMapper(PartitionMapper):
+            def to_downstream(self, key):
+                return key.casefold()
+
+            def decode_downstream(self, downstream_key):
+                return downstream_key.casefold()
+
+            def encode_upstream(self, decoded_upstream):
+                return str(decoded_upstream)
+
+        # Should not raise.
+        RollupMapper(upstream_mapper=_CasefoldMapper(), 
window=SegmentWindow(["us", "eu"]))
+
+    @pytest.mark.parametrize(
+        ("upstream_mapper_factory", "window_factory", "match"),
+        [
+            pytest.param(
+                lambda: FixedKeyMapper("all"),
+                lambda: SegmentWindow(["us", "eu"]),
+                None,
+                id="str_mapper-str_window-valid",
+            ),
+            pytest.param(
+                StartOfDayMapper,
+                DayWindow,
+                None,
+                id="datetime_mapper-datetime_window-valid",
+            ),
+            pytest.param(
+                lambda: FixedKeyMapper("all"),
+                DayWindow,
+                "DayWindow expects decoded values of type 'datetime'",
+                id="str_mapper-datetime_window-invalid",
+            ),
+            pytest.param(
+                StartOfDayMapper,
+                lambda: SegmentWindow(["us"]),
+                "SegmentWindow expects decoded values of type 'str'",
+                id="datetime_mapper-str_window-invalid",
+            ),
+        ],
+    )
+    def test_upstream_mapper_window_type_pairing(self, 
upstream_mapper_factory, window_factory, match):
+        """RollupMapper's guard must catch a decoded-type mismatch in either 
direction.
+
+        Guards the ``datetime_mapper-str_window`` direction in particular: left
+        unchecked, that pairing survives construction and serialization, then
+        raises ``AttributeError`` inside ``to_upstream`` at scheduler tick 
time.
+        """
+        if match is None:
+            # Should not raise.
+            RollupMapper(upstream_mapper=upstream_mapper_factory(), 
window=window_factory())
+        else:
+            with pytest.raises(TypeError, match=match):
+                RollupMapper(upstream_mapper=upstream_mapper_factory(), 
window=window_factory())
+
 
 class TestPartitionMapperMaxDownstreamKeysValidator:
     """Verify the max_downstream_keys validator on the PartitionMapper base 
class.
diff --git a/task-sdk/src/airflow/sdk/definitions/partition_mappers/base.py 
b/task-sdk/src/airflow/sdk/definitions/partition_mappers/base.py
index 079b85fc021..1336f29d54d 100644
--- a/task-sdk/src/airflow/sdk/definitions/partition_mappers/base.py
+++ b/task-sdk/src/airflow/sdk/definitions/partition_mappers/base.py
@@ -82,12 +82,11 @@ class RollupMapper(PartitionMapper):
         # would otherwise be swallowed by the bare ``except`` in
         # ``_create_dagruns_for_partitioned_asset_dags`` and surface only as
         # "Failed to deserialize Dag" spam).
-        if self.upstream_mapper.expected_decoded_type is str and 
self.window.expected_decoded_type is not str:
+        if self.upstream_mapper.expected_decoded_type is not 
self.window.expected_decoded_type:
             raise TypeError(
                 f"{type(self.window).__name__} expects decoded values of type "
                 f"{self.window.expected_decoded_type.__name__!r}, but "
-                f"{type(self.upstream_mapper).__name__} decodes to 'str' (SDK 
PartitionMapper default). "
-                f"Pair the window with an upstream mapper whose 
'expected_decoded_type' is "
-                f"{self.window.expected_decoded_type.__name__}, or use a 
window whose "
-                f"'expected_decoded_type' accepts str."
+                f"{type(self.upstream_mapper).__name__} decodes to "
+                f"{self.upstream_mapper.expected_decoded_type.__name__!r}. 
Pair a window and an "
+                f"upstream mapper whose 'expected_decoded_type' match."
             )
diff --git a/task-sdk/tests/task_sdk/definitions/test_partition_mappers.py 
b/task-sdk/tests/task_sdk/definitions/test_partition_mappers.py
index ca811115cb7..e588dc618d3 100644
--- a/task-sdk/tests/task_sdk/definitions/test_partition_mappers.py
+++ b/task-sdk/tests/task_sdk/definitions/test_partition_mappers.py
@@ -192,14 +192,47 @@ class TestSdkSegmentWindow:
 
 
 class TestSdkCategoricalRollupGuard:
-    """SDK-side RollupMapper guard mirrors core: str mapper + str window 
passes."""
+    """SDK-side RollupMapper guard mirrors core: the decoded type must match 
in either direction."""
 
-    def test_fixed_key_with_segment_window_does_not_raise(self):
-        # SDK guard: FixedKeyMapper.expected_decoded_type is str,
-        # SegmentWindow.expected_decoded_type is str -> guard passes.
-        RollupMapper(upstream_mapper=FixedKeyMapper("all"), 
window=SegmentWindow(["us", "eu"]))
-
-    def test_str_mapper_with_datetime_window_raises(self):
-        # SDK guard: FixedKeyMapper (str) + DayWindow (datetime) -> raise.
-        with pytest.raises(TypeError, match="DayWindow expects decoded values 
of type 'datetime'"):
-            RollupMapper(upstream_mapper=FixedKeyMapper("all"), 
window=DayWindow())
+    @pytest.mark.parametrize(
+        ("upstream_mapper_factory", "window_factory", "match"),
+        [
+            pytest.param(
+                lambda: FixedKeyMapper("all"),
+                lambda: SegmentWindow(["us", "eu"]),
+                None,
+                id="str_mapper-str_window-valid",
+            ),
+            pytest.param(
+                StartOfDayMapper,
+                DayWindow,
+                None,
+                id="datetime_mapper-datetime_window-valid",
+            ),
+            pytest.param(
+                lambda: FixedKeyMapper("all"),
+                DayWindow,
+                "DayWindow expects decoded values of type 'datetime'",
+                id="str_mapper-datetime_window-invalid",
+            ),
+            pytest.param(
+                StartOfDayMapper,
+                lambda: SegmentWindow(["us"]),
+                "SegmentWindow expects decoded values of type 'str'",
+                id="datetime_mapper-str_window-invalid",
+            ),
+        ],
+    )
+    def test_upstream_mapper_window_type_pairing(self, 
upstream_mapper_factory, window_factory, match):
+        """RollupMapper's guard must catch a decoded-type mismatch in either 
direction.
+
+        Guards the ``datetime_mapper-str_window`` direction in particular: left
+        unchecked, that pairing survives construction and serialization, then
+        raises ``AttributeError`` inside ``to_upstream`` at scheduler tick 
time.
+        """
+        if match is None:
+            # Should not raise.
+            RollupMapper(upstream_mapper=upstream_mapper_factory(), 
window=window_factory())
+        else:
+            with pytest.raises(TypeError, match=match):
+                RollupMapper(upstream_mapper=upstream_mapper_factory(), 
window=window_factory())

Reply via email to