From 85e90cc19c4be9eeee7ecb50e5a47b82394fefe6 Mon Sep 17 00:00:00 2001 From: winklemad Date: Sat, 18 Jul 2026 20:18:07 +0530 Subject: [PATCH 1/2] fix: round-trip serialization of Ellipsis in variadic tuple and Callable types serialize_type rendered the `...` in types like tuple[int, ...] and Callable[..., X] as the literal string "Ellipsis", which deserialize_type then refused as a non-type builtin. So a component using such a type (for example OutputAdapter(output_type=tuple[int, ...])) could be written by Pipeline.dumps() but failed to load back with Pipeline.loads(). Serialize Ellipsis to "..." (the literal Python itself uses when rendering these types) and resolve it, together with None and NoneType, before the generic and module-path handling in deserialize_type. The legacy "Ellipsis" literal is still accepted so pipelines serialized by older versions keep loading. --- haystack/utils/type_serialization.py | 29 ++++++++++----- ...e-type-serialization-5fea4406404a597f.yaml | 10 ++++++ test/utils/test_type_serialization.py | 35 ++++++++++++++++++- 3 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/fix-variadic-tuple-type-serialization-5fea4406404a597f.yaml diff --git a/haystack/utils/type_serialization.py b/haystack/utils/type_serialization.py index 9ee63746be7..31714af25e0 100644 --- a/haystack/utils/type_serialization.py +++ b/haystack/utils/type_serialization.py @@ -59,6 +59,13 @@ def serialize_type(target: Any) -> str: if target is NoneType: return "None" + # `...` (Ellipsis) shows up as an argument in variadic tuples (tuple[int, ...]) and in + # Callable[..., X]. It is a singleton constant rather than a type, so serialize it to the same + # literal Python uses when rendering these types. Without this it would fall through to + # str(Ellipsis) == "Ellipsis", which deserialize_type then rejects as a non-type builtin. + if target is Ellipsis: + return "..." + args = get_args(target) if isinstance(target, UnionType): @@ -171,6 +178,16 @@ def deserialize_type(type_str: str) -> Any: If the module is not on the deserialization allowlist, or if the type cannot be deserialized due to a missing module or type. """ + # Literal singletons that are not types: the None singleton ("None"), NoneType ("NoneType") and + # Ellipsis ("..."). Ellipsis appears in variadic tuples (tuple[int, ...]) and Callable[..., X]. + # These are resolved up front, before the generic and module-path handling below: the dots in + # "..." would otherwise be read as a module path, and the None/Ellipsis singletons would be refused + # by the builtin type gate. "Ellipsis" is also accepted so that types serialized by older versions + # (which emitted the literal "Ellipsis") can still be read back. + special_literals: dict[str, Any] = {"None": None, "NoneType": NoneType, "...": ..., "Ellipsis": ...} + if type_str in special_literals: + return special_literals[type_str] + # Handle PEP 604 union syntax at the top level (e.g., "str | int", "str | None") pep604_union_args = _parse_pep604_union_args(type_str) if len(pep604_union_args) > 1: @@ -199,15 +216,9 @@ def deserialize_type(type_str: str) -> Any: except ImportError as e: raise DeserializationError(str(e)) from e - # No module prefix, check builtins and typing - # Special cases for None / NoneType first: `getattr(builtins, "None")` returns the `None` - # singleton (not a type), so these must be handled before the builtins type gate below. - if type_str == "None": - return None - if type_str == "NoneType": - return NoneType - - # Then check builtins + # No module prefix, check builtins and typing. + # (None / NoneType / Ellipsis are handled at the top of this function, before they can reach the + # builtin type gate below which would refuse them for not being types.) if hasattr(builtins, type_str): resolved = getattr(builtins, type_str) # This bare-name path never consults the allowlist. A type annotation must resolve to an diff --git a/releasenotes/notes/fix-variadic-tuple-type-serialization-5fea4406404a597f.yaml b/releasenotes/notes/fix-variadic-tuple-type-serialization-5fea4406404a597f.yaml new file mode 100644 index 00000000000..f198cb3225d --- /dev/null +++ b/releasenotes/notes/fix-variadic-tuple-type-serialization-5fea4406404a597f.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + Fixed serialization of types that contain ``...`` (``Ellipsis``), such as variadic tuples + (``tuple[int, ...]``) and ``Callable[..., X]``. Previously ``serialize_type`` rendered the ``...`` + as the literal string ``"Ellipsis"``, which ``deserialize_type`` then rejected as a non-type + builtin, so a component using such a type (for example ``OutputAdapter(output_type=tuple[int, ...])``) + could be serialized but not deserialized, breaking ``Pipeline.loads()`` / ``Pipeline.load()``. + These types now round-trip correctly, and pipelines serialized by older versions (which emitted + ``"Ellipsis"``) can still be loaded. diff --git a/test/utils/test_type_serialization.py b/test/utils/test_type_serialization.py index af68b9e3781..d64b1373060 100644 --- a/test/utils/test_type_serialization.py +++ b/test/utils/test_type_serialization.py @@ -7,7 +7,7 @@ import typing from collections import deque from types import UnionType -from typing import Any, Deque, Dict, FrozenSet, List, Optional, Set, Tuple, Union +from typing import Any, Callable, Deque, Dict, FrozenSet, List, Optional, Set, Tuple, Union import pytest @@ -80,6 +80,10 @@ pytest.param("tuple[dict]", tuple[dict]), pytest.param("tuple[float]", tuple[float]), pytest.param("tuple[bool]", tuple[bool]), + # variadic tuple (the `...` is the Ellipsis singleton, not a type) + pytest.param("tuple[int, ...]", tuple[int, ...]), + pytest.param("tuple[str, ...]", tuple[str, ...]), + pytest.param("tuple[dict[str, int], ...]", tuple[dict[str, int], ...]), # typing Tuple pytest.param("typing.Tuple", Tuple), pytest.param("typing.Tuple[int]", Tuple[int]), @@ -87,6 +91,7 @@ pytest.param("typing.Tuple[dict]", Tuple[dict]), pytest.param("typing.Tuple[float]", Tuple[float]), pytest.param("typing.Tuple[bool]", Tuple[bool]), + pytest.param("typing.Tuple[int, ...]", Tuple[int, ...]), # PEP 604 X | Y pytest.param("str | int", str | int), pytest.param("int | float", int | float), @@ -245,6 +250,34 @@ def test_output_type_round_trip_typing_generic_with_nonetype(): assert deserialize_type(serialize_type(type_)) == type_ +def test_output_type_serialization_ellipsis(): + # The `...` (Ellipsis) singleton is serialized to the literal "..." Python itself uses when rendering + # variadic tuples and Callable, not to str(Ellipsis) == "Ellipsis" (which is not a type). + assert serialize_type(tuple[int, ...]) == "tuple[int, ...]" + assert serialize_type(Tuple[int, ...]) == "typing.Tuple[int, ...]" + assert serialize_type(Callable[..., int]) == "typing.Callable[..., int]" + + +def test_output_type_round_trip_variadic_tuple_and_callable_ellipsis(): + # Regression: `...` used to be serialized as "Ellipsis", which deserialize_type then rejected as a + # non-type builtin, so a type Haystack serialized itself could not be read back. + for type_ in [ + tuple[int, ...], + tuple[str, ...], + tuple[dict[str, int], ...], + Tuple[int, ...], + Callable[..., int], + Callable[..., str], + ]: + assert deserialize_type(serialize_type(type_)) == type_ + + +def test_output_type_deserialization_legacy_ellipsis_literal(): + # Types serialized by older versions emitted the literal "Ellipsis"; make sure they still load. + assert deserialize_type("tuple[int, Ellipsis]") == tuple[int, ...] + assert deserialize_type("typing.Callable[Ellipsis, int]") == Callable[..., int] + + def test_output_type_serialization_haystack_dataclasses(): # typing # Answer From 95a47beff95e57b75881f4c29f3beae74e0ed173 Mon Sep 17 00:00:00 2001 From: winklemad Date: Mon, 20 Jul 2026 22:09:09 +0530 Subject: [PATCH 2/2] Address review: extract _SPECIAL_LITERALS constant, cover Callable via round-trip params Move the special-literal lookup table in deserialize_type to a module-level _SPECIAL_LITERALS constant, and add typing.Callable[..., int] / [..., str] to TYPING_AND_TYPE_TESTS so the variadic-tuple and Callable ellipsis cases are exercised by the shared serialize/deserialize round-trip tests. The two standalone ellipsis tests are now redundant and removed. --- haystack/utils/type_serialization.py | 20 +++++++++++--------- test/utils/test_type_serialization.py | 25 +++---------------------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/haystack/utils/type_serialization.py b/haystack/utils/type_serialization.py index 31714af25e0..784c9c3b572 100644 --- a/haystack/utils/type_serialization.py +++ b/haystack/utils/type_serialization.py @@ -22,6 +22,12 @@ _import_lock = Lock() +# Literal singletons that are not types and must be resolved before the generic/module-path handling in +# deserialize_type: the None singleton ("None"), NoneType, and Ellipsis ("..."). Ellipsis appears in +# variadic tuples (tuple[int, ...]) and Callable[..., X]. "Ellipsis" is also accepted so that types +# serialized by older Haystack versions (which emitted the literal "Ellipsis") can still be read back. +_SPECIAL_LITERALS: dict[str, Any] = {"None": None, "NoneType": NoneType, "...": ..., "Ellipsis": ...} + def _is_union_type(target: Any) -> bool: """ @@ -178,15 +184,11 @@ def deserialize_type(type_str: str) -> Any: If the module is not on the deserialization allowlist, or if the type cannot be deserialized due to a missing module or type. """ - # Literal singletons that are not types: the None singleton ("None"), NoneType ("NoneType") and - # Ellipsis ("..."). Ellipsis appears in variadic tuples (tuple[int, ...]) and Callable[..., X]. - # These are resolved up front, before the generic and module-path handling below: the dots in - # "..." would otherwise be read as a module path, and the None/Ellipsis singletons would be refused - # by the builtin type gate. "Ellipsis" is also accepted so that types serialized by older versions - # (which emitted the literal "Ellipsis") can still be read back. - special_literals: dict[str, Any] = {"None": None, "NoneType": NoneType, "...": ..., "Ellipsis": ...} - if type_str in special_literals: - return special_literals[type_str] + # Resolve the non-type literal singletons (see _SPECIAL_LITERALS) up front, before the generic and + # module-path handling below: the dots in "..." would otherwise be read as a module path, and the + # None/Ellipsis singletons would be refused by the builtin type gate. + if type_str in _SPECIAL_LITERALS: + return _SPECIAL_LITERALS[type_str] # Handle PEP 604 union syntax at the top level (e.g., "str | int", "str | None") pep604_union_args = _parse_pep604_union_args(type_str) diff --git a/test/utils/test_type_serialization.py b/test/utils/test_type_serialization.py index d64b1373060..0c929c7e44c 100644 --- a/test/utils/test_type_serialization.py +++ b/test/utils/test_type_serialization.py @@ -92,6 +92,9 @@ pytest.param("typing.Tuple[float]", Tuple[float]), pytest.param("typing.Tuple[bool]", Tuple[bool]), pytest.param("typing.Tuple[int, ...]", Tuple[int, ...]), + # callable (the `...` is the Ellipsis singleton, not a type) + pytest.param("typing.Callable[..., int]", Callable[..., int]), + pytest.param("typing.Callable[..., str]", Callable[..., str]), # PEP 604 X | Y pytest.param("str | int", str | int), pytest.param("int | float", int | float), @@ -250,28 +253,6 @@ def test_output_type_round_trip_typing_generic_with_nonetype(): assert deserialize_type(serialize_type(type_)) == type_ -def test_output_type_serialization_ellipsis(): - # The `...` (Ellipsis) singleton is serialized to the literal "..." Python itself uses when rendering - # variadic tuples and Callable, not to str(Ellipsis) == "Ellipsis" (which is not a type). - assert serialize_type(tuple[int, ...]) == "tuple[int, ...]" - assert serialize_type(Tuple[int, ...]) == "typing.Tuple[int, ...]" - assert serialize_type(Callable[..., int]) == "typing.Callable[..., int]" - - -def test_output_type_round_trip_variadic_tuple_and_callable_ellipsis(): - # Regression: `...` used to be serialized as "Ellipsis", which deserialize_type then rejected as a - # non-type builtin, so a type Haystack serialized itself could not be read back. - for type_ in [ - tuple[int, ...], - tuple[str, ...], - tuple[dict[str, int], ...], - Tuple[int, ...], - Callable[..., int], - Callable[..., str], - ]: - assert deserialize_type(serialize_type(type_)) == type_ - - def test_output_type_deserialization_legacy_ellipsis_literal(): # Types serialized by older versions emitted the literal "Ellipsis"; make sure they still load. assert deserialize_type("tuple[int, Ellipsis]") == tuple[int, ...]