Skip to content

Commit 1f460e6

Browse files
fix: round-trip serialization of Callable types with an explicit parameter list (#12122)
Co-authored-by: David S. Batista <dsbatista@gmail.com>
1 parent 35d514d commit 1f460e6

5 files changed

Lines changed: 115 additions & 3 deletions

File tree

haystack/utils/type_serialization.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ def _build_pep604_union_type(types: list[type | UnionType]) -> type | UnionType:
5050
return result
5151

5252

53+
def _serialize_type_arg(arg: Any) -> str:
54+
"""
55+
Serialize a single generic argument.
56+
57+
Almost all arguments are plain types and are handed off to ``serialize_type``. The exception is the
58+
parameter list of a ``Callable[[X, Y], R]``, which ``typing.get_args`` returns as a Python ``list``
59+
(e.g. ``[X, Y]``) rather than a type. Such a list is rendered as ``[X, Y]`` so it round-trips back to the
60+
same ``Callable`` on deserialization. Without this, the list would fall through to ``serialize_type`` and
61+
be dropped (its ``str()`` starts with ``[``, which the name handling truncates to an empty string).
62+
"""
63+
if isinstance(arg, list):
64+
return f"[{', '.join(serialize_type(a) for a in arg)}]"
65+
return serialize_type(arg)
66+
67+
5368
def serialize_type(target: Any) -> str:
5469
"""
5570
Serializes a type or an instance to its string representation, including the module name.
@@ -100,7 +115,7 @@ def serialize_type(target: Any) -> str:
100115
# Union with more than two members) NoneType is a regular argument and must be kept.
101116
skip_nonetype = name == "Optional"
102117
args_str = ", ".join(
103-
serialize_type(Union[tuple(get_args(a))] if is_typing_generic and isinstance(a, UnionType) else a) # noqa: UP007
118+
_serialize_type_arg(Union[tuple(get_args(a))] if is_typing_generic and isinstance(a, UnionType) else a) # noqa: UP007
104119
for a in args
105120
if not (skip_nonetype and a is NoneType)
106121
)
@@ -163,6 +178,24 @@ def _parse_pep604_union_args(union_str: str) -> list[str]:
163178
return args
164179

165180

181+
def _deserialize_type_arg(arg_str: str) -> Any:
182+
"""
183+
Deserialize a single generic argument produced by ``_parse_generic_args``.
184+
185+
Mirrors ``_serialize_type_arg``: a ``Callable`` parameter list is emitted as ``[X, Y]`` and must be read
186+
back as a Python ``list`` of types (so ``Callable`` can be re-subscripted as ``Callable[[X, Y], R]``),
187+
not as a single type. An empty parameter list ``[]`` becomes ``[]``. Every other argument is a normal
188+
type and is delegated to ``deserialize_type``.
189+
"""
190+
stripped = arg_str.strip()
191+
if stripped.startswith("[") and stripped.endswith("]"):
192+
inner = stripped[1:-1].strip()
193+
if not inner:
194+
return []
195+
return [deserialize_type(a) for a in _parse_generic_args(inner)]
196+
return deserialize_type(arg_str)
197+
198+
166199
def deserialize_type(type_str: str) -> Any:
167200
"""
168201
Deserializes a type given its full import path as a string, including nested generic types.
@@ -202,7 +235,7 @@ def deserialize_type(type_str: str) -> Any:
202235
generics_str = generics_str[:-1]
203236

204237
main_type = deserialize_type(main_type_str)
205-
generic_args = [deserialize_type(arg) for arg in _parse_generic_args(generics_str)]
238+
generic_args = [_deserialize_type_arg(arg) for arg in _parse_generic_args(generics_str)]
206239

207240
# Reconstruct
208241
try:
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed ``serialize_type`` and ``deserialize_type`` to correctly round-trip
5+
``Callable`` types that declare an explicit parameter list, such as
6+
``Callable[[int, str], bool]``. Previously the parameter list was dropped
7+
during serialization (producing a malformed string like
8+
``typing.Callable[, bool]``) and could no longer be deserialized. This
9+
affected components that serialize type annotations, for example
10+
``ConditionalRouter`` and ``OutputAdapter`` using a ``Callable`` output type.

test/components/converters/test_output_adapter.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import json
6-
from typing import Any, List
6+
from typing import Any, Callable, List
77

88
import pytest
99

@@ -99,6 +99,18 @@ def test_sede(self):
9999
assert adapter.template == deserialized_adapter.template
100100
assert adapter.output_type == deserialized_adapter.output_type
101101

102+
def test_sede_with_callable_output_type(self):
103+
# Regression test: `output_type=Callable[[int, str], bool]` used to lose its parameter list on
104+
# `to_dict` (producing "typing.Callable[, bool]") and then fail to deserialize entirely.
105+
adapter = OutputAdapter(template="{{ callback }}", output_type=Callable[[int, str], bool])
106+
adapter_dict = adapter.to_dict()
107+
108+
assert adapter_dict["init_parameters"]["output_type"] == "typing.Callable[[int, str], bool]"
109+
110+
deserialized_adapter = OutputAdapter.from_dict(adapter_dict)
111+
assert adapter.template == deserialized_adapter.template
112+
assert adapter.output_type == deserialized_adapter.output_type
113+
102114
# OutputAdapter can be serialized to a dictionary and deserialized along with custom filters
103115
def test_sede_with_custom_filters(self):
104116
# NOTE: filters need to be declared in a namespace visible to the deserialization function

test/components/routers/test_conditional_router.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import copy
6+
from collections.abc import Callable
67
from unittest import mock
78

89
import pytest
@@ -279,6 +280,26 @@ def test_router_de_serialization(self):
279280
# check that the result is the same and correct
280281
assert result1 == result2 and result1 == {"streams": [1, 2, 3]}
281282

283+
def test_router_de_serialization_with_callable_output_type(self):
284+
# Regression test: `output_type=Callable[[int, str], bool]` used to lose its parameter list on
285+
# `to_dict` (producing "typing.Callable[, bool]") and then fail to deserialize entirely.
286+
routes = [
287+
{
288+
"condition": "{{True}}",
289+
"output": "{{callback}}",
290+
"output_type": Callable[[int, str], bool],
291+
"output_name": "callback",
292+
}
293+
]
294+
router = ConditionalRouter(routes)
295+
router_dict = router.to_dict()
296+
297+
serialized_output_type = router_dict["init_parameters"]["routes"][0]["output_type"]
298+
assert serialized_output_type == "collections.abc.Callable[[int, str], bool]"
299+
300+
new_router = ConditionalRouter.from_dict(router_dict)
301+
assert router.routes == new_router.routes
302+
282303
def test_router_de_serialization_with_none_argument(self):
283304
new_router = ConditionalRouter.from_dict(
284305
{

test/utils/test_type_serialization.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,42 @@ def test_output_type_deserialization_legacy_ellipsis_literal():
259259
assert deserialize_type("typing.Callable[Ellipsis, int]") == Callable[..., int]
260260

261261

262+
def test_output_type_serialization_callable_with_parameter_list():
263+
# `Callable[[X, Y], R]` returns its parameter list from `typing.get_args` as a Python list ([X, Y]),
264+
# not as a type. It must be serialized as "[X, Y]" so the parameters survive the round-trip.
265+
assert serialize_type(Callable[[int, str], bool]) == "typing.Callable[[int, str], bool]"
266+
assert serialize_type(Callable[[], int]) == "typing.Callable[[], int]"
267+
assert serialize_type(Callable[[int], List[str]]) == "typing.Callable[[int], typing.List[str]]"
268+
assert serialize_type(Callable[[Union[str, int]], bool]) == "typing.Callable[[typing.Union[str, int]], bool]"
269+
270+
271+
def test_output_type_deserialization_callable_with_parameter_list():
272+
assert deserialize_type("typing.Callable[[int, str], bool]") == Callable[[int, str], bool]
273+
assert deserialize_type("typing.Callable[[], int]") == Callable[[], int]
274+
assert deserialize_type("typing.Callable[[int], typing.List[str]]") == Callable[[int], List[str]]
275+
assert deserialize_type("typing.Callable[[typing.Union[str, int]], bool]") == Callable[[Union[str, int]], bool]
276+
277+
278+
def test_output_type_round_trip_callable_with_parameter_list():
279+
for type_ in [
280+
Callable[[int, str], bool],
281+
Callable[[], int],
282+
Callable[[int], List[str]],
283+
Callable[[Dict[str, int], str], List[bool]],
284+
Callable[[Union[str, int]], bool],
285+
List[Callable[[int], str]],
286+
Dict[str, Callable[[int, str], bool]],
287+
Optional[Callable[[int], str]],
288+
Callable[[Callable[[int], str]], bool],
289+
# The Ellipsis form (no parameter list) must still round-trip unaffected by the parameter-list
290+
# handling above: `_serialize_type_arg`/`_deserialize_type_arg` only special-case a `list` argument,
291+
# so `...` still falls through to the existing Ellipsis handling in serialize_type/deserialize_type.
292+
Callable[..., int],
293+
Callable[[int], Callable[..., str]],
294+
]:
295+
assert deserialize_type(serialize_type(type_)) == type_
296+
297+
262298
def test_output_type_serialization_haystack_dataclasses():
263299
# typing
264300
# Answer

0 commit comments

Comments
 (0)