From 2d533ccaeb8a5df55ed270b8f31767b10af976fb Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:39:33 +0800 Subject: [PATCH 1/3] fix: stop ConditionalRouter and BranchJoiner from_dict from mutating the caller's data --- haystack/components/joiners/branch.py | 8 ++++++-- .../components/routers/conditional_router.py | 10 ++++++++-- test/components/joiners/test_branch_joiner.py | 14 ++++++++++++++ .../routers/test_conditional_router.py | 18 ++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/haystack/components/joiners/branch.py b/haystack/components/joiners/branch.py index 40bf9ce346d..f8056eb6573 100644 --- a/haystack/components/joiners/branch.py +++ b/haystack/components/joiners/branch.py @@ -113,8 +113,12 @@ def from_dict(cls, data: dict[str, Any]) -> "BranchJoiner": :returns: A deserialized `BranchJoiner` instance. """ - data["init_parameters"]["type_"] = deserialize_type(data["init_parameters"]["type_"]) - return default_from_dict(cls, data) + # Copy so that replacing the serialized ``type_`` with the deserialized type does + # not mutate the caller's ``data`` dict in place. Without this, a second + # deserialization of the same dict would receive an already-parsed type. + init_parameters = dict(data["init_parameters"]) + init_parameters["type_"] = deserialize_type(init_parameters["type_"]) + return default_from_dict(cls, {**data, "init_parameters": init_parameters}) def run(self, **kwargs: Any) -> dict[str, Any]: """ diff --git a/haystack/components/routers/conditional_router.py b/haystack/components/routers/conditional_router.py index 5c5b248d51a..6d836f16147 100644 --- a/haystack/components/routers/conditional_router.py +++ b/haystack/components/routers/conditional_router.py @@ -364,7 +364,10 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": :returns: The deserialized component. """ - init_params = data.get("init_parameters", {}) + # Copy so that replacing serialized sub-objects below does not mutate the caller's + # ``data`` dict in place. Without this, a second deserialization of the same dict + # would receive already-parsed objects instead of their serialized form. + init_params = dict(data.get("init_parameters", {})) # `unsafe=True` swaps the Jinja sandbox for a NativeEnvironment that executes arbitrary code. # Honor it from serialized data only when the whole pipeline is being loaded in unsafe mode; @@ -384,6 +387,8 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": ) routes = init_params.get("routes") + if routes is not None: + init_params["routes"] = routes = [dict(route) for route in routes] for route in routes: # output_type needs to be deserialized from a string to a type if isinstance(route["output_type"], list): @@ -394,9 +399,10 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": # Since the custom_filters are typed as optional in the init signature, we catch the # case where they are not present in the serialized data and set them to an empty dict. if custom_filters is not None: + init_params["custom_filters"] = dict(custom_filters) for name, filter_func in custom_filters.items(): init_params["custom_filters"][name] = deserialize_callable(filter_func) if filter_func else None - return default_from_dict(cls, data) + return default_from_dict(cls, {**data, "init_parameters": init_params}) def run(self, **kwargs: Any) -> dict[str, Any]: """ diff --git a/test/components/joiners/test_branch_joiner.py b/test/components/joiners/test_branch_joiner.py index 8cb2c6b818f..05e770f3812 100644 --- a/test/components/joiners/test_branch_joiner.py +++ b/test/components/joiners/test_branch_joiner.py @@ -7,6 +7,20 @@ from haystack.components.joiners import BranchJoiner +class TestBranchJoinerDeserialization: + def test_from_dict_does_not_mutate_caller_data(self): + joiner = BranchJoiner(list[str]) + data = joiner.to_dict() + serialized_type = data["init_parameters"]["type_"] + assert isinstance(serialized_type, str) + + BranchJoiner.from_dict(data) + + assert data["init_parameters"]["type_"] == serialized_type + # a second deserialization of the same dict must behave like the first + BranchJoiner.from_dict(data) + + class TestBranchJoiner: def test_one_value(self): joiner = BranchJoiner(int) diff --git a/test/components/routers/test_conditional_router.py b/test/components/routers/test_conditional_router.py index 12e9bf04f69..e9868380f76 100644 --- a/test/components/routers/test_conditional_router.py +++ b/test/components/routers/test_conditional_router.py @@ -1021,3 +1021,21 @@ def test_conditional_router_passthrough_skips_output_template_validation(self): router = ConditionalRouter(routes) result = router.run(**{"{{unclosed": "value"}) assert result == {"out": "value"} + + +class TestConditionalRouterDeserialization: + def test_from_dict_does_not_mutate_caller_data(self): + routes = [ + {"condition": "{{ x > 1 }}", "output": "{{ x }}", "output_name": "big", "output_type": int}, + {"condition": "{{ x <= 1 }}", "output": "{{ x }}", "output_name": "small", "output_type": int}, + ] + router = ConditionalRouter(routes) + data = router.to_dict() + serialized_types = [route["output_type"] for route in data["init_parameters"]["routes"]] + assert all(isinstance(t, str) for t in serialized_types) + + ConditionalRouter.from_dict(data) + + assert [route["output_type"] for route in data["init_parameters"]["routes"]] == serialized_types + # a second deserialization of the same dict must behave like the first + ConditionalRouter.from_dict(data) From c206f0dc5cf1fea42aabc21b8bbf0354f259addd Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:01:29 +0800 Subject: [PATCH 2/3] fix CI: guard routes loop, type the mutation test, add release note - from_dict: only iterate routes when present (mypy union-attr) - test: annotate routes as list[Route] (mypy arg-type) - add releasenotes/notes entry (reno) --- haystack/components/routers/conditional_router.py | 12 ++++++------ ...conditional-router-from-dict-caller-mutation.yaml | 7 +++++++ test/components/routers/test_conditional_router.py | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 releasenotes/notes/fix-conditional-router-from-dict-caller-mutation.yaml diff --git a/haystack/components/routers/conditional_router.py b/haystack/components/routers/conditional_router.py index 6d836f16147..22519f9834e 100644 --- a/haystack/components/routers/conditional_router.py +++ b/haystack/components/routers/conditional_router.py @@ -389,12 +389,12 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": routes = init_params.get("routes") if routes is not None: init_params["routes"] = routes = [dict(route) for route in routes] - for route in routes: - # output_type needs to be deserialized from a string to a type - if isinstance(route["output_type"], list): - route["output_type"] = [deserialize_type(t) for t in route["output_type"]] - else: - route["output_type"] = deserialize_type(route["output_type"]) + for route in routes: + # output_type needs to be deserialized from a string to a type + if isinstance(route["output_type"], list): + route["output_type"] = [deserialize_type(t) for t in route["output_type"]] + else: + route["output_type"] = deserialize_type(route["output_type"]) # Since the custom_filters are typed as optional in the init signature, we catch the # case where they are not present in the serialized data and set them to an empty dict. diff --git a/releasenotes/notes/fix-conditional-router-from-dict-caller-mutation.yaml b/releasenotes/notes/fix-conditional-router-from-dict-caller-mutation.yaml new file mode 100644 index 00000000000..c672a6ceba7 --- /dev/null +++ b/releasenotes/notes/fix-conditional-router-from-dict-caller-mutation.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Fixed ``ConditionalRouter.from_dict`` mutating the caller's ``routes`` data in place: serialized + ``output_type`` strings were deserialized directly inside the caller's dictionaries, so reusing the same + serialized pipeline dict afterwards yielded already-deserialized type objects. ``from_dict`` now works on a + copy and the caller's data is left untouched. ``BranchJoiner.from_dict`` received the same treatment. diff --git a/test/components/routers/test_conditional_router.py b/test/components/routers/test_conditional_router.py index e9868380f76..4d73994dd7f 100644 --- a/test/components/routers/test_conditional_router.py +++ b/test/components/routers/test_conditional_router.py @@ -1025,7 +1025,7 @@ def test_conditional_router_passthrough_skips_output_template_validation(self): class TestConditionalRouterDeserialization: def test_from_dict_does_not_mutate_caller_data(self): - routes = [ + routes: list[Route] = [ {"condition": "{{ x > 1 }}", "output": "{{ x }}", "output_name": "big", "output_type": int}, {"condition": "{{ x <= 1 }}", "output": "{{ x }}", "output_name": "small", "output_type": int}, ] From e7d523d0fa3845c3092a12f48c883aaec05eb914 Mon Sep 17 00:00:00 2001 From: Julian Risch Date: Tue, 29 Sep 2026 12:39:06 +0200 Subject: [PATCH 3/3] refactor: tighten ConditionalRouter and BranchJoiner from_dict copies - Leave custom_filters out of the init parameters when the serialized data has none, instead of passing an empty dict to __init__ - Build the deserialized custom_filters as a new dict rather than copying and then mutating it - Shorten the copy-rationale comments to one line - Add the reno hash suffix to the release note filename Co-Authored-By: Claude Opus 5.5 (1M context) --- haystack/components/joiners/branch.py | 4 +--- .../components/routers/conditional_router.py | 17 +++++++---------- ...-dict-caller-mutation-da863fb2fd788ea9.yaml} | 0 3 files changed, 8 insertions(+), 13 deletions(-) rename releasenotes/notes/{fix-conditional-router-from-dict-caller-mutation.yaml => fix-conditional-router-from-dict-caller-mutation-da863fb2fd788ea9.yaml} (100%) diff --git a/haystack/components/joiners/branch.py b/haystack/components/joiners/branch.py index f8056eb6573..3715991459e 100644 --- a/haystack/components/joiners/branch.py +++ b/haystack/components/joiners/branch.py @@ -113,9 +113,7 @@ def from_dict(cls, data: dict[str, Any]) -> "BranchJoiner": :returns: A deserialized `BranchJoiner` instance. """ - # Copy so that replacing the serialized ``type_`` with the deserialized type does - # not mutate the caller's ``data`` dict in place. Without this, a second - # deserialization of the same dict would receive an already-parsed type. + # Copy so the caller's data keeps the serialized type and can be deserialized again init_parameters = dict(data["init_parameters"]) init_parameters["type_"] = deserialize_type(init_parameters["type_"]) return default_from_dict(cls, {**data, "init_parameters": init_parameters}) diff --git a/haystack/components/routers/conditional_router.py b/haystack/components/routers/conditional_router.py index 22519f9834e..06d838f3b4c 100644 --- a/haystack/components/routers/conditional_router.py +++ b/haystack/components/routers/conditional_router.py @@ -364,9 +364,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": :returns: The deserialized component. """ - # Copy so that replacing serialized sub-objects below does not mutate the caller's - # ``data`` dict in place. Without this, a second deserialization of the same dict - # would receive already-parsed objects instead of their serialized form. + # Copy so the caller's data stays serialized; nested routes and filters are copied below too init_params = dict(data.get("init_parameters", {})) # `unsafe=True` swaps the Jinja sandbox for a NativeEnvironment that executes arbitrary code. @@ -378,7 +376,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": "If you trust the source of this data, load it with Pipeline.load(..., unsafe=True)." ) - custom_filters = init_params.get("custom_filters", {}) + custom_filters = init_params.get("custom_filters") if custom_filters and not _is_unsafe_deserialization(): raise DeserializationError( "Refusing to deserialize a ConditionalRouter with custom filters while loading in safe mode. " @@ -396,12 +394,11 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": else: route["output_type"] = deserialize_type(route["output_type"]) - # Since the custom_filters are typed as optional in the init signature, we catch the - # case where they are not present in the serialized data and set them to an empty dict. - if custom_filters is not None: - init_params["custom_filters"] = dict(custom_filters) - for name, filter_func in custom_filters.items(): - init_params["custom_filters"][name] = deserialize_callable(filter_func) if filter_func else None + if custom_filters: + init_params["custom_filters"] = { + name: deserialize_callable(filter_func) if filter_func else None + for name, filter_func in custom_filters.items() + } return default_from_dict(cls, {**data, "init_parameters": init_params}) def run(self, **kwargs: Any) -> dict[str, Any]: diff --git a/releasenotes/notes/fix-conditional-router-from-dict-caller-mutation.yaml b/releasenotes/notes/fix-conditional-router-from-dict-caller-mutation-da863fb2fd788ea9.yaml similarity index 100% rename from releasenotes/notes/fix-conditional-router-from-dict-caller-mutation.yaml rename to releasenotes/notes/fix-conditional-router-from-dict-caller-mutation-da863fb2fd788ea9.yaml