Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions haystack/utils/type_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion test/utils/test_type_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -80,13 +80,21 @@
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]),
pytest.param("typing.Tuple[str]", Tuple[str]),
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),
Expand Down Expand Up @@ -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
Expand Down
Loading