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
25 changes: 16 additions & 9 deletions haystack/core/type_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions test/core/pipeline/test_pipeline_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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})()
Expand Down
32 changes: 32 additions & 0 deletions test/core/test_type_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading