diff --git a/haystack/core/type_utils.py b/haystack/core/type_utils.py index c789d6e73d..f008884aaa 100644 --- a/haystack/core/type_utils.py +++ b/haystack/core/type_utils.py @@ -2,9 +2,8 @@ # # SPDX-License-Identifier: Apache-2.0 -import collections.abc import inspect -from collections.abc import Callable +from collections.abc import Callable, Iterable from enum import Enum from types import NoneType, UnionType from typing import Any, Union, get_args, get_origin, get_type_hints @@ -159,24 +158,32 @@ def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool: # noqa: P sender_origin = _safe_get_origin(sender) receiver_origin = _safe_get_origin(receiver) + sender_args = get_args(sender) + receiver_args = get_args(receiver) # Special case to reject bare-Union types - if (sender_origin is Union and not get_args(sender)) or (receiver_origin is Union and not get_args(receiver)): + if (sender_origin is Union and not sender_args) or (receiver_origin is Union and not receiver_args): return False if sender_origin is not Union and receiver_origin is Union: - return any(_strict_types_are_compatible(sender, union_arg) for union_arg in get_args(receiver)) + return any(_strict_types_are_compatible(sender, union_arg) for union_arg in receiver_args) + + # Special case to allow list[T] -> Iterable[T] and list[T] -> Iterable[Any] + if sender_origin is list and receiver_origin is Iterable: + # If the receiver is a bare Iterable, we accept any list. + if not receiver_args: + return True + # If the receiver is Iterable[T], we require the sender to be list[T] for the same T. + if len(sender_args) != 1 or len(receiver_args) != 1: + return False + return _strict_types_are_compatible(sender_args[0], receiver_args[0]) # Both must have origins and they must be equal if not (sender_origin and receiver_origin and sender_origin == receiver_origin): return False - # Compare generic type arguments - sender_args = get_args(sender) - receiver_args = get_args(receiver) - # Handle Callable types - if sender_origin == receiver_origin == collections.abc.Callable: + if sender_origin == receiver_origin == Callable: return _check_callable_compatibility(sender_args, receiver_args) # Handle bare types diff --git a/releasenotes/notes/Allow-list-outputs-to-connect-to-iterable-inputs-85e3b10639ef6594.yaml b/releasenotes/notes/Allow-list-outputs-to-connect-to-iterable-inputs-85e3b10639ef6594.yaml new file mode 100644 index 0000000000..64e0f94d11 --- /dev/null +++ b/releasenotes/notes/Allow-list-outputs-to-connect-to-iterable-inputs-85e3b10639ef6594.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Allow ``Pipeline`` component outputs typed as ``list[T]`` to connect to inputs typed as ``Iterable[T]``. + Lists already satisfy the iterable input contract and are passed through without conversion. diff --git a/test/core/pipeline/test_pipeline_base.py b/test/core/pipeline/test_pipeline_base.py index 52777538b1..6006e68029 100644 --- a/test/core/pipeline/test_pipeline_base.py +++ b/test/core/pipeline/test_pipeline_base.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 import logging +from collections.abc import Iterable from typing import Any from unittest.mock import patch @@ -2225,6 +2226,25 @@ def test_connect_with_multiple_receiver_connections_with_same_type_and_differing with pytest.raises(PipelineConnectError): pipe.connect("comp1", "comp2") + def test_connect_list_output_to_iterable_input(self): + producer = component_class("Producer", output_types={"items": list[str]})() + consumer = component_class("Consumer", input_types={"sources": Iterable[str]})() + pipe = PipelineBase() + pipe.add_component("producer", producer) + pipe.add_component("consumer", consumer) + pipe.connect("producer.items", "consumer.sources") + assert list(pipe.graph.edges) == [("producer", "consumer", "items/sources")] + assert pipe.graph["producer"]["consumer"]["items/sources"]["conversion_strategy"] is None + + def test_connect_list_output_to_list_or_iterable_input_is_ambiguous(self): + producer = component_class("Producer", output_types={"value": list[str]})() + consumer = component_class("Consumer", input_types={"list_items": list[str], "iterable_items": Iterable[str]})() + pipe = PipelineBase() + pipe.add_component("producer", producer) + pipe.add_component("consumer", consumer) + with pytest.raises(PipelineConnectError, match="more than one connection is possible"): + pipe.connect("producer", "consumer") + def test_connect_with_multiple_sender_connections_with_same_type_and_same_name(self): comp1 = component_class("Comp1", output_types={"value": int, "other": int})() comp2 = component_class("Comp2", input_types={"value": int})() diff --git a/test/core/test_type_utils.py b/test/core/test_type_utils.py index ec102696d7..83642c45e0 100644 --- a/test/core/test_type_utils.py +++ b/test/core/test_type_utils.py @@ -488,6 +488,38 @@ def test_asymmetric_types_are_not_compatible_strict(sender_type, receiver_type): assert not _types_are_compatible(receiver_type, sender_type)[0] +@pytest.mark.parametrize( + "sender_type,receiver_type", + [ + pytest.param(List[int], Iterable[int], id="typing-list-to-iterable"), + pytest.param(list[int], Iterable[int], id="list-to-iterable"), + pytest.param( + list[str | Path | ByteStream], + Iterable[str | Path | ByteStream], + id="list-of-file-sources-to-iterable-of-file-sources", + ), + pytest.param(list[Class3], Iterable[Class1], id="list-of-subclass-to-iterable-of-superclass"), + pytest.param(list[int], Iterable[Any], id="list-to-iterable-of-any"), + pytest.param(list[int], Iterable, id="list-to-bare-iterable"), + ], +) +def test_list_is_compatible_with_iterable_strict(sender_type, receiver_type): + assert _types_are_compatible(sender_type, receiver_type) == (True, None) + + +@pytest.mark.parametrize( + "sender_type,receiver_type", + [ + pytest.param(list[str], Iterable[int], id="list-to-iterable-with-incompatible-item-types"), + pytest.param(list[Any], Iterable[int], id="list-of-any-to-typed-iterable"), + pytest.param(list, Iterable[int], id="bare-list-to-typed-iterable"), + pytest.param(Iterable[int], list[int], id="iterable-to-list"), + ], +) +def test_list_and_iterable_are_not_compatible_strict(sender_type, receiver_type): + assert _types_are_compatible(sender_type, receiver_type) == (False, None) + + incompatible_type_cases = [ pytest.param(Tuple[int, str], Tuple[Any], id="tuple-of-primitive-to-tuple-of-any-different-lengths"), pytest.param(tuple[int, str], tuple[Any], id="tuple-of-primitive-to-tuple-of-any-different-lengths"),