diff --git a/haystack/core/super_component/utils.py b/haystack/core/super_component/utils.py index 9eea4eccc1a..c6eb0d6e42c 100644 --- a/haystack/core/super_component/utils.py +++ b/haystack/core/super_component/utils.py @@ -13,9 +13,7 @@ class _delegate_default: """Custom object for delegating filling of default values to the underlying components.""" -def _is_compatible( - type1: type | UnionType, type2: type | UnionType, unwrap_nested: bool = True -) -> tuple[bool, type | UnionType | None]: +def _is_compatible(type1: Any, type2: Any, unwrap_nested: bool = True) -> tuple[bool, Any]: """ Check if two types are compatible (bidirectional/symmetric check). @@ -147,6 +145,13 @@ def _unwrap_all(t: type | UnionType, recursive: bool) -> type | UnionType: if _is_variadic_type(t): t = _unwrap_variadics(t, recursive=recursive) else: + # `Annotated[T, m1, m2, ...]` is the same type as `T` for compatibility — the metadata is + # just an annotation, not a type modifier. Unwrap to T (recursively, for nested Annotated). + # Haystack Variadic markers are also expressed as Annotated[T, HAYSTACK_VARIADIC_ANNOTATION] + # — the `_is_variadic_type` branch above handles those, so this unwrap only fires for plain + # user-supplied Annotated. + if get_origin(t) is Annotated: + return _unwrap_all(get_args(t)[0], recursive) # If it's a generic type and we're unwrapping recursively origin = get_origin(t) if recursive and origin is not None and (args := get_args(t)): diff --git a/haystack/core/type_utils.py b/haystack/core/type_utils.py index c789d6e73d4..005ce10a19d 100644 --- a/haystack/core/type_utils.py +++ b/haystack/core/type_utils.py @@ -7,7 +7,7 @@ from collections.abc import Callable from enum import Enum from types import NoneType, UnionType -from typing import Any, Union, get_args, get_origin, get_type_hints +from typing import Annotated, Any, Union, get_args, get_origin, get_type_hints from haystack.dataclasses import ChatMessage @@ -108,7 +108,7 @@ def _type_name(type_: Any) -> str: return f"{name}" -def _safe_get_origin(_type: type | UnionType) -> Any: +def _safe_get_origin(_type: Any) -> Any: """ Safely retrieves the origin type of a generic alias or returns the type itself if it's a built-in. @@ -124,9 +124,31 @@ def _safe_get_origin(_type: type | UnionType) -> Any: # So we convert UnionType to Union if it is detected. if origin is UnionType: origin = Union + # `Annotated[T, m1, m2, ...]` is the same type as `T` for type-checking purposes — the metadata + # is just an annotation, not a type modifier. `get_origin(Annotated[T, ...])` returns `Annotated` + # (not `T`), so unwrap to the wrapped type (recursively, for nested Annotated). Without this, + # `Annotated[int, "doc"]` would not be considered compatible with `int` and a component socket + # declared as `Annotated[int, "doc"]` could not be connected to one declared as `int`. + while origin is Annotated: + _type = get_args(_type)[0] + origin = get_origin(_type) or (_type if isinstance(_type, type) else None) return origin +def _unwrap_annotated(_type: Any) -> Any: + """ + Strip one or more ``Annotated[...]`` wrappers and return the underlying type. + + ``Annotated[T, m1, m2, ...]`` is the same type as ``T`` for type-checking purposes — the metadata + is annotation, not a type modifier. This is a small helper that does the same unwrap as + ``_safe_get_origin`` but returns the full type object (not just the origin), so callers that need + to use ``get_args`` on the result can do so without seeing the metadata as a type arg. + """ + while get_origin(_type) is Annotated: + _type = get_args(_type)[0] + return _type + + def _contains_type(container: Any, target: Any) -> bool: """Checks if the container type includes the target type""" if container == target: @@ -145,6 +167,15 @@ def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool: # noqa: P :param receiver: The receiver type. :return: True if the sender type is strictly compatible with the receiver type, False otherwise. """ + # `Annotated[T, m1, m2, ...]` is the same type as `T` for type-checking purposes — the metadata is + # just an annotation, not a type modifier. Unwrap both sides so the rest of the function works on + # the underlying types. Without this, `get_args(Annotated[int, "x"])` returns `(int, "x")` and the + # generic-args comparison below would see a 2-arg sender against a 0-arg receiver (int) and reject + # the pair. _safe_get_origin does the same unwrap; we also need it here because the rest of this + # function uses `get_args` directly. + sender = _unwrap_annotated(sender) + receiver = _unwrap_annotated(receiver) + if sender == receiver or receiver is Any: return True diff --git a/haystack/utils/type_serialization.py b/haystack/utils/type_serialization.py index 80b767cf057..7ca3c93ae3b 100644 --- a/haystack/utils/type_serialization.py +++ b/haystack/utils/type_serialization.py @@ -69,6 +69,23 @@ def _serialize_type_arg(arg: Any) -> str: return serialize_type(arg) +def _serialize_annotated_meta(meta: Any) -> str: + """ + Serialize a single metadata value of a ``typing.Annotated[...]``. + + Literal values (str, int, bool, None, bytes) are rendered with ``repr()`` so they keep their + quotes and round-trip cleanly through ``ast.literal_eval`` on the deserialize side. Type-like + values (classes, typing forms) are rendered through ``serialize_type`` so the full module path + is preserved and the deserialize side can resolve them back to the same object. ``repr`` on a + class produces ```` which is not a valid Python literal and would not round-trip. + """ + try: + ast.literal_eval(repr(meta)) + except (ValueError, SyntaxError): + return serialize_type(meta) + return repr(meta) + + def serialize_type(target: Any) -> str: """ Serializes a type or an instance to its string representation, including the module name. @@ -96,6 +113,18 @@ def serialize_type(target: Any) -> str: if typing.get_origin(target) is typing.Literal: return f"typing.Literal[{', '.join(repr(a) for a in get_args(target))}]" + # Annotated[T, m1, m2, ...] holds a type T followed by metadata values. Python normalizes the bare + # `Annotated[T]` form back to `T` (no metadata), so the metadata list is always non-empty here. + # Render the type through serialize_type (so nested generics and module paths are preserved) and + # each metadata value with repr() so strings keep their quotes — same approach as the Literal + # branch above, since metadata values are not types and would otherwise be misread as type names on + # deserialize (e.g. Annotated[str, "int"] silently corrupting to Annotated[str, int]). + if typing.get_origin(target) is typing.Annotated: + ann_args = get_args(target) + type_str = serialize_type(ann_args[0]) + meta_str = ", ".join(_serialize_annotated_meta(m) for m in ann_args[1:]) + return f"typing.Annotated[{type_str}, {meta_str}]" + args = get_args(target) if isinstance(target, UnionType): @@ -206,7 +235,66 @@ def _deserialize_type_arg(arg_str: str) -> Any: @mark_deserialization_internal -def deserialize_type(type_str: str) -> Any: +def _split_annotated_args(args_str: str) -> tuple[str, str]: + """ + Split the inside of a serialized ``typing.Annotated[...]`` into ``(type_str, metadata_str)``. + + Splits on the first comma at the top level — outside any ``[...]`` brackets and outside any string + literal — so a comma inside a string metadata value (``Annotated[int, "a, b"]``) is not treated as a + separator. ``_parse_generic_args`` is bracket-aware but not quote-aware and would mis-split such a + payload. If no top-level comma is found the whole string is the type and the metadata is empty + (defensive: ``Annotated`` always has at least one metadata value in practice because Python + normalizes ``Annotated[T]`` back to ``T``). + + :param args_str: The contents between the brackets of a serialized ``typing.Annotated[...]``. + :returns: A ``(type_str, metadata_str)`` tuple. Both are stripped. ``metadata_str`` is empty when no + top-level comma is present. + """ + bracket_count = 0 + in_string = False + string_char: str | None = None + i = 0 + while i < len(args_str): + c = args_str[i] + if in_string: + if c == "\\" and i + 1 < len(args_str): + # Skip the escaped character so a backslash-escaped quote does not end the string. + i += 2 + continue + if c == string_char: + in_string = False + elif c in ('"', "'"): + in_string = True + string_char = c + elif c == "[": + bracket_count += 1 + elif c == "]": + bracket_count -= 1 + elif c == "," and bracket_count == 0: + return args_str[:i].strip(), args_str[i + 1 :].strip() + i += 1 + return args_str.strip(), "" + + +@mark_deserialization_internal +def _deserialize_annotated_metadata(arg_str: str) -> Any: + """ + Deserialize a single metadata value from a serialized ``typing.Annotated[...]``. + + Tries ``ast.literal_eval`` first (which safely parses Python literals — str, int, bool, None, bytes — + and is quote-aware). Falls back to ``deserialize_type`` for non-literal metadata such as a validator + class (``Annotated[int, MyValidator]``), which is rendered with its full module path by + ``serialize_type``. Ellipsis (``...``) is parsed through the fallback to ``_SPECIAL_LITERALS``. + """ + stripped = arg_str.strip() + try: + return ast.literal_eval(stripped) + except (ValueError, SyntaxError): + return deserialize_type(stripped) + + +@mark_deserialization_internal +def deserialize_type(type_str: str) -> Any: # noqa: PLR0911 - dispatch on wire-form (PEP 604 / generic / Annotated / module path / builtin / typing) """ Deserializes a type given its full import path as a string, including nested generic types. @@ -252,6 +340,30 @@ def deserialize_type(type_str: str) -> Any: if main_type is typing.Literal: return typing.Literal[ast.literal_eval(f"({generics_str},)")] + # Annotated[T, m1, m2, ...] has a type as the first argument and metadata values (typically + # Python literals) as the rest. The split is quote-aware because a string metadata value can + # contain a comma — `_parse_generic_args` is bracket-aware but not quote-aware and would + # mis-split such a payload. The first chunk is the type and is resolved via deserialize_type; + # each metadata chunk is resolved via `_deserialize_annotated_metadata` (literal_eval with a + # deserialize_type fallback for type metadata like validators). + if main_type is typing.Annotated: + type_str, meta_str = _split_annotated_args(generics_str) + deserialized_type = deserialize_type(type_str) + if not meta_str: + raise DeserializationError( + f"Annotated requires at least one metadata value: typing.Annotated[{generics_str}]" + ) + # Try the all-literals path first (the common case): wrap the metadata in `(...)` with a + # trailing comma so `ast.literal_eval` parses it as a tuple. This is quote-aware, so a + # comma inside a string metadata value is not treated as a separator. If any metadata is + # a type (e.g. `Annotated[int, MyValidator]`), `ast.literal_eval` raises and we fall back + # to the per-arg path that mixes literal_eval with deserialize_type. + try: + deserialized_metadata = list(ast.literal_eval(f"({meta_str},)")) + except (ValueError, SyntaxError): + deserialized_metadata = [_deserialize_annotated_metadata(a) for a in _parse_generic_args(meta_str)] + return typing.Annotated[(deserialized_type, *deserialized_metadata)] + generic_args = [_deserialize_type_arg(arg) for arg in _parse_generic_args(generics_str)] # Reconstruct diff --git a/releasenotes/notes/fix-annotated-type-serialization-7d2c4f1e3a8b9450.yaml b/releasenotes/notes/fix-annotated-type-serialization-7d2c4f1e3a8b9450.yaml new file mode 100644 index 00000000000..9de367bc9a5 --- /dev/null +++ b/releasenotes/notes/fix-annotated-type-serialization-7d2c4f1e3a8b9450.yaml @@ -0,0 +1,24 @@ +--- +fixes: + - | + Fixed round-trip serialization and Pipeline type-checking for ``typing.Annotated``. Two related gaps: + + * ``serialize_type``/``deserialize_type`` had no handling for ``Annotated``: metadata values were + rendered as bare tokens (e.g. ``typing.Annotated[int, doc]``) which failed to deserialize, and + values that happened to look like a type name (e.g. ``Annotated[str, "int"]``) were silently + turned into types on the round-trip. Literal metadata (str, int, bool, None, bytes) is now + serialized with ``repr()`` and read back with ``ast.literal_eval``; type metadata is serialized + through ``serialize_type`` and resolved through ``deserialize_type`` on read. A quote-aware + split is used on the deserialize side so a comma inside a string metadata value (e.g. + ``Annotated[int, "a, b"]``) is not treated as an argument separator. + * ``_safe_get_origin`` and ``_strict_types_are_compatible`` in ``haystack.core.type_utils`` did + not unwrap ``Annotated``, so a component socket declared as ``Annotated[int, "doc"]`` was + treated as a different type from ``int`` and could not be connected. ``Annotated`` is now + treated as the underlying type for type-checking purposes (the metadata is annotation, not a + type modifier), so a socket declared as ``Annotated[int, "doc"]`` can be connected to one + declared as ``int``. + + An ``Annotated`` type used by a component (such as an ``OutputAdapter`` or any tool whose schema + uses Pydantic ``Field`` metadata) now round-trips correctly through ``Pipeline.dumps()`` / + ``Pipeline.loads()`` and can be connected across components. Mirrors the ``Literal`` fix from + ``#12286``. diff --git a/test/core/super_component/test_utils.py b/test/core/super_component/test_utils.py index 87c57afddb1..99d2f173d74 100644 --- a/test/core/super_component/test_utils.py +++ b/test/core/super_component/test_utils.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 -from typing import Any, Optional, Union +from typing import Annotated, Any, Optional, Union import pytest @@ -242,10 +242,10 @@ def test_mixed_variadic_types(): # Variadic with Union var_union = Variadic[Union[int, str]] - is_compat, common = _is_compatible(var_union, Union[int, str]) # type: ignore[arg-type] + is_compat, common = _is_compatible(var_union, Union[int, str]) assert is_compat and common == Union[int, str] - is_compat, common = _is_compatible(Union[int, str], var_union) # type: ignore[arg-type] + is_compat, common = _is_compatible(Union[int, str], var_union) assert is_compat and common == Union[int, str] # GreedyVariadic with Optional @@ -262,3 +262,39 @@ def test_mixed_variadic_types(): is_compat, common = _is_compatible(nested_var, list[int]) assert is_compat and common == list[int] + + +# `Annotated[T, m1, m2, ...]` is the same type as `T` for compatibility — the metadata is just an +# annotation, not a type modifier. These tests verify that the super_component type checker treats +# Annotated the same as the wrapped type. Without this, a SuperComponent socket declared as +# `Annotated[int, "doc"]` could not be aligned with one declared as `int`. +def test_annotated_type_compatibility(): + # Annotated[int, ...] is compatible with int in both directions. + is_compat, common = _is_compatible(Annotated[int, "doc"], int) + assert is_compat and common is int + + is_compat, common = _is_compatible(int, Annotated[int, "doc"]) + assert is_compat and common is int + + # Two Annotated types with the same wrapped type are compatible (metadata is ignored). + is_compat, common = _is_compatible(Annotated[int, "x"], Annotated[int, "y"]) + assert is_compat and common is int + + # Nested Annotated: unwrap recursively. + is_compat, common = _is_compatible(Annotated[Annotated[int, "inner"], "outer"], int) + assert is_compat and common is int + + # Annotated with a generic wrapped type is compatible with the bare generic. + is_compat, common = _is_compatible(Annotated[list[int], "doc"], list[int]) + assert is_compat and common == list[int] + + is_compat, common = _is_compatible(list[int], Annotated[list[int], "doc"]) + assert is_compat and common == list[int] + + # Annotated[Optional[int], ...] is compatible with Optional[int]. + is_compat, common = _is_compatible(Annotated[Optional[int], "doc"], Optional[int]) + assert is_compat and common == Optional[int] + + # Annotated[str, ...] is not compatible with int. + is_compat, _ = _is_compatible(Annotated[str, "doc"], int) + assert not is_compat diff --git a/test/core/test_type_utils.py b/test/core/test_type_utils.py index ec102696d7f..3a822f40b02 100644 --- a/test/core/test_type_utils.py +++ b/test/core/test_type_utils.py @@ -1233,3 +1233,45 @@ def test_keeps_annotations_of_target_that_cannot_carry_them(self): "documents": "list[Document]", "top_k": "int | None", } + + +# `Annotated[T, m1, m2, ...]` is the same type as `T` for type-checking purposes — the metadata is +# just an annotation, not a type modifier. These tests verify that the Pipeline type checker treats +# Annotated the same as the wrapped type so a component socket declared as `Annotated[int, "doc"]` +# can be connected to a socket declared as `int` (this is what enables `Annotated` annotations in +# component input/output socket types to round-trip through Pipeline serialization). +def test_annotated_same_as_wrapped_type_strict(): + from typing import Annotated + + from haystack.core.type_utils import _strict_types_are_compatible + + # Annotated[int, ...] is compatible with int in both directions. + assert _strict_types_are_compatible(Annotated[int, "doc"], int) + assert _strict_types_are_compatible(int, Annotated[int, "doc"]) + # Two Annotated types with the same wrapped type are compatible (metadata is ignored). + assert _strict_types_are_compatible(Annotated[int, "x"], Annotated[int, "y"]) + # Nested Annotated: unwrap recursively. + assert _strict_types_are_compatible(Annotated[Annotated[int, "inner"], "outer"], int) + # Annotated with a generic wrapped type is compatible with the bare generic. + assert _strict_types_are_compatible(Annotated[List[int], "doc"], List[int]) + assert _strict_types_are_compatible(List[int], Annotated[List[int], "doc"]) + # Annotated[str, ...] is not compatible with int. + assert not _strict_types_are_compatible(Annotated[str, "doc"], int) + assert not _strict_types_are_compatible(int, Annotated[str, "doc"]) + + +def test_safe_get_origin_unwraps_annotated(): + from typing import Annotated + + from haystack.core.type_utils import _safe_get_origin + + # _safe_get_origin(Annotated[T, ...]) returns the origin of T (not Annotated), so the rest of + # the type checker can treat Annotated[T, ...] as T. Without this unwrap, the type checker + # would see `Annotated` as the origin and reject the type as incompatible with anything else. + assert _safe_get_origin(Annotated[int, "doc"]) is int + assert _safe_get_origin(Annotated[str, "x", "y"]) is str + # Nested Annotated: unwrap recursively. + assert _safe_get_origin(Annotated[Annotated[int, "inner"], "outer"]) is int + # Bare types are unchanged. + assert _safe_get_origin(int) is int + assert _safe_get_origin(List[int]) is list diff --git a/test/utils/test_type_serialization.py b/test/utils/test_type_serialization.py index a8c5a2e6261..da74c710c6d 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, Callable, Deque, Dict, FrozenSet, List, Literal, Optional, Set, Tuple, Union +from typing import Annotated, Any, Callable, Deque, Dict, FrozenSet, List, Literal, Optional, Set, Tuple, Union import pytest @@ -509,3 +509,128 @@ def test_type_de_se_union_and_optional(): assert serialize_type(Optional[dict]) == "typing.Optional[dict]" assert serialize_type(Optional[float]) == "typing.Optional[float]" assert serialize_type(Optional[bool]) == "typing.Optional[bool]" + + +# `Annotated[T, m1, m2, ...]` holds a type T followed by metadata values. The metadata values are not +# types and must be rendered with repr() (so strings keep their quotes) and parsed with ast.literal_eval +# on the deserialize side. The split is quote-aware so a comma inside a string metadata value does not +# break the parse. Type-like metadata (classes, typing forms) is rendered through serialize_type and +# resolved through deserialize_type on the read side. This mirrors the Literal fix from PR #12286 +# (commit 1f460e620) and the Callable-with-parameter-list fix from PR #12122. +def test_output_type_serialization_annotated(): + # String metadata — the failing case before the fix (silently serialized as `typing.Annotated[int, doc]` + # and rejected on deserialize). + assert serialize_type(Annotated[int, "doc"]) == "typing.Annotated[int, 'doc']" + assert serialize_type(Annotated[str, "x", "y"]) == "typing.Annotated[str, 'x', 'y']" + # Non-string literal metadata (int, bool, None, bytes) — the repr() of these is a valid Python + # literal, so it round-trips through ast.literal_eval on the deserialize side. + assert serialize_type(Annotated[int, 42]) == "typing.Annotated[int, 42]" + assert serialize_type(Annotated[int, True]) == "typing.Annotated[int, True]" + assert serialize_type(Annotated[int, None]) == "typing.Annotated[int, None]" + assert serialize_type(Annotated[int, b"bytes"]) == "typing.Annotated[int, b'bytes']" + # A comma inside a string metadata value must be preserved verbatim, not split. + assert serialize_type(Annotated[int, "a, b"]) == "typing.Annotated[int, 'a, b']" + # Ellipsis metadata — `...` is serialized as the literal `...` (same as the top-level Ellipsis + # handling in serialize_type), so it round-trips through the existing Ellipsis handling on deserialize. + assert serialize_type(Annotated[int, ...]) == "typing.Annotated[int, ...]" + # Type metadata — a class is rendered with its module path (or bare name for builtins), not via repr. + # `repr(str)` would be `` which is not a valid Python literal and would not round-trip. + assert serialize_type(Annotated[int, str]) == "typing.Annotated[int, str]" + # A typing form as metadata is rendered as a type so nested generics and module paths are preserved. + assert serialize_type(Annotated[int, List[str]]) == "typing.Annotated[int, typing.List[str]]" + # A Literal as metadata is rendered as a type. + assert serialize_type(Annotated[int, Literal["a", "b"]]) == "typing.Annotated[int, typing.Literal['a', 'b']]" + # Nested in a generic — the inner Annotated is itself serialized through the Annotated branch. + assert serialize_type(List[Annotated[int, "tag"]]) == "typing.List[typing.Annotated[int, 'tag']]" + # Nested in an Optional — Python normalizes `Optional[X]` to `Union[X, None]`, so the wire form is + # `typing.Optional[typing.Annotated[...]]` (the trailing None is dropped, same as for any other generic). + assert serialize_type(Optional[Annotated[int, "doc"]]) == "typing.Optional[typing.Annotated[int, 'doc']]" + # The wrapped type can itself be a generic with a top-level comma (Callable's parameter list). + # The quote-aware split keeps the comma inside `Callable[[int, str], bool]` from being treated as + # the Annotated separator. + assert ( + serialize_type(Annotated[Callable[[int, str], bool], "doc"]) + == "typing.Annotated[typing.Callable[[int, str], bool], 'doc']" + ) + + +def test_output_type_deserialization_annotated(): + # String metadata round-trips. + assert deserialize_type("typing.Annotated[int, 'doc']") == Annotated[int, "doc"] + assert deserialize_type("typing.Annotated[str, 'x', 'y']") == Annotated[str, "x", "y"] + # Non-string literal metadata. + assert deserialize_type("typing.Annotated[int, 42]") == Annotated[int, 42] + assert deserialize_type("typing.Annotated[int, True]") == Annotated[int, True] + assert deserialize_type("typing.Annotated[int, None]") == Annotated[int, None] + assert deserialize_type("typing.Annotated[int, b'bytes']") == Annotated[int, b"bytes"] + # A comma inside a string metadata value must not split the args (quote-aware split). + assert deserialize_type("typing.Annotated[int, 'a, b']") == Annotated[int, "a, b"] + # Ellipsis metadata round-trips through the existing Ellipsis handling. + assert deserialize_type("typing.Annotated[int, ...]") == Annotated[int, ...] + # Type metadata — a class is resolved through the type-import path. + assert deserialize_type("typing.Annotated[int, str]") == Annotated[int, str] + # A typing form as metadata is resolved through deserialize_type. + assert deserialize_type("typing.Annotated[int, typing.List[str]]") == Annotated[int, List[str]] + # A Literal as metadata. + assert deserialize_type("typing.Annotated[int, typing.Literal['a', 'b']]") == Annotated[int, Literal["a", "b"]] + # Nested in a generic. + assert deserialize_type("typing.List[typing.Annotated[int, 'tag']]") == List[Annotated[int, "tag"]] + # Nested in an Optional. + assert deserialize_type("typing.Optional[typing.Annotated[int, 'doc']]") == Optional[Annotated[int, "doc"]] + # The wrapped type is a generic with a top-level comma (Callable's parameter list). + assert ( + deserialize_type("typing.Annotated[typing.Callable[[int, str], bool], 'doc']") + == Annotated[Callable[[int, str], bool], "doc"] + ) + + +def test_output_type_round_trip_annotated(): + # Round-trip all the kinds of metadata Python's Annotated accepts and that survive a text round-trip. + # Mirrors the existing Literal trio (serialization, deserialization, round_trip). + cases = [ + Annotated[int, "doc"], + Annotated[str, "x", "y"], + Annotated[int, 42], + Annotated[int, True], + Annotated[int, None], + Annotated[int, b"bytes"], + Annotated[int, "a, b"], # comma in string metadata — quote-aware split + Annotated[int, ...], # Ellipsis metadata + Annotated[int, str], # class metadata + Annotated[int, int], # class metadata (built-in) + Annotated[int, List[str]], # typing form metadata + Annotated[int, Literal["a", "b"]], # Literal as metadata + List[Annotated[int, "tag"]], # nested in a generic + Optional[Annotated[int, "doc"]], # nested in Optional + Annotated[Callable[[int, str], bool], "doc"], # wrapped type has a top-level comma + ] + for type_ in cases: + assert deserialize_type(serialize_type(type_)) == type_ + + +def test_split_annotated_args(): + # The split is on the first top-level comma (outside brackets and outside string literals), so a + # comma inside a string metadata value is not treated as the separator. + from haystack.utils.type_serialization import _split_annotated_args + + assert _split_annotated_args("int, 'doc'") == ("int", "'doc'") + assert _split_annotated_args("int, 'a, b'") == ("int", "'a, b'") + # The wrapped type can contain brackets and commas inside them (e.g. Callable's parameter list): + # the comma inside the brackets is at depth > 0 and is not the separator. + assert _split_annotated_args("typing.Callable[[int, str], bool], 'doc'") == ( + "typing.Callable[[int, str], bool]", + "'doc'", + ) + # Double-quoted metadata works the same way. + assert _split_annotated_args('int, "doc"') == ("int", '"doc"') + # No top-level comma: the whole string is the type and there is no metadata (defensive: Annotated + # always has metadata in practice, since Python normalizes the bare Annotated[T] form back to T). + assert _split_annotated_args("int") == ("int", "") + + +def test_output_type_deserialization_annotated_no_metadata_errors(): + # The wire form `typing.Annotated[int]` is not a valid Annotated (Python normalizes the bare form + # back to `int`), so an empty metadata chunk on the deserialize side is a hard error rather than a + # silent misshape. + with pytest.raises(DeserializationError, match="Annotated requires at least one metadata value"): + deserialize_type("typing.Annotated[int]")