Skip to content

Commit 04fe770

Browse files
authored
fix: round-trip serialization of Ellipsis in variadic tuple and Callable types (#12062)
1 parent 268e23c commit 04fe770

3 files changed

Lines changed: 47 additions & 10 deletions

File tree

haystack/utils/type_serialization.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222

2323
_import_lock = Lock()
2424

25+
# Literal singletons that are not types and must be resolved before the generic/module-path handling in
26+
# deserialize_type: the None singleton ("None"), NoneType, and Ellipsis ("..."). Ellipsis appears in
27+
# variadic tuples (tuple[int, ...]) and Callable[..., X]. "Ellipsis" is also accepted so that types
28+
# serialized by older Haystack versions (which emitted the literal "Ellipsis") can still be read back.
29+
_SPECIAL_LITERALS: dict[str, Any] = {"None": None, "NoneType": NoneType, "...": ..., "Ellipsis": ...}
30+
2531

2632
def _is_union_type(target: Any) -> bool:
2733
"""
@@ -59,6 +65,13 @@ def serialize_type(target: Any) -> str:
5965
if target is NoneType:
6066
return "None"
6167

68+
# `...` (Ellipsis) shows up as an argument in variadic tuples (tuple[int, ...]) and in
69+
# Callable[..., X]. It is a singleton constant rather than a type, so serialize it to the same
70+
# literal Python uses when rendering these types. Without this it would fall through to
71+
# str(Ellipsis) == "Ellipsis", which deserialize_type then rejects as a non-type builtin.
72+
if target is Ellipsis:
73+
return "..."
74+
6275
args = get_args(target)
6376

6477
if isinstance(target, UnionType):
@@ -171,6 +184,12 @@ def deserialize_type(type_str: str) -> Any:
171184
If the module is not on the deserialization allowlist, or if the type cannot be
172185
deserialized due to a missing module or type.
173186
"""
187+
# Resolve the non-type literal singletons (see _SPECIAL_LITERALS) up front, before the generic and
188+
# module-path handling below: the dots in "..." would otherwise be read as a module path, and the
189+
# None/Ellipsis singletons would be refused by the builtin type gate.
190+
if type_str in _SPECIAL_LITERALS:
191+
return _SPECIAL_LITERALS[type_str]
192+
174193
# Handle PEP 604 union syntax at the top level (e.g., "str | int", "str | None")
175194
pep604_union_args = _parse_pep604_union_args(type_str)
176195
if len(pep604_union_args) > 1:
@@ -199,15 +218,9 @@ def deserialize_type(type_str: str) -> Any:
199218
except ImportError as e:
200219
raise DeserializationError(str(e)) from e
201220

202-
# No module prefix, check builtins and typing
203-
# Special cases for None / NoneType first: `getattr(builtins, "None")` returns the `None`
204-
# singleton (not a type), so these must be handled before the builtins type gate below.
205-
if type_str == "None":
206-
return None
207-
if type_str == "NoneType":
208-
return NoneType
209-
210-
# Then check builtins
221+
# No module prefix, check builtins and typing.
222+
# (None / NoneType / Ellipsis are handled at the top of this function, before they can reach the
223+
# builtin type gate below which would refuse them for not being types.)
211224
if hasattr(builtins, type_str):
212225
resolved = getattr(builtins, type_str)
213226
# This bare-name path never consults the allowlist. A type annotation must resolve to an
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed serialization of types that contain ``...`` (``Ellipsis``), such as variadic tuples
5+
(``tuple[int, ...]``) and ``Callable[..., X]``. Previously ``serialize_type`` rendered the ``...``
6+
as the literal string ``"Ellipsis"``, which ``deserialize_type`` then rejected as a non-type
7+
builtin, so a component using such a type (for example ``OutputAdapter(output_type=tuple[int, ...])``)
8+
could be serialized but not deserialized, breaking ``Pipeline.loads()`` / ``Pipeline.load()``.
9+
These types now round-trip correctly, and pipelines serialized by older versions (which emitted
10+
``"Ellipsis"``) can still be loaded.

test/utils/test_type_serialization.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import typing
88
from collections import deque
99
from types import UnionType
10-
from typing import Any, Deque, Dict, FrozenSet, List, Optional, Set, Tuple, Union
10+
from typing import Any, Callable, Deque, Dict, FrozenSet, List, Optional, Set, Tuple, Union
1111

1212
import pytest
1313

@@ -80,13 +80,21 @@
8080
pytest.param("tuple[dict]", tuple[dict]),
8181
pytest.param("tuple[float]", tuple[float]),
8282
pytest.param("tuple[bool]", tuple[bool]),
83+
# variadic tuple (the `...` is the Ellipsis singleton, not a type)
84+
pytest.param("tuple[int, ...]", tuple[int, ...]),
85+
pytest.param("tuple[str, ...]", tuple[str, ...]),
86+
pytest.param("tuple[dict[str, int], ...]", tuple[dict[str, int], ...]),
8387
# typing Tuple
8488
pytest.param("typing.Tuple", Tuple),
8589
pytest.param("typing.Tuple[int]", Tuple[int]),
8690
pytest.param("typing.Tuple[str]", Tuple[str]),
8791
pytest.param("typing.Tuple[dict]", Tuple[dict]),
8892
pytest.param("typing.Tuple[float]", Tuple[float]),
8993
pytest.param("typing.Tuple[bool]", Tuple[bool]),
94+
pytest.param("typing.Tuple[int, ...]", Tuple[int, ...]),
95+
# callable (the `...` is the Ellipsis singleton, not a type)
96+
pytest.param("typing.Callable[..., int]", Callable[..., int]),
97+
pytest.param("typing.Callable[..., str]", Callable[..., str]),
9098
# PEP 604 X | Y
9199
pytest.param("str | int", str | int),
92100
pytest.param("int | float", int | float),
@@ -245,6 +253,12 @@ def test_output_type_round_trip_typing_generic_with_nonetype():
245253
assert deserialize_type(serialize_type(type_)) == type_
246254

247255

256+
def test_output_type_deserialization_legacy_ellipsis_literal():
257+
# Types serialized by older versions emitted the literal "Ellipsis"; make sure they still load.
258+
assert deserialize_type("tuple[int, Ellipsis]") == tuple[int, ...]
259+
assert deserialize_type("typing.Callable[Ellipsis, int]") == Callable[..., int]
260+
261+
248262
def test_output_type_serialization_haystack_dataclasses():
249263
# typing
250264
# Answer

0 commit comments

Comments
 (0)