From b0adbb4ab20ee5f67af9582d38a05cce7f7d25d4 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Mon, 24 Aug 2026 09:04:16 +0200 Subject: [PATCH 1/4] allow list[T] to Iterable[T] connections --- haystack/core/type_utils.py | 11 ++++++++ ...t-to-iterable-inputs-85e3b10639ef6594.yaml | 5 ++++ test/core/pipeline/test_pipeline_base.py | 23 ++++++++++++++++ test/core/test_type_utils.py | 27 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 releasenotes/notes/Allow-list-outputs-to-connect-to-iterable-inputs-85e3b10639ef6594.yaml diff --git a/haystack/core/type_utils.py b/haystack/core/type_utils.py index c789d6e73d..4c9688de11 100644 --- a/haystack/core/type_utils.py +++ b/haystack/core/type_utils.py @@ -167,6 +167,17 @@ def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool: # noqa: P 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)) + # A list output can be passed unchanged to a component accepting an Iterable. Keep this directional: an + # Iterable output does not guarantee that the runtime value is a list. + if sender_origin is list and receiver_origin is collections.abc.Iterable: + sender_args = get_args(sender) + receiver_args = get_args(receiver) + if not receiver_args: + return True + 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 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..0b4b8ab704 --- /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..6c4000fdfe 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,28 @@ 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..e2651f3f9e 100644 --- a/test/core/test_type_utils.py +++ b/test/core/test_type_utils.py @@ -488,6 +488,33 @@ 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[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"), From 3f3b4bbdbf958649a47a6161879100d3b029bd05 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Mon, 24 Aug 2026 09:07:43 +0200 Subject: [PATCH 2/4] updates --- ...tputs-to-connect-to-iterable-inputs-85e3b10639ef6594.yaml | 2 +- test/core/test_type_utils.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 index 0b4b8ab704..64e0f94d11 100644 --- 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 @@ -1,5 +1,5 @@ --- fixes: - | - Allow Pipeline component outputs typed as ``list[T]`` to connect to inputs typed as ``Iterable[T]``. + 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/test_type_utils.py b/test/core/test_type_utils.py index e2651f3f9e..83642c45e0 100644 --- a/test/core/test_type_utils.py +++ b/test/core/test_type_utils.py @@ -493,6 +493,11 @@ def test_asymmetric_types_are_not_compatible_strict(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"), From 8313f07145f047b36a77594b71e2cf5893a38ad8 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Mon, 24 Aug 2026 09:39:47 +0200 Subject: [PATCH 3/4] slight refactor and add dev comments --- haystack/core/type_utils.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/haystack/core/type_utils.py b/haystack/core/type_utils.py index 4c9688de11..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,21 +158,22 @@ 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) - # A list output can be passed unchanged to a component accepting an Iterable. Keep this directional: an - # Iterable output does not guarantee that the runtime value is a list. - if sender_origin is list and receiver_origin is collections.abc.Iterable: - sender_args = get_args(sender) - receiver_args = get_args(receiver) + # 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]) @@ -182,12 +182,8 @@ def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool: # noqa: P 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 From 306a032026bf7486f5896c8eadb76bb3f6e69fe7 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Mon, 24 Aug 2026 09:59:49 +0200 Subject: [PATCH 4/4] update test file --- test/core/pipeline/test_pipeline_base.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/core/pipeline/test_pipeline_base.py b/test/core/pipeline/test_pipeline_base.py index 6c4000fdfe..6006e68029 100644 --- a/test/core/pipeline/test_pipeline_base.py +++ b/test/core/pipeline/test_pipeline_base.py @@ -2232,9 +2232,7 @@ def test_connect_list_output_to_iterable_input(self): 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 @@ -2244,7 +2242,6 @@ def test_connect_list_output_to_list_or_iterable_input_is_ambiguous(self): 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")