From 92f65b5ddf4de5161262e4c2633952ceb28e0757 Mon Sep 17 00:00:00 2001 From: inchang-ing <197932532+inchang-ing@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:41:49 +0800 Subject: [PATCH] fix: preserve dict subclass types in _deepcopy_with_exceptions The dict branch rebuilt every dict with a dict comprehension, so `defaultdict`, `OrderedDict` and `Counter` values passed to a pipeline came back as plain `dict`. `Pipeline.run` copies its inputs with this helper, so a component received a different type than the caller passed in, and a `defaultdict` lost its `default_factory` in the process. Rebuild the container as its own type (copy, clear and refill with deep-copied values) instead of collapsing it to a plain `dict`, matching the existing `type(obj)` handling for lists/tuples/sets and namedtuples. --- haystack/core/pipeline/utils.py | 13 +++++++++-- ...epcopy-dict-subclass-c89f7e37f2f7f027.yaml | 11 +++++++++ test/core/pipeline/test_utils.py | 23 ++++++++++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/fix-deepcopy-dict-subclass-c89f7e37f2f7f027.yaml diff --git a/haystack/core/pipeline/utils.py b/haystack/core/pipeline/utils.py index d1263dec973..49b8dc42185 100644 --- a/haystack/core/pipeline/utils.py +++ b/haystack/core/pipeline/utils.py @@ -4,7 +4,7 @@ import heapq from collections.abc import Callable -from copy import deepcopy +from copy import copy, deepcopy from functools import wraps from itertools import count from typing import Any @@ -42,7 +42,16 @@ def _deepcopy_with_exceptions(obj: Any) -> Any: return type(obj)(_deepcopy_with_exceptions(v) for v in obj) if isinstance(obj, dict): - return {k: _deepcopy_with_exceptions(v) for k, v in obj.items()} + # Rebuild the concrete dict subclass (e.g. ``defaultdict``, ``OrderedDict``, ``Counter``) instead of + # collapsing it to a plain ``dict``. ``type(obj)()`` is unsafe here because dict subclasses + # have incompatible constructors (``defaultdict`` takes ``default_factory`` first), so we copy the + # container and refill it, mirroring the ``type(obj)`` handling above. Assigning item by item avoids + # ``Counter.update`` interpreting the items as an iterable to count. + result = copy(obj) + result.clear() + for key, value in obj.items(): + result[key] = _deepcopy_with_exceptions(value) + return result # Components and Tools often contain objects that we do not want to deepcopy or are not deepcopyable # (e.g. models, clients, etc.). In this case we return the object as-is. diff --git a/releasenotes/notes/fix-deepcopy-dict-subclass-c89f7e37f2f7f027.yaml b/releasenotes/notes/fix-deepcopy-dict-subclass-c89f7e37f2f7f027.yaml new file mode 100644 index 00000000000..238d26904b9 --- /dev/null +++ b/releasenotes/notes/fix-deepcopy-dict-subclass-c89f7e37f2f7f027.yaml @@ -0,0 +1,11 @@ +--- +fixes: + - | + Fixed ``_deepcopy_with_exceptions`` collapsing ``dict`` subclasses such as + ``defaultdict``, ``OrderedDict`` and ``Counter`` into plain ``dict``. The + function rebuilt every dict with a dict comprehension, so a component input or + pipeline parameter that contained a ``defaultdict`` lost its ``default_factory`` + and a ``Counter`` came back as a plain ``dict``, even though the list/tuple/set + branch already preserved ``type(obj)``. Dict subclasses are now copied as their + own type (with deep-copied values), so ``Pipeline.run`` no longer changes the + type of the data a component receives. diff --git a/test/core/pipeline/test_utils.py b/test/core/pipeline/test_utils.py index c9e1d9831ff..e3e20cba1a1 100644 --- a/test/core/pipeline/test_utils.py +++ b/test/core/pipeline/test_utils.py @@ -4,7 +4,8 @@ import logging import warnings -from collections import namedtuple +from collections import Counter, OrderedDict, defaultdict, namedtuple +from functools import partial import pytest @@ -269,6 +270,26 @@ def test_deepcopy_with_fallback_namedtuple(self): # Its contents are deep-copied, matching how plain tuples are handled. assert copy["point"].x is not original["point"].x + @pytest.mark.parametrize("factory", [partial(defaultdict, list), OrderedDict, Counter]) + def test_deepcopy_with_fallback_dict_subclass(self, factory): + container = factory() + container["a"] = [1, 2] + original = {"data": container} + copy = _deepcopy_with_exceptions(original) + # The concrete dict subclass survives the copy instead of collapsing to ``dict``. + assert type(copy["data"]) is type(original["data"]) + assert copy["data"] == original["data"] + # The container and its contents are deep-copied, matching how plain dicts are handled. + assert copy["data"] is not original["data"] + assert copy["data"]["a"] is not original["data"]["a"] + + def test_deepcopy_with_fallback_defaultdict_keeps_factory(self): + original = {"data": defaultdict(list, a=[1])} + copy = _deepcopy_with_exceptions(original) + assert copy["data"].default_factory is list + # A missing key still triggers the factory instead of raising KeyError. + assert copy["data"]["missing"] == [] + class TestArgsDeprecated: @pytest.fixture