diff --git a/haystack/utils/type_serialization.py b/haystack/utils/type_serialization.py index 9ee63746be7..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: """ @@ -59,6 +65,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 +184,12 @@ 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. """ + # 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) if len(pep604_union_args) > 1: @@ -199,15 +218,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..0c929c7e44c 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,10 @@ 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, ...]), + # 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), @@ -245,6 +253,12 @@ def test_output_type_round_trip_typing_generic_with_nonetype(): 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