chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .super_component import SuperComponent
|
||||
|
||||
__all__ = ["SuperComponent"]
|
||||
@@ -0,0 +1,8 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from haystack.core.component.component import Component, component
|
||||
from haystack.core.component.types import InputSocket, OutputSocket
|
||||
|
||||
__all__ = ["component", "Component", "InputSocket", "OutputSocket"]
|
||||
@@ -0,0 +1,644 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""
|
||||
Attributes:
|
||||
|
||||
component: Marks a class as a component. Any class decorated with `@component` can be used by a Pipeline.
|
||||
|
||||
All components must follow the contract below. This docstring is the source of truth for components contract.
|
||||
|
||||
<hr>
|
||||
|
||||
`@component` decorator
|
||||
|
||||
All component classes must be decorated with the `@component` decorator. This allows Haystack to discover them.
|
||||
|
||||
<hr>
|
||||
|
||||
`__init__(self, **kwargs)`
|
||||
|
||||
Optional method.
|
||||
|
||||
Components may have an `__init__` method where they define:
|
||||
|
||||
- `self.init_parameters = {same parameters that the __init__ method received}`:
|
||||
In this dictionary you can store any state the components wish to be persisted when they are saved.
|
||||
These values will be given to the `__init__` method of a new instance when the pipeline is loaded.
|
||||
Note that by default the `@component` decorator saves the arguments automatically.
|
||||
However, if a component sets their own `init_parameters` manually in `__init__()`, that will be used instead.
|
||||
Note: all of the values contained here **must be JSON serializable**. Serialize them manually if needed.
|
||||
|
||||
Components should take only "basic" Python types as parameters of their `__init__` function, or iterables and
|
||||
dictionaries containing only such values. Anything else (objects, functions, etc) will raise an exception at init
|
||||
time. If there's the need for such values, consider serializing them to a string.
|
||||
|
||||
If you need to accept classes or callables, accept either a string import path or the callable itself. Resolve strings
|
||||
to objects in `__init__`, and serialize objects back to importable strings in `to_dict()` so that `from_dict()` can load
|
||||
them (for example, store `"module_path.symbol_name"` and load it via `importlib`). This keeps init parameters JSON
|
||||
serializable for pipeline save/load. See `haystack.testing.sample_components.accumulate.Accumulate` for a reference
|
||||
implementation.
|
||||
|
||||
The `__init__` must be extremely lightweight, because it's a frequent operation during the construction and
|
||||
validation of the pipeline. If a component has some heavy state to initialize (models, backends, etc...) refer to
|
||||
the `warm_up()` method.
|
||||
|
||||
<hr>
|
||||
|
||||
`warm_up(self)`
|
||||
|
||||
Optional method.
|
||||
|
||||
This method is called by Pipeline before the graph execution. Make sure to avoid double-initializations,
|
||||
because Pipeline will not keep track of which components it called `warm_up()` on.
|
||||
|
||||
<hr>
|
||||
|
||||
`run(self, data)`
|
||||
|
||||
Mandatory method.
|
||||
|
||||
This is the method where the main functionality of the component should be carried out. It's called by
|
||||
`Pipeline.run()`.
|
||||
|
||||
When the component should run, Pipeline will call this method with an instance of the dataclass returned by the
|
||||
method decorated with `@component.input`. This dataclass contains:
|
||||
|
||||
- all the input values coming from other components connected to it,
|
||||
- if any is missing, the corresponding value defined in `self.defaults`, if it exists.
|
||||
|
||||
`run()` must return a single instance of the dataclass declared through the method decorated with
|
||||
`@component.output`.
|
||||
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
from collections.abc import Callable, Coroutine, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from types import new_class
|
||||
from typing import Any, ParamSpec, Protocol, TypeVar, overload, runtime_checkable
|
||||
|
||||
from haystack import logging
|
||||
from haystack.core.errors import ComponentError
|
||||
|
||||
from .sockets import Sockets
|
||||
from .types import InputSocket, OutputSocket, _empty
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RunParamsT = ParamSpec("RunParamsT")
|
||||
RunReturnT = TypeVar("RunReturnT", bound=Mapping[str, Any] | Coroutine[Any, Any, Mapping[str, Any]])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreInitHookPayload:
|
||||
"""
|
||||
Payload for the hook called before a component instance is initialized.
|
||||
|
||||
:param callback:
|
||||
Receives the following inputs: component class and init parameter keyword args.
|
||||
:param in_progress:
|
||||
Flag to indicate if the hook is currently being executed.
|
||||
Used to prevent it from being called recursively (if the component's constructor
|
||||
instantiates another component).
|
||||
"""
|
||||
|
||||
callback: Callable
|
||||
in_progress: bool = False
|
||||
|
||||
|
||||
_COMPONENT_PRE_INIT_HOOK: ContextVar[PreInitHookPayload | None] = ContextVar("component_pre_init_hook", default=None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _hook_component_init(callback: Callable) -> Iterator[None]:
|
||||
"""
|
||||
Context manager to set a callback that will be invoked before a component's constructor is called.
|
||||
|
||||
The callback receives the component class and the init parameters (as keyword arguments) and can modify the init
|
||||
parameters in place.
|
||||
|
||||
:param callback:
|
||||
Callback function to invoke.
|
||||
"""
|
||||
token = _COMPONENT_PRE_INIT_HOOK.set(PreInitHookPayload(callback))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_COMPONENT_PRE_INIT_HOOK.reset(token)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Component(Protocol):
|
||||
"""
|
||||
Note this is only used by type checking tools.
|
||||
|
||||
In order to implement the `Component` protocol, custom components need to
|
||||
have a `run` method. The signature of the method and its return value
|
||||
won't be checked, i.e. classes with the following methods:
|
||||
|
||||
def run(self, param: str) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
and
|
||||
|
||||
def run(self, **kwargs):
|
||||
...
|
||||
|
||||
will be both considered as respecting the protocol. This makes the type
|
||||
checking much weaker, but we have other places where we ensure code is
|
||||
dealing with actual Components.
|
||||
|
||||
The protocol is runtime checkable so it'll be possible to assert:
|
||||
|
||||
isinstance(MyComponent, Component)
|
||||
"""
|
||||
|
||||
# The following expression defines a run method compatible with any input signature.
|
||||
# Its type is equivalent to Callable[..., dict[str, Any]].
|
||||
# See https://typing.python.org/en/latest/spec/callables.html#meaning-of-in-callable.
|
||||
#
|
||||
# Using `run: Callable[..., dict[str, Any]]` directly leads to type errors: the protocol would expect a settable
|
||||
# attribute `run`, while the actual implementation is a read-only method.
|
||||
# For example:
|
||||
# from haystack import Pipeline, component
|
||||
# @component
|
||||
# class MyComponent:
|
||||
# @component.output_types(out=str)
|
||||
# def run(self):
|
||||
# return {"out": "Hello, world!"}
|
||||
# pipeline = Pipeline()
|
||||
# pipeline.add_component("my_component", MyComponent())
|
||||
#
|
||||
# mypy raises:
|
||||
# error: Argument 2 to "add_component" of "PipelineBase" has incompatible type "MyComponent"; expected "Component"
|
||||
# [arg-type]
|
||||
# note: Protocol member Component.run expected settable variable, got read-only attribute
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> Mapping[str, Any]: # noqa: D102
|
||||
...
|
||||
|
||||
|
||||
class ComponentMeta(type):
|
||||
@staticmethod
|
||||
def _positional_to_kwargs(cls_type: type, args: tuple[Any, ...]) -> dict[str, Any]:
|
||||
"""
|
||||
Convert positional arguments to keyword arguments based on the signature of the `__init__` method.
|
||||
"""
|
||||
init_signature = inspect.signature(cls_type.__init__) # type:ignore[misc]
|
||||
init_params = {name: info for name, info in init_signature.parameters.items() if name != "self"}
|
||||
|
||||
out = {}
|
||||
for arg, (name, info) in zip(args, init_params.items(), strict=False):
|
||||
if info.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||
raise ComponentError(
|
||||
"Pre-init hooks do not support components with variadic positional args in their init method"
|
||||
)
|
||||
|
||||
assert info.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY)
|
||||
out[name] = arg
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _parse_and_set_output_sockets(instance: Any) -> None:
|
||||
has_async_run = hasattr(instance, "run_async")
|
||||
|
||||
# If `component.set_output_types()` was called in the component constructor,
|
||||
# `__haystack_output__` is already populated, no need to do anything.
|
||||
if not hasattr(instance, "__haystack_output__"):
|
||||
# If that's not the case, we need to populate `__haystack_output__`
|
||||
#
|
||||
# If either of the run methods were decorated, they'll have a field assigned that
|
||||
# stores the output specification. If both run methods were decorated, we ensure that
|
||||
# outputs are the same. We deepcopy the content of the cache to transfer ownership from
|
||||
# the class method to the actual instance, so that different instances of the same class
|
||||
# won't share this data.
|
||||
|
||||
run_output_types = getattr(instance.run, "_output_types_cache", {})
|
||||
async_run_output_types = getattr(instance.run_async, "_output_types_cache", {}) if has_async_run else {}
|
||||
|
||||
if has_async_run and run_output_types != async_run_output_types:
|
||||
raise ComponentError("Output type specifications of 'run' and 'run_async' methods must be the same")
|
||||
output_types_cache = run_output_types
|
||||
|
||||
instance.__haystack_output__ = Sockets(instance, deepcopy(output_types_cache), OutputSocket)
|
||||
|
||||
@staticmethod
|
||||
def _parse_and_set_input_sockets(component_cls: type, instance: Any) -> None:
|
||||
def inner(method: Callable[..., Any], sockets: Sockets) -> inspect.Signature:
|
||||
from inspect import Parameter
|
||||
|
||||
run_signature = inspect.signature(method)
|
||||
try:
|
||||
# TypeError is raised if the argument is not of a type that can contain annotations
|
||||
run_hints = typing.get_type_hints(method)
|
||||
except TypeError:
|
||||
run_hints = None
|
||||
|
||||
for param_name, param_info in run_signature.parameters.items():
|
||||
if param_name == "self" or param_info.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
|
||||
# We prefer the type annotation from inspect.signature, but if it's a string we need to resolve it
|
||||
# using the hints. The type annotation can be a string if the component is using postponed evaluation
|
||||
# of annotations.
|
||||
annotation = param_info.annotation
|
||||
if isinstance(annotation, str) and run_hints is not None:
|
||||
annotation = run_hints.get(param_name, annotation)
|
||||
|
||||
socket_kwargs = {"name": param_name, "type": annotation}
|
||||
if param_info.default != Parameter.empty:
|
||||
socket_kwargs["default_value"] = param_info.default
|
||||
|
||||
new_socket = InputSocket(**socket_kwargs)
|
||||
|
||||
# Also ensure that new sockets don't override existing ones.
|
||||
existing_socket = sockets.get(param_name)
|
||||
if existing_socket is not None and existing_socket != new_socket:
|
||||
raise ComponentError(
|
||||
"set_input_types()/set_input_type() cannot override the parameters of the 'run' method"
|
||||
)
|
||||
|
||||
sockets[param_name] = new_socket
|
||||
|
||||
return run_signature
|
||||
|
||||
# Create the sockets if set_input_types() wasn't called in the constructor.
|
||||
if not hasattr(instance, "__haystack_input__"):
|
||||
instance.__haystack_input__ = Sockets(instance, {}, InputSocket)
|
||||
|
||||
inner(getattr(component_cls, "run"), instance.__haystack_input__) # noqa: B009
|
||||
|
||||
# Ensure that the sockets are the same for the async method, if it exists.
|
||||
async_run = getattr(component_cls, "run_async", None)
|
||||
if async_run is not None:
|
||||
run_sockets = Sockets(instance, {}, InputSocket)
|
||||
async_run_sockets = Sockets(instance, {}, InputSocket)
|
||||
|
||||
# Can't use the sockets from above as they might contain
|
||||
# values set with set_input_types().
|
||||
run_sig = inner(getattr(component_cls, "run"), run_sockets) # noqa: B009
|
||||
async_run_sig = inner(async_run, async_run_sockets)
|
||||
|
||||
if async_run_sockets != run_sockets or run_sig != async_run_sig:
|
||||
sig_diff = _compare_run_methods_signatures(run_sig, async_run_sig)
|
||||
raise ComponentError(
|
||||
f"Parameters of 'run' and 'run_async' methods must be the same.\nDifferences found:\n{sig_diff}"
|
||||
)
|
||||
|
||||
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
"""
|
||||
This method is called when clients instantiate a Component and runs before __new__ and __init__.
|
||||
"""
|
||||
# This will call __new__ then __init__, giving us back the Component instance
|
||||
pre_init_hook = _COMPONENT_PRE_INIT_HOOK.get()
|
||||
if pre_init_hook is None or pre_init_hook.in_progress:
|
||||
instance = super().__call__(*args, **kwargs)
|
||||
else:
|
||||
try:
|
||||
pre_init_hook.in_progress = True
|
||||
named_positional_args = ComponentMeta._positional_to_kwargs(cls, args)
|
||||
assert set(named_positional_args.keys()).intersection(kwargs.keys()) == set(), (
|
||||
"positional and keyword arguments overlap"
|
||||
)
|
||||
kwargs.update(named_positional_args)
|
||||
pre_init_hook.callback(cls, kwargs)
|
||||
instance = super().__call__(**kwargs)
|
||||
finally:
|
||||
pre_init_hook.in_progress = False
|
||||
|
||||
# Before returning, we have the chance to modify the newly created
|
||||
# Component instance, so we take the chance and set up the I/O sockets
|
||||
has_async_run = hasattr(instance, "run_async")
|
||||
if has_async_run and not inspect.iscoroutinefunction(instance.run_async):
|
||||
raise ComponentError(f"Method 'run_async' of component '{cls.__name__}' must be a coroutine")
|
||||
instance.__haystack_supports_async__ = has_async_run
|
||||
|
||||
ComponentMeta._parse_and_set_input_sockets(cls, instance)
|
||||
ComponentMeta._parse_and_set_output_sockets(instance)
|
||||
|
||||
# Since a Component can't be used in multiple Pipelines at the same time
|
||||
# we need to know if it's already owned by a Pipeline when adding it to one.
|
||||
# We use this flag to check that.
|
||||
instance.__haystack_added_to_pipeline__ = None
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
def _component_repr(component: Component) -> str:
|
||||
"""
|
||||
All Components override their __repr__ method with this one.
|
||||
|
||||
It prints the component name and the input/output sockets.
|
||||
"""
|
||||
result = object.__repr__(component)
|
||||
if pipeline := getattr(component, "__haystack_added_to_pipeline__", None):
|
||||
# This Component has been added in a Pipeline, let's get the name from there.
|
||||
result += f"\n{pipeline.get_component_name(component)}"
|
||||
|
||||
# We're explicitly ignoring the type here because we're sure that the component
|
||||
# has the __haystack_input__ and __haystack_output__ attributes at this point
|
||||
return (
|
||||
f"{result}\n{getattr(component, '__haystack_input__', '<invalid_input_sockets>')}"
|
||||
f"\n{getattr(component, '__haystack_output__', '<invalid_output_sockets>')}"
|
||||
)
|
||||
|
||||
|
||||
def _component_run_has_kwargs(component_cls: type) -> bool:
|
||||
run_method = getattr(component_cls, "run", None)
|
||||
if run_method is None:
|
||||
return False
|
||||
return any(
|
||||
param.kind == inspect.Parameter.VAR_KEYWORD for param in inspect.signature(run_method).parameters.values()
|
||||
)
|
||||
|
||||
|
||||
def _compare_run_methods_signatures(run_sig: inspect.Signature, async_run_sig: inspect.Signature) -> str:
|
||||
"""
|
||||
Builds a detailed error message with the differences between the signatures of the run and run_async methods.
|
||||
|
||||
:param run_sig: The signature of the run method
|
||||
:param async_run_sig: The signature of the run_async method
|
||||
|
||||
:returns:
|
||||
A detailed error message if signatures don't match, empty string if they do
|
||||
"""
|
||||
differences = []
|
||||
run_params = list(run_sig.parameters.items())
|
||||
async_params = list(async_run_sig.parameters.items())
|
||||
|
||||
if len(run_params) != len(async_params):
|
||||
differences.append(
|
||||
f"Different number of parameters: run has {len(run_params)}, run_async has {len(async_params)}"
|
||||
)
|
||||
|
||||
for (run_name, run_param), (async_name, async_param) in zip(run_params, async_params, strict=False):
|
||||
if run_name != async_name:
|
||||
differences.append(f"Parameter name mismatch: {run_name} vs {async_name}")
|
||||
|
||||
if run_param.annotation != async_param.annotation:
|
||||
differences.append(
|
||||
f"Parameter '{run_name}' type mismatch: {run_param.annotation} vs {async_param.annotation}"
|
||||
)
|
||||
|
||||
if run_param.default != async_param.default:
|
||||
differences.append(
|
||||
f"Parameter '{run_name}' default value mismatch: {run_param.default} vs {async_param.default}"
|
||||
)
|
||||
|
||||
if run_param.kind != async_param.kind:
|
||||
differences.append(
|
||||
f"Parameter '{run_name}' kind (POSITIONAL, KEYWORD, etc.) mismatch: "
|
||||
f"{run_param.kind} vs {async_param.kind}"
|
||||
)
|
||||
|
||||
return "\n".join(differences)
|
||||
|
||||
|
||||
T = TypeVar("T", bound=Component)
|
||||
|
||||
|
||||
class _Component:
|
||||
"""
|
||||
See module's docstring.
|
||||
|
||||
Args:
|
||||
cls: the class that should be used as a component.
|
||||
|
||||
Returns:
|
||||
A class that can be recognized as a component.
|
||||
|
||||
Raises:
|
||||
ComponentError: if the class provided has no `run()` method or otherwise doesn't respect the component contract.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.registry: dict[str, type] = {}
|
||||
|
||||
def set_input_type(
|
||||
self,
|
||||
instance: Component,
|
||||
name: str,
|
||||
type: Any, # noqa: A002
|
||||
default: Any = _empty,
|
||||
) -> None:
|
||||
"""
|
||||
Add a single input socket to the component instance.
|
||||
|
||||
Replaces any existing input socket with the same name.
|
||||
|
||||
:param instance: Component instance where the input type will be added.
|
||||
:param name: name of the input socket.
|
||||
:param type: type of the input socket.
|
||||
:param default: default value of the input socket, defaults to _empty
|
||||
"""
|
||||
if not _component_run_has_kwargs(instance.__class__):
|
||||
raise ComponentError(
|
||||
"Cannot set input types on a component that doesn't have a kwargs parameter in the 'run' method"
|
||||
)
|
||||
|
||||
if not hasattr(instance, "__haystack_input__"):
|
||||
instance.__haystack_input__ = Sockets(instance, {}, InputSocket) # type: ignore
|
||||
instance.__haystack_input__[name] = InputSocket(name=name, type=type, default_value=default) # type: ignore
|
||||
|
||||
def set_input_types(self, instance: Any, **types: type[Any]) -> None:
|
||||
"""
|
||||
Method that specifies the input types when 'kwargs' is passed to the run method.
|
||||
|
||||
Use as:
|
||||
|
||||
```python
|
||||
@component
|
||||
class MyComponent:
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
component.set_input_types(self, value_1=str, value_2=str)
|
||||
...
|
||||
|
||||
@component.output_types(output_1=int, output_2=str)
|
||||
def run(self, **kwargs):
|
||||
return {"output_1": kwargs["value_1"], "output_2": ""}
|
||||
```
|
||||
|
||||
Note that if the `run()` method also specifies some parameters, those will take precedence.
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
@component
|
||||
class MyComponent:
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
component.set_input_types(self, value_1=str, value_2=str)
|
||||
...
|
||||
|
||||
@component.output_types(output_1=int, output_2=str)
|
||||
def run(self, value_0: str, value_1: Optional[str] = None, **kwargs):
|
||||
return {"output_1": kwargs["value_1"], "output_2": ""}
|
||||
```
|
||||
|
||||
would add a mandatory `value_0` parameters, make the `value_1`
|
||||
parameter optional with a default None, and keep the `value_2`
|
||||
parameter mandatory as specified in `set_input_types`.
|
||||
|
||||
"""
|
||||
if not _component_run_has_kwargs(instance.__class__):
|
||||
raise ComponentError(
|
||||
"Cannot set input types on a component that doesn't have a kwargs parameter in the 'run' method"
|
||||
)
|
||||
|
||||
instance.__haystack_input__ = Sockets(
|
||||
instance, {name: InputSocket(name=name, type=type_) for name, type_ in types.items()}, InputSocket
|
||||
)
|
||||
|
||||
def set_output_types(self, instance: Any, **types: type[Any]) -> None:
|
||||
"""
|
||||
Method that specifies the output types when the 'run' method is not decorated with 'component.output_types'.
|
||||
|
||||
Use as:
|
||||
|
||||
```python
|
||||
@component
|
||||
class MyComponent:
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
component.set_output_types(self, output_1=int, output_2=str)
|
||||
...
|
||||
|
||||
# no decorators here
|
||||
def run(self, value: int):
|
||||
return {"output_1": 1, "output_2": "2"}
|
||||
|
||||
# also no decorators here
|
||||
async def run_async(self, value: int):
|
||||
return {"output_1": 1, "output_2": "2"}
|
||||
```
|
||||
"""
|
||||
has_run_decorator = hasattr(instance.run, "_output_types_cache")
|
||||
has_run_async_decorator = hasattr(instance, "run_async") and hasattr(instance.run_async, "_output_types_cache")
|
||||
if has_run_decorator or has_run_async_decorator:
|
||||
raise ComponentError(
|
||||
"Cannot call `set_output_types` on a component that already has the 'output_types' decorator on its "
|
||||
"`run` or `run_async` methods."
|
||||
)
|
||||
|
||||
instance.__haystack_output__ = Sockets(
|
||||
instance, {name: OutputSocket(name=name, type=type_) for name, type_ in types.items()}, OutputSocket
|
||||
)
|
||||
|
||||
def output_types(
|
||||
self, **types: Any
|
||||
) -> Callable[[Callable[RunParamsT, RunReturnT]], Callable[RunParamsT, RunReturnT]]:
|
||||
"""
|
||||
Decorator factory that specifies the output types of a component.
|
||||
|
||||
Use as:
|
||||
```python
|
||||
@component
|
||||
class MyComponent:
|
||||
@component.output_types(output_1=int, output_2=str)
|
||||
def run(self, value: int):
|
||||
return {"output_1": 1, "output_2": "2"}
|
||||
```
|
||||
"""
|
||||
|
||||
def output_types_decorator(run_method: Callable[RunParamsT, RunReturnT]) -> Callable[RunParamsT, RunReturnT]:
|
||||
"""
|
||||
Decorator that sets the output types of the decorated method.
|
||||
|
||||
This happens at class creation time, and since we don't have the decorated
|
||||
class available here, we temporarily store the output types as an attribute of
|
||||
the decorated method. The ComponentMeta metaclass will use this data to create
|
||||
sockets at instance creation time.
|
||||
"""
|
||||
method_name = run_method.__name__
|
||||
if method_name not in ("run", "run_async"):
|
||||
raise ComponentError("'output_types' decorator can only be used on 'run' and 'run_async' methods")
|
||||
|
||||
setattr( # noqa: B010
|
||||
run_method,
|
||||
"_output_types_cache",
|
||||
{name: OutputSocket(name=name, type=type_) for name, type_ in types.items()},
|
||||
)
|
||||
return run_method
|
||||
|
||||
return output_types_decorator
|
||||
|
||||
def _component(self, cls: type[T]) -> type[T]:
|
||||
"""
|
||||
Decorator validating the structure of the component and registering it in the components registry.
|
||||
"""
|
||||
logger.debug("Registering {component} as a component", component=cls)
|
||||
|
||||
# Check for required methods and fail as soon as possible
|
||||
if not hasattr(cls, "run"):
|
||||
raise ComponentError(f"{cls.__name__} must have a 'run()' method. See the docs for more information.")
|
||||
|
||||
def copy_class_namespace(namespace: dict[str, Any]) -> None:
|
||||
"""
|
||||
This is the callback that `typing.new_class` will use to populate the newly created class.
|
||||
|
||||
Simply copy the whole namespace from the decorated class.
|
||||
"""
|
||||
for key, val in dict(cls.__dict__).items():
|
||||
# __dict__ and __weakref__ are class-bound, we should let Python recreate them.
|
||||
if key in ("__dict__", "__weakref__"):
|
||||
continue
|
||||
namespace[key] = val
|
||||
|
||||
# Recreate the decorated component class so it uses our metaclass.
|
||||
# We must explicitly redefine the type of the class to make sure language servers
|
||||
# and type checkers understand that the class is of the correct type.
|
||||
new_cls: type[T] = new_class(cls.__name__, cls.__bases__, {"metaclass": ComponentMeta}, copy_class_namespace)
|
||||
|
||||
# Save the component in the class registry (for deserialization)
|
||||
class_path = f"{new_cls.__module__}.{new_cls.__name__}"
|
||||
if class_path in self.registry:
|
||||
# Corner case, but it may occur easily in notebooks when re-running cells.
|
||||
logger.debug(
|
||||
"Component {component} is already registered. Previous imported from '{module_name}', \
|
||||
new imported from '{new_module_name}'",
|
||||
component=class_path,
|
||||
module_name=self.registry[class_path],
|
||||
new_module_name=new_cls,
|
||||
)
|
||||
self.registry[class_path] = new_cls
|
||||
logger.debug("Registered Component {component}", component=new_cls)
|
||||
|
||||
# Override the __repr__ method with a default one
|
||||
# mypy is not happy that:
|
||||
# 1) we are assigning a method to a class
|
||||
# 2) _component_repr has a different type (Callable[[Component], str]) than the expected
|
||||
# __repr__ method (Callable[[object], str])
|
||||
new_cls.__repr__ = _component_repr # type: ignore[assignment]
|
||||
|
||||
return new_cls
|
||||
|
||||
# Call signature when the decorator is used without parens (@component).
|
||||
@overload
|
||||
def __call__(self, cls: type[T]) -> type[T]: ...
|
||||
|
||||
# Overload allowing the decorator to be used with parens (@component()).
|
||||
@overload
|
||||
def __call__(self) -> Callable[[type[T]], type[T]]: ...
|
||||
|
||||
def __call__(self, cls: type[T] | None = None) -> type[T] | Callable[[type[T]], type[T]]:
|
||||
# We must wrap the call to the decorator in a function for it to work
|
||||
# correctly with or without parens
|
||||
def wrap(cls: type[T]) -> type[T]:
|
||||
return self._component(cls)
|
||||
|
||||
if cls:
|
||||
# Decorator is called without parens
|
||||
return wrap(cls)
|
||||
|
||||
# Decorator is called with parens
|
||||
return wrap
|
||||
|
||||
|
||||
component = _Component()
|
||||
@@ -0,0 +1,143 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack.core.type_utils import _type_name
|
||||
|
||||
from .types import InputSocket, OutputSocket
|
||||
|
||||
SocketsDict = dict[str, InputSocket | OutputSocket]
|
||||
SocketsIOType = type[InputSocket] | type[OutputSocket]
|
||||
|
||||
|
||||
class Sockets: # noqa: PLW1641
|
||||
"""
|
||||
Represents the inputs or outputs of a `Component`.
|
||||
|
||||
Depending on the type passed to the constructor, it will represent either the inputs or the outputs of
|
||||
the `Component`.
|
||||
|
||||
Usage:
|
||||
```python
|
||||
from typing import Any
|
||||
from haystack.components.builders.prompt_builder import PromptBuilder
|
||||
from haystack.core.component.sockets import Sockets
|
||||
from haystack.core.component.types import InputSocket, OutputSocket
|
||||
|
||||
|
||||
prompt_template = \"""
|
||||
Given these documents, answer the question.\nDocuments:
|
||||
{% for doc in documents %}
|
||||
{{ doc.content }}
|
||||
{% endfor %}
|
||||
|
||||
\nQuestion: {{question}}
|
||||
\nAnswer:
|
||||
\"""
|
||||
|
||||
prompt_builder = PromptBuilder(template=prompt_template)
|
||||
sockets = {"question": InputSocket("question", Any), "documents": InputSocket("documents", Any)}
|
||||
inputs = Sockets(component=prompt_builder, sockets_dict=sockets, sockets_io_type=InputSocket)
|
||||
inputs
|
||||
# >> Inputs:
|
||||
# >> - question: Any
|
||||
# >> - documents: Any
|
||||
|
||||
inputs.question
|
||||
# >> InputSocket(name='question', type=typing.Any, default_value=<class 'haystack.core.component.types._empty'>, ...
|
||||
```
|
||||
"""
|
||||
|
||||
# We're using a forward declaration here to avoid a circular import.
|
||||
def __init__(
|
||||
self,
|
||||
component: "Component", # type: ignore[name-defined] # noqa: F821
|
||||
sockets_dict: SocketsDict,
|
||||
sockets_io_type: SocketsIOType,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new Sockets object.
|
||||
|
||||
We don't do any enforcement on the types of the sockets here, the `sockets_type` is only used for
|
||||
the `__repr__` method.
|
||||
We could do without it and use the type of a random value in the `sockets` dict, but that wouldn't
|
||||
work for components that have no sockets at all. Either input or output.
|
||||
|
||||
:param component:
|
||||
The component that these sockets belong to.
|
||||
:param sockets_dict:
|
||||
A dictionary of sockets.
|
||||
:param sockets_io_type:
|
||||
The type of the sockets.
|
||||
"""
|
||||
self._sockets_io_type = sockets_io_type
|
||||
self._component = component
|
||||
self._sockets_dict = sockets_dict
|
||||
self.__dict__.update(sockets_dict)
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
if not isinstance(value, Sockets):
|
||||
return False
|
||||
|
||||
return (
|
||||
self._sockets_io_type == value._sockets_io_type
|
||||
and self._component == value._component
|
||||
and self._sockets_dict == value._sockets_dict
|
||||
)
|
||||
|
||||
def __setitem__(self, key: str, socket: InputSocket | OutputSocket) -> None:
|
||||
"""
|
||||
Adds a new socket to this Sockets object.
|
||||
|
||||
This eases a bit updating the list of sockets after Sockets has been created.
|
||||
That should happen only in the `component` decorator.
|
||||
"""
|
||||
self._sockets_dict[key] = socket
|
||||
self.__dict__[key] = socket
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self._sockets_dict
|
||||
|
||||
def get(self, key: str, default: InputSocket | OutputSocket | None = None) -> InputSocket | OutputSocket | None:
|
||||
"""
|
||||
Get a socket from the Sockets object.
|
||||
|
||||
:param key:
|
||||
The name of the socket to get.
|
||||
:param default:
|
||||
The value to return if the key is not found.
|
||||
:returns:
|
||||
The socket with the given key or `default` if the key is not found.
|
||||
"""
|
||||
return self._sockets_dict.get(key, default)
|
||||
|
||||
def _component_name(self) -> str:
|
||||
if pipeline := self._component.__haystack_added_to_pipeline__:
|
||||
# This Component has been added in a Pipeline, let's get the name from there.
|
||||
return pipeline.get_component_name(self._component)
|
||||
|
||||
# This Component has not been added to a Pipeline yet, so we can't know its name.
|
||||
# Let's use default __repr__. We don't call repr() directly as Components have a custom
|
||||
# __repr__ method and that would lead to infinite recursion since we call Sockets.__repr__ in it.
|
||||
return object.__repr__(self._component)
|
||||
|
||||
def __getattribute__(self, name: Any) -> Any:
|
||||
try:
|
||||
sockets = object.__getattribute__(self, "_sockets")
|
||||
if name in sockets:
|
||||
return sockets[name]
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
result = ""
|
||||
if self._sockets_io_type == InputSocket:
|
||||
result = "Inputs:\n"
|
||||
elif self._sockets_io_type == OutputSocket:
|
||||
result = "Outputs:\n"
|
||||
|
||||
return result + "\n".join([f" - {n}: {_type_name(s.type)}" for n, s in self._sockets_dict.items()])
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from types import UnionType
|
||||
from typing import Annotated, Any, TypeAlias, TypedDict, TypeVar, get_args
|
||||
|
||||
from haystack.core.errors import ComponentError
|
||||
|
||||
HAYSTACK_VARIADIC_ANNOTATION = "__haystack__variadic_t"
|
||||
HAYSTACK_GREEDY_VARIADIC_ANNOTATION = "__haystack__greedy_variadic_t"
|
||||
|
||||
# # Generic type variable used in the Variadic container
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# Variadic is a custom annotation type we use to mark input types.
|
||||
# This type doesn't do anything else than "marking" the contained
|
||||
# type so it can be used in the `InputSocket` creation where we
|
||||
# check that its annotation equals to HAYSTACK_VARIADIC_ANNOTATION
|
||||
Variadic: TypeAlias = Annotated[Iterable[T], HAYSTACK_VARIADIC_ANNOTATION]
|
||||
|
||||
# GreedyVariadic type is similar to Variadic.
|
||||
# The only difference is the way it's treated by the Pipeline when input is received
|
||||
# in a socket with this type.
|
||||
# Instead of waiting for other inputs to be received, Components that have a GreedyVariadic
|
||||
# input will be run right after receiving the first input.
|
||||
# Even if there are multiple connections to that socket.
|
||||
GreedyVariadic: TypeAlias = Annotated[Iterable[T], HAYSTACK_GREEDY_VARIADIC_ANNOTATION]
|
||||
|
||||
|
||||
class _empty:
|
||||
"""Custom object for marking InputSocket.default_value as not set."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputSocket:
|
||||
"""
|
||||
Represents an input of a `Component`.
|
||||
|
||||
:param name:
|
||||
The name of the input.
|
||||
:param type:
|
||||
The type of the input.
|
||||
:param default_value:
|
||||
The default value of the input. If not set, the input is mandatory.
|
||||
:param is_lazy_variadic:
|
||||
Whether the input is a lazy variadic or not.
|
||||
:param is_greedy:
|
||||
Whether the input is a greedy variadic or not.
|
||||
:param senders:
|
||||
The list of components that send data to this input.
|
||||
:param wrap_input_in_list:
|
||||
Whether to wrap the input in a list before passing it to the component.
|
||||
Only applies to lazy variadic inputs so when is_lazy_variadic is True.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: type | UnionType
|
||||
default_value: Any = _empty
|
||||
is_lazy_variadic: bool = field(init=False)
|
||||
is_greedy: bool = field(init=False)
|
||||
senders: list[str] = field(default_factory=list)
|
||||
wrap_input_in_list: bool = True
|
||||
|
||||
@property
|
||||
def is_variadic(self) -> bool:
|
||||
"""Check if the input is variadic."""
|
||||
return self.is_greedy or self.is_lazy_variadic
|
||||
|
||||
@property
|
||||
def is_mandatory(self) -> bool:
|
||||
"""Check if the input is mandatory."""
|
||||
return self.default_value == _empty
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
# __metadata__ is a tuple
|
||||
self.is_lazy_variadic = (
|
||||
hasattr(self.type, "__metadata__") and self.type.__metadata__[0] == HAYSTACK_VARIADIC_ANNOTATION
|
||||
)
|
||||
self.is_greedy = (
|
||||
hasattr(self.type, "__metadata__") and self.type.__metadata__[0] == HAYSTACK_GREEDY_VARIADIC_ANNOTATION
|
||||
)
|
||||
except AttributeError:
|
||||
self.is_lazy_variadic = False
|
||||
self.is_greedy = False
|
||||
|
||||
# We need to "unpack" the type inside the Variadic annotation, otherwise the pipeline connection api will try
|
||||
# to match `Annotated[type, HAYSTACK_VARIADIC_ANNOTATION]`.
|
||||
#
|
||||
# Note1: Variadic is expressed as an annotation of one single type, so the return value of get_args will
|
||||
# always be a one-item tuple.
|
||||
#
|
||||
# Note2: a pipeline always passes a list of items when a component input is declared as Variadic, so the
|
||||
# type itself always wraps an iterable of the declared type. For example, Variadic[int] is eventually an
|
||||
# alias for Iterable[int]. Since we're interested in getting the inner type `int`, we call `get_args`
|
||||
# twice: the first time to get `list[int]` out of `Variadic`, the second time to get `int` out of `list[int]`.
|
||||
if self.is_lazy_variadic or self.is_greedy:
|
||||
outer_args = get_args(self.type)
|
||||
inner_type = outer_args[0]
|
||||
inner_args = get_args(inner_type)
|
||||
if not inner_args:
|
||||
raise ComponentError(
|
||||
f"Variadic input '{self.name}' must have a type argument, e.g. Variadic[int]. "
|
||||
f"Got bare {inner_type!r} without a type argument."
|
||||
)
|
||||
self.type = inner_args[0]
|
||||
|
||||
|
||||
class InputSocketTypeDescriptor(TypedDict):
|
||||
"""
|
||||
Describes the type of `InputSocket`.
|
||||
"""
|
||||
|
||||
type: type | UnionType
|
||||
is_mandatory: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputSocket:
|
||||
"""
|
||||
Represents an output of a `Component`.
|
||||
|
||||
:param name:
|
||||
The name of the output.
|
||||
:param type:
|
||||
The type of the output.
|
||||
:param receivers:
|
||||
The list of components that receive the output of this component.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: type
|
||||
receivers: list[str] = field(default_factory=list)
|
||||
@@ -0,0 +1,175 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack.dataclasses.breakpoints import Breakpoint, PipelineSnapshot
|
||||
|
||||
|
||||
class PipelineError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PipelineRuntimeError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
component_name: str | None,
|
||||
component_type: type | None,
|
||||
message: str,
|
||||
pipeline_snapshot: PipelineSnapshot | None = None,
|
||||
*,
|
||||
pipeline_snapshot_file_path: str | None = None,
|
||||
) -> None:
|
||||
self.component_name = component_name
|
||||
self.component_type = component_type
|
||||
self.pipeline_snapshot = pipeline_snapshot
|
||||
self.pipeline_snapshot_file_path = pipeline_snapshot_file_path
|
||||
super().__init__(message)
|
||||
|
||||
@classmethod
|
||||
def from_exception(cls, component_name: str, component_type: type, error: Exception) -> "PipelineRuntimeError":
|
||||
"""
|
||||
Create a PipelineRuntimeError from an exception.
|
||||
"""
|
||||
message = (
|
||||
f"The following component failed to run:\n"
|
||||
f"Component name: '{component_name}'\n"
|
||||
f"Component type: '{component_type.__name__}'\n"
|
||||
f"Error: {str(error)}"
|
||||
)
|
||||
return cls(component_name, component_type, message)
|
||||
|
||||
@classmethod
|
||||
def from_invalid_output(cls, component_name: str, component_type: type, output: Any) -> "PipelineRuntimeError":
|
||||
"""
|
||||
Create a PipelineRuntimeError from an invalid output.
|
||||
"""
|
||||
message = (
|
||||
f"The following component returned an invalid output:\n"
|
||||
f"Component name: '{component_name}'\n"
|
||||
f"Component type: '{component_type.__name__}'\n"
|
||||
f"Expected a dictionary, but got {type(output).__name__} instead.\n"
|
||||
f"Check the component's output and ensure it is a valid dictionary."
|
||||
)
|
||||
return cls(component_name, component_type, message)
|
||||
|
||||
|
||||
class PipelineComponentsBlockedError(PipelineRuntimeError):
|
||||
def __init__(self) -> None:
|
||||
message = (
|
||||
"Cannot run pipeline - all components are blocked. "
|
||||
"This typically happens when:\n"
|
||||
"1. There is no valid entry point for the pipeline\n"
|
||||
"2. There is a circular dependency preventing the pipeline from running\n"
|
||||
"Check the connections between these components and ensure all required inputs are provided."
|
||||
)
|
||||
super().__init__(None, None, message)
|
||||
|
||||
|
||||
class PipelineConnectError(PipelineError):
|
||||
pass
|
||||
|
||||
|
||||
class PipelineValidationError(PipelineError):
|
||||
pass
|
||||
|
||||
|
||||
class PipelineDrawingError(PipelineError):
|
||||
pass
|
||||
|
||||
|
||||
class PipelineMaxComponentRuns(PipelineError):
|
||||
pass
|
||||
|
||||
|
||||
class PipelineUnmarshalError(PipelineError):
|
||||
pass
|
||||
|
||||
|
||||
class ComponentError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ComponentDeserializationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DeserializationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SerializationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BreakpointException(Exception):
|
||||
"""
|
||||
Exception raised when a pipeline breakpoint is triggered.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
component: str | None = None,
|
||||
pipeline_snapshot: PipelineSnapshot | None = None,
|
||||
pipeline_snapshot_file_path: str | None = None,
|
||||
*,
|
||||
break_point: Breakpoint | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.component = component
|
||||
self.pipeline_snapshot = pipeline_snapshot
|
||||
self.pipeline_snapshot_file_path = pipeline_snapshot_file_path
|
||||
self._break_point = break_point
|
||||
|
||||
if self.pipeline_snapshot is None and self._break_point is None:
|
||||
raise ValueError("Either pipeline_snapshot or break_point must be provided.")
|
||||
|
||||
@classmethod
|
||||
def from_triggered_breakpoint(cls, break_point: Breakpoint) -> "BreakpointException":
|
||||
"""
|
||||
Create a BreakpointException from a triggered breakpoint.
|
||||
"""
|
||||
msg = f"Breaking at component {break_point.component_name} at visit count {break_point.visit_count}"
|
||||
return BreakpointException(message=msg, component=break_point.component_name, break_point=break_point)
|
||||
|
||||
@property
|
||||
def inputs(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Returns the current inputs of the pipeline at the breakpoint.
|
||||
"""
|
||||
if not self.pipeline_snapshot:
|
||||
return None
|
||||
return self.pipeline_snapshot.pipeline_state.inputs
|
||||
|
||||
@property
|
||||
def results(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Returns the current outputs of the pipeline at the breakpoint.
|
||||
"""
|
||||
if not self.pipeline_snapshot:
|
||||
return None
|
||||
return self.pipeline_snapshot.pipeline_state.pipeline_outputs
|
||||
|
||||
@property
|
||||
def break_point(self) -> Breakpoint:
|
||||
"""
|
||||
Returns the Breakpoint that caused this exception.
|
||||
|
||||
If a specific break point was provided during initialization, it is returned.
|
||||
Otherwise, if the pipeline snapshot contains a break point, that is returned.
|
||||
"""
|
||||
if self._break_point is not None:
|
||||
return self._break_point
|
||||
# Mypy doesn't know that pipeline_snapshot.break_point must not be None here based on the constructor check
|
||||
return self.pipeline_snapshot.break_point # type: ignore[union-attr]
|
||||
|
||||
|
||||
class PipelineInvalidPipelineSnapshotError(Exception):
|
||||
"""
|
||||
Exception raised when a pipeline is resumed from an invalid snapshot.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .pipeline import Pipeline
|
||||
|
||||
__all__ = ["Pipeline"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from networkx import MultiDiGraph
|
||||
|
||||
from haystack import logging
|
||||
from haystack.core.errors import PipelineInvalidPipelineSnapshotError
|
||||
from haystack.core.pipeline.utils import _deepcopy_with_exceptions
|
||||
from haystack.dataclasses.breakpoints import Breakpoint, PipelineSnapshot, PipelineState
|
||||
from haystack.utils.base_serialization import _serialize_value_with_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable to control pipeline snapshot file saving (enabled by default)
|
||||
HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED = "HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED"
|
||||
|
||||
# Type alias for snapshot callback function
|
||||
# The callback receives a PipelineSnapshot and optionally returns a file path string
|
||||
SnapshotCallback = Callable[[PipelineSnapshot], str | None]
|
||||
|
||||
|
||||
def _is_snapshot_save_enabled() -> bool:
|
||||
"""
|
||||
Check if pipeline snapshot file saving is enabled via environment variable.
|
||||
|
||||
The environment variable HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED controls whether
|
||||
pipeline snapshots are saved to files. By default (when the variable is not set),
|
||||
saving is disabled. Only "true" and "1" (case-insensitive) enable saving; any other value disables it.
|
||||
|
||||
:returns: True if snapshot saving is enabled, False otherwise.
|
||||
"""
|
||||
value = os.environ.get(HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED, "false").lower()
|
||||
return value in ("true", "1")
|
||||
|
||||
|
||||
def _validate_break_point_against_pipeline(break_point: Breakpoint, graph: MultiDiGraph) -> None:
|
||||
"""
|
||||
Validates the breakpoints passed to the pipeline.
|
||||
|
||||
Makes sure the breakpoint contains a valid components registered in the pipeline.
|
||||
|
||||
:param break_point: a breakpoint to validate
|
||||
"""
|
||||
if break_point.component_name not in graph.nodes:
|
||||
raise ValueError(f"break_point {break_point} is not a registered component in the pipeline")
|
||||
|
||||
|
||||
def _validate_pipeline_snapshot_against_pipeline(pipeline_snapshot: PipelineSnapshot, graph: MultiDiGraph) -> None:
|
||||
"""
|
||||
Validates that the pipeline_snapshot contains valid configuration for the current pipeline.
|
||||
|
||||
Raises a PipelineInvalidPipelineSnapshotError if any component in pipeline_snapshot is not part of the
|
||||
target pipeline.
|
||||
|
||||
:param pipeline_snapshot: The saved state to validate.
|
||||
"""
|
||||
|
||||
pipeline_state = pipeline_snapshot.pipeline_state
|
||||
valid_components = set(graph.nodes.keys())
|
||||
|
||||
# Check if the ordered_component_names are valid components in the pipeline
|
||||
invalid_ordered_components = set(pipeline_snapshot.ordered_component_names) - valid_components
|
||||
if invalid_ordered_components:
|
||||
raise PipelineInvalidPipelineSnapshotError(
|
||||
f"Invalid pipeline snapshot: components {invalid_ordered_components} in 'ordered_component_names' "
|
||||
f"are not part of the current pipeline."
|
||||
)
|
||||
|
||||
# Check if the original_input_data is valid components in the pipeline
|
||||
serialized_input_data = pipeline_snapshot.original_input_data["serialized_data"]
|
||||
invalid_input_data = set(serialized_input_data.keys()) - valid_components
|
||||
if invalid_input_data:
|
||||
raise PipelineInvalidPipelineSnapshotError(
|
||||
f"Invalid pipeline snapshot: components {invalid_input_data} in 'input_data' "
|
||||
f"are not part of the current pipeline."
|
||||
)
|
||||
|
||||
# Validate 'component_visits'
|
||||
invalid_component_visits = set(pipeline_state.component_visits.keys()) - valid_components
|
||||
if invalid_component_visits:
|
||||
raise PipelineInvalidPipelineSnapshotError(
|
||||
f"Invalid pipeline snapshot: components {invalid_component_visits} in 'component_visits' "
|
||||
f"are not part of the current pipeline."
|
||||
)
|
||||
|
||||
component_name = pipeline_snapshot.break_point.component_name
|
||||
visit_count = pipeline_snapshot.pipeline_state.component_visits[component_name]
|
||||
|
||||
logger.info(
|
||||
"Resuming pipeline from {component} with visit count {visits}", component=component_name, visits=visit_count
|
||||
)
|
||||
|
||||
|
||||
def load_pipeline_snapshot(file_path: str | Path) -> PipelineSnapshot:
|
||||
"""
|
||||
Load a saved pipeline snapshot.
|
||||
|
||||
:param file_path: Path to the pipeline_snapshot file.
|
||||
:returns:
|
||||
Dict containing the loaded pipeline_snapshot.
|
||||
"""
|
||||
|
||||
file_path = Path(file_path)
|
||||
|
||||
try:
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
pipeline_snapshot_dict = json.load(f)
|
||||
except FileNotFoundError as e:
|
||||
raise FileNotFoundError(f"File not found: {file_path}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise json.JSONDecodeError(f"Invalid JSON file {file_path}: {str(e)}", e.doc, e.pos) from e
|
||||
except OSError as e:
|
||||
raise OSError(f"Error reading {file_path}: {str(e)}") from e
|
||||
|
||||
try:
|
||||
pipeline_snapshot = PipelineSnapshot.from_dict(pipeline_snapshot_dict)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid pipeline snapshot from {file_path}: {str(e)}") from e
|
||||
|
||||
logger.info("Successfully loaded the pipeline snapshot from: {file_path}", file_path=file_path)
|
||||
return pipeline_snapshot
|
||||
|
||||
|
||||
def _save_pipeline_snapshot(
|
||||
pipeline_snapshot: PipelineSnapshot,
|
||||
raise_on_failure: bool = True,
|
||||
snapshot_callback: SnapshotCallback | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Save the pipeline snapshot dictionary to a JSON file, or invoke a custom callback.
|
||||
|
||||
If a `snapshot_callback` is provided, it will be called with the pipeline snapshot instead of saving to a file.
|
||||
This allows users to customize how snapshots are handled (e.g., saving to a database, sending to a remote service).
|
||||
|
||||
When no callback is provided, the default behavior saves to a JSON file:
|
||||
- The filename is generated based on the component name, visit count, and timestamp.
|
||||
- The component name is taken from the break point's `component_name`.
|
||||
- The visit count is taken from the pipeline state's `component_visits` for the component name.
|
||||
- The timestamp is taken from the pipeline snapshot's `timestamp` or the current time if not available.
|
||||
- The file path is taken from the break point's `snapshot_file_path`.
|
||||
- If the `snapshot_file_path` is None, the function will return without saving.
|
||||
|
||||
The default file saving behavior is disabled. To enable it, set the environment variable
|
||||
`HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED` to "true" or "1". When disabled,
|
||||
the function will return None without saving to a file (custom callbacks are still invoked).
|
||||
|
||||
:param pipeline_snapshot: The pipeline snapshot to save.
|
||||
:param raise_on_failure: If True, raises an exception if saving fails. If False, logs the error and returns.
|
||||
:param snapshot_callback: Optional callback function that receives the PipelineSnapshot.
|
||||
If provided, the callback is invoked instead of the default file-saving behavior.
|
||||
The callback should return an optional string (e.g., a file path or identifier) or None.
|
||||
|
||||
:returns:
|
||||
The full path to the saved JSON file (or the value returned by the callback), or None if
|
||||
`snapshot_file_path` is None, no callback is provided, or snapshot saving is disabled.
|
||||
:raises:
|
||||
Exception: If saving the JSON snapshot fails (when raise_on_failure is True).
|
||||
"""
|
||||
# If a callback is provided, use it instead of the default file-saving behavior
|
||||
if snapshot_callback is not None:
|
||||
try:
|
||||
result = snapshot_callback(pipeline_snapshot)
|
||||
logger.info("Pipeline snapshot handled by custom callback.")
|
||||
return result
|
||||
except Exception as error:
|
||||
logger.exception("Failed to handle pipeline snapshot with custom callback. Error: {error}", error=error)
|
||||
if raise_on_failure:
|
||||
raise
|
||||
return None
|
||||
|
||||
# Check if snapshot saving is enabled via environment variable (enabled by default)
|
||||
if not _is_snapshot_save_enabled():
|
||||
logger.debug("Pipeline snapshot file saving is disabled via HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED env var.")
|
||||
return None
|
||||
|
||||
break_point = pipeline_snapshot.break_point
|
||||
snapshot_file_path = break_point.snapshot_file_path
|
||||
|
||||
if snapshot_file_path is None:
|
||||
return None
|
||||
|
||||
dt = pipeline_snapshot.timestamp or datetime.now()
|
||||
snapshot_dir = Path(snapshot_file_path)
|
||||
|
||||
component_name = break_point.component_name
|
||||
visit_nr = pipeline_snapshot.pipeline_state.component_visits.get(component_name, 0)
|
||||
timestamp = dt.strftime("%Y_%m_%d_%H_%M_%S")
|
||||
file_name = f"{component_name}_{visit_nr}_{timestamp}.json"
|
||||
full_path = snapshot_dir / file_name
|
||||
|
||||
try:
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(full_path, "w") as f_out:
|
||||
json.dump(pipeline_snapshot.to_dict(), f_out, indent=2)
|
||||
logger.info(
|
||||
"Pipeline snapshot saved to '{full_path}'. You can use this file to debug or resume the pipeline.",
|
||||
full_path=full_path,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Failed to save pipeline snapshot to '{full_path}'. Error: {e}", full_path=full_path, e=error)
|
||||
if raise_on_failure:
|
||||
raise
|
||||
|
||||
return str(full_path)
|
||||
|
||||
|
||||
def _create_pipeline_snapshot(
|
||||
*,
|
||||
inputs: dict[str, Any],
|
||||
component_inputs: dict[str, Any],
|
||||
break_point: Breakpoint,
|
||||
component_visits: dict[str, int],
|
||||
original_input_data: dict[str, Any],
|
||||
ordered_component_names: list[str],
|
||||
include_outputs_from: set[str],
|
||||
pipeline_outputs: dict[str, Any],
|
||||
) -> PipelineSnapshot:
|
||||
"""
|
||||
Create a snapshot of the pipeline at the point where the breakpoint was triggered.
|
||||
|
||||
:param inputs: The current pipeline snapshot inputs.
|
||||
:param component_inputs: The inputs to the component that triggered the breakpoint.
|
||||
:param break_point: The breakpoint that triggered the snapshot.
|
||||
:param component_visits: The visit count of the component that triggered the breakpoint.
|
||||
:param original_input_data: The original input data.
|
||||
:param ordered_component_names: The ordered component names.
|
||||
:param include_outputs_from: Set of component names whose outputs should be included in the pipeline results.
|
||||
:param pipeline_outputs: The current outputs of the pipeline.
|
||||
:returns:
|
||||
A PipelineSnapshot containing the state of the pipeline at the point of the breakpoint.
|
||||
"""
|
||||
component_name = break_point.component_name
|
||||
|
||||
transformed_original_input_data = _transform_json_structure(original_input_data)
|
||||
transformed_inputs = _transform_json_structure({**inputs, component_name: component_inputs})
|
||||
|
||||
serialized_inputs = _serialize_with_field_fallback(
|
||||
transformed_inputs, description="the inputs of the current pipeline state"
|
||||
)
|
||||
serialized_original_input_data = _serialize_with_field_fallback(
|
||||
transformed_original_input_data, description="original input data for `pipeline.run`"
|
||||
)
|
||||
serialized_pipeline_outputs = _serialize_with_field_fallback(
|
||||
pipeline_outputs, description="outputs of the current pipeline state"
|
||||
)
|
||||
|
||||
return PipelineSnapshot(
|
||||
pipeline_state=PipelineState(
|
||||
inputs=serialized_inputs, component_visits=component_visits, pipeline_outputs=serialized_pipeline_outputs
|
||||
),
|
||||
timestamp=datetime.now(),
|
||||
break_point=break_point,
|
||||
original_input_data=serialized_original_input_data,
|
||||
ordered_component_names=ordered_component_names,
|
||||
include_outputs_from=include_outputs_from,
|
||||
)
|
||||
|
||||
|
||||
def _transform_json_structure(data: dict[str, Any] | list[Any] | Any) -> Any:
|
||||
"""
|
||||
Transforms a JSON structure by removing the 'sender' key and moving the 'value' to the top level.
|
||||
|
||||
For example:
|
||||
"key": [{"sender": null, "value": "some value"}] -> "key": "some value"
|
||||
|
||||
:param data: The JSON structure to transform.
|
||||
:returns: The transformed structure.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
# If this dict has both 'sender' and 'value', return just the value
|
||||
if "value" in data and "sender" in data:
|
||||
return data["value"]
|
||||
# Otherwise, recursively process each key-value pair
|
||||
return {k: _transform_json_structure(v) for k, v in data.items()}
|
||||
|
||||
if isinstance(data, list):
|
||||
# First, transform each item in the list.
|
||||
transformed = [_transform_json_structure(item) for item in data]
|
||||
# If the original list has exactly one element and that element was a dict
|
||||
# with 'sender' and 'value', then unwrap the list.
|
||||
if len(data) == 1 and isinstance(data[0], dict) and "value" in data[0] and "sender" in data[0]:
|
||||
return transformed[0]
|
||||
return transformed
|
||||
|
||||
# For other data types, just return the value as is.
|
||||
return data
|
||||
|
||||
|
||||
def _serialize_with_field_fallback(payload: Any, *, description: str) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize a payload and, on failure, retry field-by-field to preserve resumable fields.
|
||||
|
||||
If the whole payload serializes, the result is returned as-is. Otherwise, and if the payload is a
|
||||
mapping, each top-level field is serialized individually and only the failing fields are omitted.
|
||||
When the payload is not a mapping, or when every field fails to serialize, the helper returns a
|
||||
structurally valid empty-object payload so that the downstream ``_deserialize_value_with_schema``
|
||||
can still load it back instead of raising ``DeserializationError`` on a bare ``{}``.
|
||||
|
||||
:param payload: The value to serialize.
|
||||
:param description: Short human-readable label used in warning messages, for example
|
||||
``"the agent's chat_generator inputs"`` or ``"the inputs of the current pipeline state"``.
|
||||
:returns: A dict of the form ``{"serialization_schema": ..., "serialized_data": ...}``.
|
||||
"""
|
||||
try:
|
||||
return _serialize_value_with_schema(_deepcopy_with_exceptions(payload))
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to serialize {description}. "
|
||||
"Haystack will omit only the non-serializable fields when possible. Error: {e}",
|
||||
description=description,
|
||||
e=error,
|
||||
)
|
||||
|
||||
serialized_properties: dict[str, Any] = {}
|
||||
serialized_data: dict[str, Any] = {}
|
||||
|
||||
if isinstance(payload, dict):
|
||||
for field_name, value in payload.items():
|
||||
try:
|
||||
serialized_value = _serialize_value_with_schema(_deepcopy_with_exceptions(value))
|
||||
except Exception as field_error:
|
||||
logger.warning(
|
||||
"Failed to serialize the '{field_name}' field of {description}. "
|
||||
"The field will be omitted from the snapshot. Error: {e}",
|
||||
field_name=field_name,
|
||||
description=description,
|
||||
e=field_error,
|
||||
)
|
||||
continue
|
||||
|
||||
serialized_properties[field_name] = serialized_value["serialization_schema"]
|
||||
serialized_data[field_name] = serialized_value["serialized_data"]
|
||||
|
||||
return {
|
||||
"serialization_schema": {"type": "object", "properties": serialized_properties},
|
||||
"serialized_data": serialized_data,
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack.core.component.types import InputSocket, _empty
|
||||
|
||||
_NO_OUTPUT_PRODUCED = _empty
|
||||
|
||||
|
||||
def can_component_run(component: dict, inputs: dict) -> bool:
|
||||
"""
|
||||
Checks if the component can run, given the current state of its inputs.
|
||||
|
||||
A component needs to pass two gates so that it is ready to run:
|
||||
1. It has received all mandatory inputs.
|
||||
2. It has received a trigger.
|
||||
:param component: Component metadata and the component instance.
|
||||
:param inputs: Inputs for the component.
|
||||
"""
|
||||
received_all_mandatory_inputs = are_all_sockets_ready(component, inputs, only_check_mandatory=True)
|
||||
received_trigger = has_any_trigger(component, inputs)
|
||||
|
||||
return received_all_mandatory_inputs and received_trigger
|
||||
|
||||
|
||||
def has_any_trigger(component: dict, inputs: dict) -> bool:
|
||||
"""
|
||||
Checks if a component was triggered to execute.
|
||||
|
||||
There are 3 triggers:
|
||||
1. A predecessor provided input to the component.
|
||||
2. Input to the component was provided from outside the pipeline (e.g. user input).
|
||||
3. The component does not receive input from any other components in the pipeline and `Pipeline.run` was called.
|
||||
|
||||
A trigger can only cause a component to execute ONCE because:
|
||||
1. Components consume inputs from predecessors before execution (they are deleted).
|
||||
2. Inputs from outside the pipeline can only trigger a component when it is executed for the first time.
|
||||
3. `Pipeline.run` can only trigger a component when it is executed for the first time.
|
||||
|
||||
:param component: Component metadata and the component instance.
|
||||
:param inputs: Inputs for the component.
|
||||
"""
|
||||
trigger_from_predecessor = any_predecessors_provided_input(component, inputs)
|
||||
trigger_from_user = has_user_input(inputs) and component["visits"] == 0
|
||||
trigger_without_inputs = can_not_receive_inputs_from_pipeline(component) and component["visits"] == 0
|
||||
|
||||
return trigger_from_predecessor or trigger_from_user or trigger_without_inputs
|
||||
|
||||
|
||||
def are_all_sockets_ready(component: dict, inputs: dict, only_check_mandatory: bool = False) -> bool:
|
||||
"""
|
||||
Checks if all sockets of a component have enough inputs for the component to execute.
|
||||
|
||||
:param component: Component metadata and the component instance.
|
||||
:param inputs: Inputs for the component.
|
||||
:param only_check_mandatory: If only mandatory sockets should be checked.
|
||||
"""
|
||||
filled_sockets = set()
|
||||
expected_sockets = set()
|
||||
if only_check_mandatory:
|
||||
sockets_to_check = {
|
||||
socket_name: socket for socket_name, socket in component["input_sockets"].items() if socket.is_mandatory
|
||||
}
|
||||
else:
|
||||
sockets_to_check = {
|
||||
socket_name: socket
|
||||
for socket_name, socket in component["input_sockets"].items()
|
||||
if socket.is_mandatory or len(socket.senders)
|
||||
}
|
||||
|
||||
for socket_name, socket in sockets_to_check.items():
|
||||
socket_inputs = inputs.get(socket_name, [])
|
||||
expected_sockets.add(socket_name)
|
||||
|
||||
# Check if socket has all required inputs or is a lazy variadic socket with any input
|
||||
if has_socket_received_all_inputs(socket, socket_inputs) or (
|
||||
socket.is_lazy_variadic and any_socket_input_received(socket_inputs)
|
||||
):
|
||||
filled_sockets.add(socket_name)
|
||||
|
||||
return filled_sockets == expected_sockets
|
||||
|
||||
|
||||
def any_predecessors_provided_input(component: dict, inputs: dict) -> bool:
|
||||
"""
|
||||
Checks if a component received inputs from any predecessors.
|
||||
|
||||
:param component: Component metadata and the component instance.
|
||||
:param inputs: Inputs for the component.
|
||||
"""
|
||||
return any(
|
||||
any_socket_value_from_predecessor_received(inputs.get(socket_name, []))
|
||||
for socket_name in component["input_sockets"].keys()
|
||||
)
|
||||
|
||||
|
||||
def any_socket_value_from_predecessor_received(socket_inputs: list[dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Checks if a component socket received input from any predecessors.
|
||||
|
||||
:param socket_inputs: Inputs for the component's socket.
|
||||
"""
|
||||
# When sender is None, the input was provided from outside the pipeline.
|
||||
return any(inp["value"] is not _NO_OUTPUT_PRODUCED and inp["sender"] is not None for inp in socket_inputs)
|
||||
|
||||
|
||||
def has_user_input(inputs: dict) -> bool:
|
||||
"""
|
||||
Checks if a component has received input from outside the pipeline (e.g. user input).
|
||||
|
||||
:param inputs: Inputs for the component.
|
||||
"""
|
||||
return any(inp for socket in inputs.values() for inp in socket if inp["sender"] is None)
|
||||
|
||||
|
||||
def can_not_receive_inputs_from_pipeline(component: dict) -> bool:
|
||||
"""
|
||||
Checks if a component can not receive inputs from any other components in the pipeline.
|
||||
|
||||
:param: Component metadata and the component instance.
|
||||
"""
|
||||
return all(len(sock.senders) == 0 for sock in component["input_sockets"].values())
|
||||
|
||||
|
||||
def all_socket_predecessors_executed(socket: InputSocket, socket_inputs: list[dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Checks if all components connecting to an InputSocket have executed.
|
||||
|
||||
:param: The InputSocket of a component.
|
||||
:param: socket_inputs: Inputs for the socket.
|
||||
"""
|
||||
expected_senders = set(socket.senders)
|
||||
executed_senders = {inp["sender"] for inp in socket_inputs if inp["sender"] is not None}
|
||||
return expected_senders == executed_senders
|
||||
|
||||
|
||||
def any_socket_input_received(socket_inputs: list[dict]) -> bool:
|
||||
"""
|
||||
Checks if a socket has received any input from any other components in the pipeline or from outside the pipeline.
|
||||
|
||||
:param socket_inputs: Inputs for the socket.
|
||||
"""
|
||||
return any(inp["value"] is not _NO_OUTPUT_PRODUCED for inp in socket_inputs)
|
||||
|
||||
|
||||
def has_lazy_variadic_socket_received_all_inputs(socket: InputSocket, socket_inputs: list[dict]) -> bool:
|
||||
"""
|
||||
Checks if a lazy variadic socket has received all expected inputs from other components in the pipeline.
|
||||
|
||||
:param socket: The InputSocket of a component.
|
||||
:param socket_inputs: Inputs for the socket.
|
||||
"""
|
||||
expected_senders = set(socket.senders)
|
||||
actual_senders = {
|
||||
sock["sender"]
|
||||
for sock in socket_inputs
|
||||
if sock["value"] is not _NO_OUTPUT_PRODUCED and sock["sender"] is not None
|
||||
}
|
||||
return expected_senders == actual_senders
|
||||
|
||||
|
||||
def has_socket_received_all_inputs(socket: InputSocket, socket_inputs: list[dict]) -> bool:
|
||||
"""
|
||||
Checks if a socket has received all expected inputs.
|
||||
|
||||
:param socket: The InputSocket of a component.
|
||||
:param socket_inputs: Inputs for the socket.
|
||||
"""
|
||||
# No inputs received for the socket, it is not filled.
|
||||
if len(socket_inputs) == 0:
|
||||
return False
|
||||
|
||||
# The socket is greedy variadic and at least one input was produced, it is complete.
|
||||
if (
|
||||
socket.is_variadic
|
||||
and socket.is_greedy
|
||||
and any(sock["value"] is not _NO_OUTPUT_PRODUCED for sock in socket_inputs)
|
||||
):
|
||||
return True
|
||||
|
||||
# The socket is lazy variadic and all expected inputs were produced.
|
||||
if socket.is_lazy_variadic and has_lazy_variadic_socket_received_all_inputs(socket, socket_inputs):
|
||||
return True
|
||||
|
||||
# The socket is not variadic and the only expected input is complete.
|
||||
return not socket.is_variadic and socket_inputs[0]["value"] is not _NO_OUTPUT_PRODUCED
|
||||
|
||||
|
||||
def all_predecessors_executed(component: dict, inputs: dict) -> bool:
|
||||
"""
|
||||
Checks if all predecessors of a component have executed.
|
||||
|
||||
:param component: Component metadata and the component instance.
|
||||
:param inputs: Inputs for the component.
|
||||
"""
|
||||
return all(
|
||||
all_socket_predecessors_executed(socket, inputs.get(socket_name, []))
|
||||
for socket_name, socket in component["input_sockets"].items()
|
||||
)
|
||||
|
||||
|
||||
def is_any_greedy_socket_ready(component: dict, inputs: dict) -> bool:
|
||||
"""
|
||||
Checks if the component has any greedy socket that is ready to run.
|
||||
|
||||
:param component: Component metadata and the component instance.
|
||||
:param inputs: Inputs for the component.
|
||||
"""
|
||||
for socket_name, socket in component["input_sockets"].items():
|
||||
if socket.is_greedy and has_socket_received_all_inputs(socket, inputs.get(socket_name, [])):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,51 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
import networkx
|
||||
|
||||
from haystack.core.component.types import InputSocket, OutputSocket
|
||||
|
||||
|
||||
def find_pipeline_inputs(
|
||||
graph: networkx.MultiDiGraph, include_connected_sockets: bool = False
|
||||
) -> dict[str, list[InputSocket]]:
|
||||
"""
|
||||
Collect components that have disconnected/connected input sockets.
|
||||
|
||||
Note that this method returns *ALL* disconnected input sockets, including all such sockets with default values.
|
||||
It also includes variadic input sockets, even if they are currently connected, as they can accept additional
|
||||
inputs from outside the pipeline.
|
||||
|
||||
:param graph: The pipeline graph to analyze.
|
||||
:param include_connected_sockets: If True, also include input sockets that are already connected.
|
||||
This can be useful for understanding the full input requirements of the pipeline, including inputs
|
||||
that are currently satisfied by connections within the pipeline. If False, only include input sockets that
|
||||
are not connected to any output socket, which represent the external inputs that can be provided when running
|
||||
the pipeline.
|
||||
"""
|
||||
return {
|
||||
name: [
|
||||
socket
|
||||
for socket in data.get("input_sockets", {}).values()
|
||||
if socket.is_variadic or (include_connected_sockets or not socket.senders)
|
||||
]
|
||||
for name, data in graph.nodes(data=True)
|
||||
}
|
||||
|
||||
|
||||
def find_pipeline_outputs(
|
||||
graph: networkx.MultiDiGraph, include_connected_sockets: bool = False
|
||||
) -> dict[str, list[OutputSocket]]:
|
||||
"""
|
||||
Collect components that have disconnected/connected output sockets. They define the pipeline output.
|
||||
"""
|
||||
return {
|
||||
name: [
|
||||
socket
|
||||
for socket in data.get("output_sockets", {}).values()
|
||||
if (include_connected_sockets or not socket.receivers)
|
||||
]
|
||||
for name, data in graph.nodes(data=True)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
import colorsys
|
||||
import json
|
||||
import random
|
||||
import zlib
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import networkx
|
||||
|
||||
from haystack import logging
|
||||
from haystack.core.errors import PipelineDrawingError
|
||||
from haystack.core.pipeline.descriptions import find_pipeline_inputs, find_pipeline_outputs
|
||||
from haystack.core.type_utils import _type_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_color_variations(n: int, base_color: str | None = "#3498DB", variation_range: float = 0.4) -> list[str]:
|
||||
"""
|
||||
Generate n different variations of a base color.
|
||||
|
||||
:param n: Number of variations to generate
|
||||
:param base_color: Hex color code, default is a shade of blue (#3498DB)
|
||||
:param variation_range: Range for varying brightness and saturation (0-1)
|
||||
|
||||
:returns:
|
||||
list: List of hex color codes representing variations of the base color
|
||||
"""
|
||||
# convert hex to RGB
|
||||
base_color = base_color.lstrip("#") # type:ignore
|
||||
r = int(base_color[0:2], 16) / 255.0
|
||||
g = int(base_color[2:4], 16) / 255.0
|
||||
b = int(base_color[4:6], 16) / 255.0
|
||||
|
||||
# convert RGB to HSV (Hue, Saturation, Value)
|
||||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||||
|
||||
variations = []
|
||||
for _ in range(n):
|
||||
# vary saturation and brightness within the specified range
|
||||
new_s = max(0, min(1, s + random.uniform(-variation_range, variation_range)))
|
||||
new_v = max(0, min(1, v + random.uniform(-variation_range, variation_range)))
|
||||
|
||||
# keep hue the same for color consistency
|
||||
new_h = h
|
||||
|
||||
# Convert back to RGB and then to hex
|
||||
new_r, new_g, new_b = colorsys.hsv_to_rgb(new_h, new_s, new_v)
|
||||
hex_color = f"#{int(new_r * 255):02x}{int(new_g * 255):02x}{int(new_b * 255):02x}"
|
||||
|
||||
variations.append(hex_color)
|
||||
|
||||
return variations
|
||||
|
||||
|
||||
def _prepare_for_drawing(graph: networkx.MultiDiGraph) -> networkx.MultiDiGraph:
|
||||
"""
|
||||
Add some extra nodes to show the inputs and outputs of the pipeline.
|
||||
|
||||
Also adds labels to edges.
|
||||
"""
|
||||
# Label the edges
|
||||
for inp, outp, key, data in graph.edges(keys=True, data=True):
|
||||
data["label"] = (
|
||||
f"{data['from_socket'].name} -> {data['to_socket'].name}{' (opt.)' if not data['mandatory'] else ''}"
|
||||
)
|
||||
graph.add_edge(inp, outp, key=key, **data)
|
||||
|
||||
# Add inputs fake node
|
||||
graph.add_node("input")
|
||||
for node, in_sockets in find_pipeline_inputs(graph).items():
|
||||
for in_socket in in_sockets:
|
||||
if not in_socket.senders and in_socket.is_mandatory:
|
||||
# If this socket has no sender it could be a socket that receives input
|
||||
# directly when running the Pipeline. We can't know that for sure, in doubt
|
||||
# we draw it as receiving input directly.
|
||||
graph.add_edge("input", node, label=in_socket.name, conn_type=_type_name(in_socket.type))
|
||||
|
||||
# Add outputs fake node
|
||||
graph.add_node("output")
|
||||
for node, out_sockets in find_pipeline_outputs(graph).items():
|
||||
for out_socket in out_sockets:
|
||||
graph.add_edge(node, "output", label=out_socket.name, conn_type=_type_name(out_socket.type))
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
ARROWTAIL_MANDATORY = "--"
|
||||
ARROWTAIL_OPTIONAL = "-."
|
||||
ARROWHEAD_MANDATORY = "-->"
|
||||
ARROWHEAD_OPTIONAL = ".->"
|
||||
MERMAID_STYLED_TEMPLATE = """
|
||||
%%{{ init: {params} }}%%
|
||||
|
||||
graph TD;
|
||||
|
||||
{connections}
|
||||
|
||||
classDef component text-align:center;
|
||||
{style_definitions}
|
||||
"""
|
||||
|
||||
|
||||
def _validate_mermaid_params(params: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validates and sets default values for Mermaid parameters.
|
||||
|
||||
:param params:
|
||||
Dictionary of customization parameters to modify the output. Refer to Mermaid documentation for more details.
|
||||
Supported keys:
|
||||
- format: Output format ('img', 'svg', or 'pdf'). Default: 'img'.
|
||||
- type: Image type for /img endpoint ('jpeg', 'png', 'webp'). Default: 'png'.
|
||||
- theme: Mermaid theme ('default', 'neutral', 'dark', 'forest'). Default: 'neutral'.
|
||||
- bgColor: Background color in hexadecimal (e.g., 'FFFFFF') or named format (e.g., '!white').
|
||||
- width: Width of the output image (integer).
|
||||
- height: Height of the output image (integer).
|
||||
- scale: Scaling factor (1–3). Only applicable if 'width' or 'height' is specified.
|
||||
- fit: Whether to fit the diagram size to the page (PDF only, boolean).
|
||||
- paper: Paper size for PDFs (e.g., 'a4', 'a3'). Ignored if 'fit' is true.
|
||||
- landscape: Landscape orientation for PDFs (boolean). Ignored if 'fit' is true.
|
||||
|
||||
:raises ValueError:
|
||||
If any parameter is invalid or does not match the expected format.
|
||||
"""
|
||||
valid_img_types = {"jpeg", "png", "webp"}
|
||||
valid_themes = {"default", "neutral", "dark", "forest"}
|
||||
valid_formats = {"img", "svg", "pdf"}
|
||||
|
||||
params.setdefault("format", "img")
|
||||
params.setdefault("type", "png")
|
||||
params.setdefault("theme", "neutral")
|
||||
|
||||
if params["format"] not in valid_formats:
|
||||
raise ValueError(f"Invalid image format: {params['format']}. Valid options are: {valid_formats}.")
|
||||
|
||||
if params["format"] == "img" and params["type"] not in valid_img_types:
|
||||
raise ValueError(f"Invalid image type: {params['type']}. Valid options are: {valid_img_types}.")
|
||||
|
||||
if params["theme"] not in valid_themes:
|
||||
raise ValueError(f"Invalid theme: {params['theme']}. Valid options are: {valid_themes}.")
|
||||
|
||||
if "width" in params and not isinstance(params["width"], int):
|
||||
raise ValueError("Width must be an integer.")
|
||||
if "height" in params and not isinstance(params["height"], int):
|
||||
raise ValueError("Height must be an integer.")
|
||||
|
||||
if "scale" in params and not 1 <= params["scale"] <= 3:
|
||||
raise ValueError("Scale must be a number between 1 and 3.")
|
||||
if "scale" in params and not ("width" in params or "height" in params):
|
||||
raise ValueError("Scale is only allowed when width or height is set.")
|
||||
|
||||
if "bgColor" in params and not isinstance(params["bgColor"], str):
|
||||
raise ValueError("Background color must be a string.")
|
||||
|
||||
# PDF specific parameters
|
||||
if params["format"] == "pdf":
|
||||
if "fit" in params and not isinstance(params["fit"], bool):
|
||||
raise ValueError("Fit must be a boolean.")
|
||||
if "paper" in params and not isinstance(params["paper"], str):
|
||||
raise ValueError("Paper size must be a string (e.g., 'a4', 'a3').")
|
||||
if "landscape" in params and not isinstance(params["landscape"], bool):
|
||||
raise ValueError("Landscape must be a boolean.")
|
||||
if "fit" in params and ("paper" in params or "landscape" in params):
|
||||
logger.warning("`fit` overrides `paper` and `landscape` for PDFs. Ignoring `paper` and `landscape`.")
|
||||
|
||||
|
||||
# Magic-byte signatures used to verify a Mermaid server response matches the requested output format.
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_JPEG_SIGNATURE = b"\xff\xd8\xff"
|
||||
_PDF_SIGNATURE = b"%PDF-"
|
||||
_RIFF_SIGNATURE = b"RIFF"
|
||||
_WEBP_SIGNATURE = b"WEBP"
|
||||
_SVG_PREFIXES = (b"<?xml", b"<svg")
|
||||
|
||||
|
||||
def _validate_image_response(resp: httpx.Response, params: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate that the Mermaid server response actually contains the expected image/SVG/PDF data.
|
||||
|
||||
`Pipeline.draw()` writes the raw response body to disk, so a misconfigured or malicious
|
||||
`server_url` could otherwise cause arbitrary content (e.g. an HTML error page or a crafted
|
||||
payload) to be written verbatim to the output path. As defense-in-depth we check both the
|
||||
`Content-Type` header (which the server controls and could spoof) and the response body's
|
||||
magic-byte signature (which is harder to forge while still producing a usable payload).
|
||||
|
||||
:param resp:
|
||||
The HTTP response returned by the Mermaid server.
|
||||
:param params:
|
||||
Validated Mermaid parameters; used to determine the expected output format.
|
||||
:raises PipelineDrawingError:
|
||||
If the response is empty or does not match the expected format.
|
||||
"""
|
||||
content = resp.content
|
||||
if not content:
|
||||
raise PipelineDrawingError("The Mermaid server returned an empty response; no image will be saved.")
|
||||
|
||||
output_format = params.get("format", "img")
|
||||
img_type = params.get("type", "png")
|
||||
|
||||
# (human-readable label, expected Content-Type prefix, body signature check)
|
||||
content_type_prefixes: tuple[str, ...]
|
||||
if output_format == "svg":
|
||||
expected_label = "SVG"
|
||||
content_type_prefixes = ("image/svg+xml", "text/xml", "application/xml")
|
||||
stripped = content.lstrip()[:512].lower()
|
||||
body_ok = stripped.startswith(_SVG_PREFIXES) or _SVG_PREFIXES[1] in stripped
|
||||
elif output_format == "pdf":
|
||||
expected_label = "PDF"
|
||||
content_type_prefixes = ("application/pdf",)
|
||||
body_ok = content.startswith(_PDF_SIGNATURE)
|
||||
elif img_type == "jpeg":
|
||||
expected_label = "JPEG image"
|
||||
content_type_prefixes = ("image/jpeg",)
|
||||
body_ok = content.startswith(_JPEG_SIGNATURE)
|
||||
elif img_type == "webp":
|
||||
expected_label = "WebP image"
|
||||
content_type_prefixes = ("image/webp",)
|
||||
body_ok = content[0:4] == _RIFF_SIGNATURE and content[8:12] == _WEBP_SIGNATURE
|
||||
else: # png (default)
|
||||
expected_label = "PNG image"
|
||||
content_type_prefixes = ("image/png",)
|
||||
body_ok = content.startswith(_PNG_SIGNATURE)
|
||||
|
||||
# The Content-Type header is server-controlled, so a mismatch is only a warning: the
|
||||
# authoritative check is the body signature below.
|
||||
content_type = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||
if content_type and not content_type.startswith(content_type_prefixes):
|
||||
logger.warning(
|
||||
"The Mermaid server returned an unexpected Content-Type '{content_type}' (expected {expected}).",
|
||||
content_type=content_type,
|
||||
expected=expected_label,
|
||||
)
|
||||
|
||||
if not body_ok:
|
||||
raise PipelineDrawingError(
|
||||
f"The Mermaid server response does not look like a valid {expected_label}. "
|
||||
f"This can happen if 'server_url' points to a server that is not a Mermaid renderer. "
|
||||
f"To avoid writing untrusted content to disk, no file will be saved."
|
||||
)
|
||||
|
||||
|
||||
def _to_mermaid_image(
|
||||
graph: networkx.MultiDiGraph,
|
||||
server_url: str = "https://mermaid.ink",
|
||||
params: dict | None = None,
|
||||
timeout: int = 30,
|
||||
super_component_mapping: dict[str, str] | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Renders a pipeline using a Mermaid server.
|
||||
|
||||
:param graph:
|
||||
The graph to render as a Mermaid pipeline.
|
||||
:param server_url:
|
||||
Base URL of the Mermaid server (default: 'https://mermaid.ink').
|
||||
:param params:
|
||||
Dictionary of customization parameters. See `validate_mermaid_params` for valid keys.
|
||||
:param timeout:
|
||||
Timeout in seconds for the request to the Mermaid server.
|
||||
:returns:
|
||||
The image, SVG, or PDF data returned by the Mermaid server as bytes.
|
||||
:raises ValueError:
|
||||
If any parameter is invalid or does not match the expected format.
|
||||
:raises PipelineDrawingError:
|
||||
If there is an issue connecting to the Mermaid server or the server returns an error.
|
||||
"""
|
||||
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
_validate_mermaid_params(params)
|
||||
|
||||
theme = params.get("theme")
|
||||
init_params = json.dumps({"theme": theme})
|
||||
|
||||
# Copy the graph to avoid modifying the original
|
||||
graph_styled = _to_mermaid_text(graph.copy(), init_params, super_component_mapping)
|
||||
json_string = json.dumps({"code": graph_styled})
|
||||
|
||||
# Compress the JSON string with zlib (RFC 1950)
|
||||
compressor = zlib.compressobj(level=9, wbits=15)
|
||||
compressed_data = compressor.compress(json_string.encode("utf-8")) + compressor.flush()
|
||||
compressed_url_safe_base64 = base64.urlsafe_b64encode(compressed_data).decode("utf-8").strip()
|
||||
|
||||
# Determine the correct endpoint
|
||||
endpoint_format = params.get("format", "img") # Default to /img endpoint
|
||||
if endpoint_format not in {"img", "svg", "pdf"}:
|
||||
raise ValueError(f"Invalid format: {endpoint_format}. Valid options are 'img', 'svg', or 'pdf'.")
|
||||
|
||||
# Construct the URL without query parameters
|
||||
url = f"{server_url}/{endpoint_format}/pako:{compressed_url_safe_base64}"
|
||||
|
||||
# Add query parameters adhering to mermaid.ink documentation
|
||||
query_params = []
|
||||
for key, value in params.items():
|
||||
if key not in {"theme", "format"}: # Exclude theme (handled in init_params) and format (endpoint-specific)
|
||||
if value is True:
|
||||
query_params.append(f"{key}")
|
||||
else:
|
||||
query_params.append(f"{key}={value}")
|
||||
|
||||
if query_params:
|
||||
url += "?" + "&".join(query_params)
|
||||
|
||||
logger.debug("Rendering graph at {url}", url=url)
|
||||
try:
|
||||
resp = httpx.get(url, timeout=timeout)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"Failed to draw the pipeline: {server_url} returned status {status_code}",
|
||||
server_url=server_url,
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
logger.info("Exact URL requested: {url}", url=url)
|
||||
logger.warning("No pipeline diagram will be saved.")
|
||||
resp.raise_for_status()
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to draw the pipeline: could not connect to {server_url} ({error})", server_url=server_url, error=exc
|
||||
)
|
||||
logger.info("Exact URL requested: {url}", url=url)
|
||||
logger.warning("No pipeline diagram will be saved.")
|
||||
raise PipelineDrawingError(f"There was an issue with {server_url}, see the stacktrace for details.") from exc
|
||||
|
||||
# Validate the response before it gets written to disk by the caller, so that a misconfigured
|
||||
# or malicious server cannot cause arbitrary content to be saved to the output path.
|
||||
_validate_image_response(resp, params)
|
||||
|
||||
return resp.content
|
||||
|
||||
|
||||
def _to_mermaid_text(
|
||||
graph: networkx.MultiDiGraph, init_params: str | dict, super_component_mapping: dict[str, str] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Converts a Networkx graph into Mermaid syntax.
|
||||
|
||||
The output of this function can be used in the documentation with `mermaid` codeblocks and will be
|
||||
automatically rendered.
|
||||
|
||||
:param graph: The graph to convert to Mermaid syntax
|
||||
:param init_params: Initialization parameters for Mermaid
|
||||
:param super_component_mapping: Mapping of component names to super component names
|
||||
"""
|
||||
# Copy the graph to avoid modifying the original
|
||||
graph = _prepare_for_drawing(graph.copy())
|
||||
sockets = {
|
||||
comp: "".join(
|
||||
[
|
||||
f"<li>{name} ({_type_name(socket.type)})</li>"
|
||||
for name, socket in data.get("input_sockets", {}).items()
|
||||
if (not socket.is_mandatory and not socket.senders) or socket.is_variadic
|
||||
]
|
||||
)
|
||||
for comp, data in graph.nodes(data=True)
|
||||
}
|
||||
optional_inputs = {
|
||||
comp: f"<br><br>Optional inputs:<ul style='text-align:left;'>{sockets}</ul>" if sockets else ""
|
||||
for comp, sockets in sockets.items()
|
||||
}
|
||||
|
||||
# Create node definitions
|
||||
states = {}
|
||||
super_component_components = super_component_mapping.keys() if super_component_mapping else {}
|
||||
|
||||
# color variations for super components
|
||||
super_component_colors = {}
|
||||
if super_component_components:
|
||||
unique_super_components = set(super_component_mapping.values()) # type:ignore
|
||||
color_variations = generate_color_variations(n=len(unique_super_components))
|
||||
super_component_colors = dict(zip(unique_super_components, color_variations, strict=True))
|
||||
|
||||
# Generate style definitions for each super component
|
||||
style_definitions = []
|
||||
for super_comp, color in super_component_colors.items():
|
||||
style_definitions.append(f"classDef {super_comp} fill:{color},color:white;")
|
||||
|
||||
for comp, data in graph.nodes(data=True):
|
||||
if comp in ["input", "output"]:
|
||||
continue
|
||||
|
||||
# styling based on whether the component is a SuperComponent
|
||||
if comp in super_component_components:
|
||||
super_component_name = super_component_mapping[comp] # type:ignore
|
||||
style = super_component_name
|
||||
else:
|
||||
style = "component"
|
||||
node_def = f'{comp}["<b>{comp}</b><br><small><i>{type(data["instance"]).__name__}{optional_inputs[comp]}</i></small>"]:::{style}' # noqa: E501
|
||||
states[comp] = node_def
|
||||
|
||||
connections_list = []
|
||||
for from_comp, to_comp, conn_data in graph.edges(data=True):
|
||||
if from_comp != "input" and to_comp != "output":
|
||||
arrowtail = ARROWTAIL_MANDATORY if conn_data["mandatory"] else ARROWTAIL_OPTIONAL
|
||||
arrowhead = ARROWHEAD_MANDATORY if conn_data["mandatory"] else ARROWHEAD_OPTIONAL
|
||||
label = f'"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"'
|
||||
conn_string = f"{states[from_comp]} {arrowtail} {label} {arrowhead} {states[to_comp]}"
|
||||
connections_list.append(conn_string)
|
||||
|
||||
input_connections = [
|
||||
f'i{{*}}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> {states[to_comp]}'
|
||||
for _, to_comp, conn_data in graph.out_edges("input", data=True)
|
||||
]
|
||||
output_connections = [
|
||||
f'{states[from_comp]}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> o{{*}}'
|
||||
for from_comp, _, conn_data in graph.in_edges("output", data=True)
|
||||
]
|
||||
connections = "\n".join(connections_list + input_connections + output_connections)
|
||||
|
||||
# Create legend
|
||||
legend_nodes = []
|
||||
if super_component_colors:
|
||||
legend_nodes.append("subgraph Legend")
|
||||
for super_comp in super_component_colors:
|
||||
legend_id = f"legend_{super_comp}"
|
||||
legend_nodes.append(f'{legend_id}["{super_comp}"]:::{super_comp}')
|
||||
legend_nodes.append("end")
|
||||
connections += "\n" + "\n".join(legend_nodes)
|
||||
|
||||
# Add style definitions to the template
|
||||
graph_styled = MERMAID_STYLED_TEMPLATE.format(
|
||||
params=init_params, connections=connections, style_definitions="\n".join(style_definitions)
|
||||
)
|
||||
logger.debug("Mermaid diagram:\n{diagram}", diagram=graph_styled)
|
||||
|
||||
return graph_styled
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import heapq
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
from functools import wraps
|
||||
from itertools import count
|
||||
from typing import Any
|
||||
|
||||
from haystack import logging
|
||||
from haystack.core.component import Component
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _deepcopy_with_exceptions(obj: Any) -> Any:
|
||||
"""
|
||||
Attempts to perform a deep copy of the given object.
|
||||
|
||||
This function recursively handles common container types (lists, tuples, sets, and dicts) to ensure deep copies
|
||||
of nested structures. For specific object types that are known to be problematic for deepcopying-such as
|
||||
instances of `Component`, `Tool`, or `Toolset` - the original object is returned as-is.
|
||||
If `deepcopy` fails for any other reason, the original object is returned and a log message is recorded.
|
||||
|
||||
:param obj: The object to be deep-copied.
|
||||
|
||||
:returns:
|
||||
A deep-copied version of the object, or the original object if deepcopying fails.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from haystack.tools.tool import Tool
|
||||
from haystack.tools.toolset import Toolset
|
||||
|
||||
if isinstance(obj, (list, tuple, set)):
|
||||
return type(obj)(_deepcopy_with_exceptions(v) for v in obj)
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {k: _deepcopy_with_exceptions(v) for k, v in obj.items()}
|
||||
|
||||
# Components and Tools often contain objects that we do not want to deepcopy or are not deepcopyable
|
||||
# (e.g. models, clients, etc.). In this case we return the object as-is.
|
||||
if isinstance(obj, (Component, Tool, Toolset)):
|
||||
return obj
|
||||
|
||||
try:
|
||||
return deepcopy(obj)
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"Deepcopy failed for object of type '{obj_type}'. Error: {error}. Returning original object instead.",
|
||||
obj_type=type(obj).__name__,
|
||||
error=e,
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def parse_connect_string(connection: str) -> tuple[str, str | None]:
|
||||
"""
|
||||
Returns component-connection pairs from a connect_to/from string.
|
||||
|
||||
:param connection:
|
||||
The connection string.
|
||||
:returns:
|
||||
A tuple containing the component name and the connection name.
|
||||
"""
|
||||
if "." in connection:
|
||||
split_str = connection.split(".", maxsplit=1)
|
||||
return (split_str[0], split_str[1])
|
||||
return connection, None
|
||||
|
||||
|
||||
class FIFOPriorityQueue:
|
||||
"""
|
||||
A priority queue that maintains FIFO order for items of equal priority.
|
||||
|
||||
Items with the same priority are processed in the order they were added.
|
||||
This queue ensures that when multiple items share the same priority level,
|
||||
they are dequeued in the same order they were enqueued (First-In-First-Out).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize a new FIFO priority queue.
|
||||
"""
|
||||
# List of tuples (priority, count, item) where count ensures FIFO order
|
||||
self._queue: list[tuple[int, int, Any]] = []
|
||||
# Counter to maintain insertion order for equal priorities
|
||||
self._counter = count()
|
||||
|
||||
def push(self, item: Any, priority: int) -> None:
|
||||
"""
|
||||
Push an item into the queue with a given priority.
|
||||
|
||||
Items with equal priority maintain FIFO ordering based on insertion time.
|
||||
Lower priority numbers are dequeued first.
|
||||
|
||||
:param item:
|
||||
The item to insert into the queue.
|
||||
:param priority:
|
||||
Priority level for the item. Lower numbers indicate higher priority.
|
||||
"""
|
||||
next_count = next(self._counter)
|
||||
entry = (priority, next_count, item)
|
||||
heapq.heappush(self._queue, entry)
|
||||
|
||||
def pop(self) -> tuple[int, Any]:
|
||||
"""
|
||||
Remove and return the highest priority item from the queue.
|
||||
|
||||
For items with equal priority, returns the one that was inserted first.
|
||||
|
||||
:returns:
|
||||
A tuple containing (priority, item) with the lowest priority number.
|
||||
:raises IndexError:
|
||||
If the queue is empty.
|
||||
"""
|
||||
if not self._queue:
|
||||
raise IndexError("pop from empty queue")
|
||||
priority, _, item = heapq.heappop(self._queue)
|
||||
return priority, item
|
||||
|
||||
def peek(self) -> tuple[int, Any]:
|
||||
"""
|
||||
Return but don't remove the highest priority item from the queue.
|
||||
|
||||
For items with equal priority, returns the one that was inserted first.
|
||||
|
||||
:returns:
|
||||
A tuple containing (priority, item) with the lowest priority number.
|
||||
:raises IndexError:
|
||||
If the queue is empty.
|
||||
"""
|
||||
if not self._queue:
|
||||
raise IndexError("peek at empty queue")
|
||||
priority, _, item = self._queue[0]
|
||||
return priority, item
|
||||
|
||||
def get(self) -> tuple[int, Any] | None:
|
||||
"""
|
||||
Remove and return the highest priority item from the queue.
|
||||
|
||||
For items with equal priority, returns the one that was inserted first.
|
||||
Unlike pop(), returns None if the queue is empty instead of raising an exception.
|
||||
|
||||
:returns:
|
||||
A tuple containing (priority, item), or None if the queue is empty.
|
||||
"""
|
||||
if not self._queue:
|
||||
return None
|
||||
priority, _, item = heapq.heappop(self._queue)
|
||||
return priority, item
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""
|
||||
Return the number of items in the queue.
|
||||
|
||||
:returns:
|
||||
The number of items currently in the queue.
|
||||
"""
|
||||
return len(self._queue)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""
|
||||
Return True if the queue has items, False if empty.
|
||||
|
||||
:returns:
|
||||
True if the queue contains items, False otherwise.
|
||||
"""
|
||||
return bool(self._queue)
|
||||
|
||||
|
||||
def args_deprecated(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator to warn about the use of positional arguments in a function.
|
||||
|
||||
Adapted from https://stackoverflow.com/questions/68432070/
|
||||
:param func:
|
||||
"""
|
||||
|
||||
def _positional_arg_warning() -> None:
|
||||
"""
|
||||
Triggers a warning message if positional arguments are used in a function
|
||||
"""
|
||||
import warnings
|
||||
|
||||
msg = (
|
||||
"Warning: In an upcoming release, this method will require keyword arguments for all parameters. "
|
||||
"Please update your code to use keyword arguments to ensure future compatibility. "
|
||||
)
|
||||
warnings.warn(msg, DeprecationWarning, stacklevel=2)
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
# call the function first, to make sure the signature matches
|
||||
ret_value = func(*args, **kwargs)
|
||||
|
||||
# A Pipeline instance is always the first argument - remove it from the args to check for positional arguments
|
||||
# We check the class name as strings to avoid circular imports
|
||||
if args and isinstance(args, tuple) and args[0].__class__.__name__ in ["Pipeline", "PipelineBase"]:
|
||||
args = args[1:]
|
||||
|
||||
if args:
|
||||
_positional_arg_warning()
|
||||
return ret_value
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,381 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from haystack.core.component.component import _hook_component_init
|
||||
from haystack.core.errors import DeserializationError, SerializationError
|
||||
|
||||
# `allow_deserialization_module` is re-exported here to enable all serialization-specific imports
|
||||
# from haystack.core.serialization.
|
||||
# The redundant `as` alias marks it as an intentional re-export so ruff does not flag it (F401).
|
||||
from haystack.core.serialization_security import allow_deserialization_module as allow_deserialization_module
|
||||
from haystack.utils.auth import Secret
|
||||
from haystack.utils.device import ComponentDevice
|
||||
from haystack.utils.type_serialization import _import_class_by_name
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeserializationCallbacks:
|
||||
"""
|
||||
Callback functions that are invoked in specific stages of the pipeline deserialization process.
|
||||
|
||||
:param component_pre_init:
|
||||
Invoked just before a component instance is
|
||||
initialized. Receives the following inputs:
|
||||
`component_name` (`str`), `component_class` (`Type`), `init_params` (`dict[str, Any]`).
|
||||
|
||||
The callback is allowed to modify the `init_params`
|
||||
dictionary, which contains all the parameters that
|
||||
are passed to the component's constructor.
|
||||
"""
|
||||
|
||||
component_pre_init: Callable | None = None
|
||||
|
||||
|
||||
def component_to_dict(obj: Any, name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Converts a component instance into a dictionary.
|
||||
|
||||
If a `to_dict` method is present in the component instance, that will be used instead of the default method.
|
||||
|
||||
:param obj:
|
||||
The component to be serialized.
|
||||
:param name:
|
||||
The name of the component.
|
||||
:returns:
|
||||
A dictionary representation of the component.
|
||||
|
||||
:raises SerializationError:
|
||||
If the component doesn't have a `to_dict` method.
|
||||
If the values of the init parameters can't be determined.
|
||||
If a non-basic Python type is used in the serialized data.
|
||||
"""
|
||||
if hasattr(obj, "to_dict"):
|
||||
data = obj.to_dict()
|
||||
else:
|
||||
init_parameters = {}
|
||||
for param_name, param in inspect.signature(obj.__init__).parameters.items():
|
||||
# Ignore `args` and `kwargs`, used by the default constructor
|
||||
if param_name in ("args", "kwargs"):
|
||||
continue
|
||||
try:
|
||||
# This only works if the Component constructor assigns the init
|
||||
# parameter to an instance variable or property with the same name
|
||||
param_value = getattr(obj, param_name)
|
||||
except AttributeError as e:
|
||||
# If the parameter doesn't have a default value, raise an error
|
||||
if param.default is param.empty:
|
||||
raise SerializationError(
|
||||
f"Cannot determine the value of the init parameter '{param_name}' "
|
||||
f"for the class {obj.__class__.__name__}."
|
||||
f"You can fix this error by assigning 'self.{param_name} = {param_name}' or adding a "
|
||||
f"custom serialization method 'to_dict' to the class."
|
||||
) from e
|
||||
# In case the init parameter was not assigned, we use the default value
|
||||
param_value = param.default
|
||||
init_parameters[param_name] = param_value
|
||||
|
||||
data = default_to_dict(obj, **init_parameters)
|
||||
|
||||
_validate_component_to_dict_output(obj, name, data)
|
||||
return data
|
||||
|
||||
|
||||
def _validate_component_to_dict_output(component: Any, name: str, data: dict[str, Any]) -> None:
|
||||
# Ensure that only basic Python types are used in the serde data.
|
||||
def is_allowed_type(obj: Any) -> bool:
|
||||
return isinstance(obj, (str, int, float, bool, list, dict, set, tuple, type(None)))
|
||||
|
||||
def check_iterable(iterable: Iterable[Any]) -> None:
|
||||
for v in iterable:
|
||||
if not is_allowed_type(v):
|
||||
raise SerializationError(
|
||||
f"Component '{name}' of type '{type(component).__name__}' has an unsupported value "
|
||||
f"of type '{type(v).__name__}' in the serialized data."
|
||||
)
|
||||
if isinstance(v, (list, set, tuple)):
|
||||
check_iterable(v)
|
||||
elif isinstance(v, dict):
|
||||
check_dict(v)
|
||||
|
||||
def check_dict(d: dict[str, Any]) -> None:
|
||||
if any(not isinstance(k, str) for k in d):
|
||||
raise SerializationError(
|
||||
f"Component '{name}' of type '{type(component).__name__}' has a non-string key in the serialized data."
|
||||
)
|
||||
|
||||
for k, v in d.items():
|
||||
if not is_allowed_type(v):
|
||||
raise SerializationError(
|
||||
f"Component '{name}' of type '{type(component).__name__}' has an unsupported value "
|
||||
f"of type '{type(v).__name__}' in the serialized data under key '{k}'."
|
||||
)
|
||||
if isinstance(v, (list, set, tuple)):
|
||||
check_iterable(v)
|
||||
elif isinstance(v, dict):
|
||||
check_dict(v)
|
||||
|
||||
check_dict(data)
|
||||
|
||||
|
||||
def generate_qualified_class_name(cls: type[object]) -> str:
|
||||
"""
|
||||
Generates a qualified class name for a class.
|
||||
|
||||
:param cls:
|
||||
The class whose qualified name is to be generated.
|
||||
:returns:
|
||||
The qualified name of the class.
|
||||
"""
|
||||
return f"{cls.__module__}.{cls.__name__}"
|
||||
|
||||
|
||||
def component_from_dict(
|
||||
cls: type[object], data: dict[str, Any], name: str, callbacks: DeserializationCallbacks | None = None
|
||||
) -> Any:
|
||||
"""
|
||||
Creates a component instance from a dictionary.
|
||||
|
||||
If a `from_dict` method is present in the component class, that will be used instead of the default method.
|
||||
|
||||
:param cls:
|
||||
The class to be used for deserialization.
|
||||
:param data:
|
||||
The serialized data.
|
||||
:param name:
|
||||
The name of the component.
|
||||
:param callbacks:
|
||||
Callbacks to invoke during deserialization.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
|
||||
def component_pre_init_callback(component_cls: type, init_params: dict[str, Any]) -> None:
|
||||
assert callbacks is not None
|
||||
assert callbacks.component_pre_init is not None
|
||||
callbacks.component_pre_init(name, component_cls, init_params)
|
||||
|
||||
def do_from_dict() -> Any:
|
||||
if hasattr(cls, "from_dict"):
|
||||
return cls.from_dict(data)
|
||||
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
if callbacks is None or callbacks.component_pre_init is None:
|
||||
return do_from_dict()
|
||||
|
||||
with _hook_component_init(component_pre_init_callback):
|
||||
return do_from_dict()
|
||||
|
||||
|
||||
def default_to_dict(obj: Any, **init_parameters: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Utility function to serialize an object to a dictionary.
|
||||
|
||||
This is mostly necessary for components but can be used by any object.
|
||||
`init_parameters` are parameters passed to the object class `__init__`.
|
||||
They must be defined explicitly as they'll be used when creating a new
|
||||
instance of `obj` with `from_dict`. Omitting them might cause deserialisation
|
||||
errors or unexpected behaviours later, when calling `from_dict`.
|
||||
|
||||
Objects in `init_parameters` that have a `to_dict()` method are automatically
|
||||
serialized by calling that method.
|
||||
|
||||
This is the format used for saved pipeline files (`Pipeline.dump`/`Pipeline.load`). Don't merge
|
||||
it with `base_serialization._serialize_value_with_schema` — that one uses a different envelope
|
||||
for a different job (arbitrary runtime values, not Components) and changing either would break
|
||||
saved files.
|
||||
|
||||
An example usage:
|
||||
|
||||
```python
|
||||
class MyClass:
|
||||
def __init__(self, my_param: int = 10) -> None:
|
||||
self.my_param = my_param
|
||||
|
||||
def to_dict(self):
|
||||
return default_to_dict(self, my_param=self.my_param)
|
||||
|
||||
|
||||
obj = MyClass(my_param=1000)
|
||||
data = obj.to_dict()
|
||||
assert data == {
|
||||
"type": "MyClass",
|
||||
"init_parameters": {
|
||||
"my_param": 1000,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
:param obj:
|
||||
The object to be serialized.
|
||||
:param init_parameters:
|
||||
The parameters used to create a new instance of the class.
|
||||
:returns:
|
||||
A dictionary representation of the instance.
|
||||
"""
|
||||
# Automatically serialize objects that have a to_dict method
|
||||
serialized_params = {}
|
||||
for key, value in init_parameters.items():
|
||||
if value is not None and hasattr(value, "to_dict") and callable(value.to_dict):
|
||||
serialized_params[key] = value.to_dict()
|
||||
else:
|
||||
serialized_params[key] = value
|
||||
|
||||
return {"type": generate_qualified_class_name(type(obj)), "init_parameters": serialized_params}
|
||||
|
||||
|
||||
def _is_serialized_component_device(value: Any) -> bool:
|
||||
"""
|
||||
Check if a value is a serialized ComponentDevice dictionary.
|
||||
|
||||
A dictionary is considered a serialized ComponentDevice if:
|
||||
- It has "type": "single" and a "device" key with a string value, or
|
||||
- It has "type": "multiple" and a "device_map" key with a dict value
|
||||
|
||||
This matches the structure produced by ComponentDevice.to_dict().
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
|
||||
type_value = value.get("type")
|
||||
if type_value == "single":
|
||||
return "device" in value and isinstance(value["device"], str)
|
||||
if type_value == "multiple":
|
||||
return "device_map" in value and isinstance(value["device_map"], dict)
|
||||
return False
|
||||
|
||||
|
||||
def default_from_dict(cls: type[T], data: dict[str, Any]) -> T:
|
||||
"""
|
||||
Utility function to deserialize a dictionary to an object.
|
||||
|
||||
This is mostly necessary for components but can be used by any object. Reverses the
|
||||
`{"type": ..., "init_parameters": ...}` envelope produced by `default_to_dict` — see that
|
||||
function's docstring for why this envelope is not interchangeable with
|
||||
`haystack.utils.base_serialization._serialize_value_with_schema`'s.
|
||||
|
||||
The function will raise a `DeserializationError` if the `type` field in `data` is
|
||||
missing or it doesn't match the type of `cls`.
|
||||
|
||||
If `data` contains an `init_parameters` field it will be used as parameters to create
|
||||
a new instance of `cls`.
|
||||
|
||||
Serialized Secret dictionaries in `init_parameters` are automatically detected and
|
||||
deserialized. A dictionary is considered a serialized Secret if it has a "type" key
|
||||
with value "env_var".
|
||||
|
||||
Serialized ComponentDevice dictionaries in `init_parameters` are automatically detected
|
||||
and deserialized. A dictionary is considered a serialized ComponentDevice if it has a
|
||||
"type" key with value "single" or "multiple".
|
||||
|
||||
Objects in `init_parameters` that are dictionaries with a "type" key containing a fully
|
||||
qualified class name are automatically detected and deserialized if the class has a
|
||||
`from_dict()` method.
|
||||
|
||||
:param cls:
|
||||
The class to be used for deserialization.
|
||||
:param data:
|
||||
The serialized data.
|
||||
:returns:
|
||||
The deserialized object.
|
||||
|
||||
:raises DeserializationError:
|
||||
If the `type` field in `data` is missing or it doesn't match the type of `cls`.
|
||||
"""
|
||||
# Copy so that replacing serialized sub-objects (Secret/ComponentDevice/nested components) with their
|
||||
# deserialized instances below does not mutate the caller's ``data`` dict in place. Without this, a second
|
||||
# deserialization of the same dict would receive already-parsed objects instead of their serialized form.
|
||||
init_params = dict(data.get("init_parameters", {}))
|
||||
if "type" not in data:
|
||||
raise DeserializationError("Missing 'type' in serialization data")
|
||||
if data["type"] != generate_qualified_class_name(cls):
|
||||
raise DeserializationError(f"Class '{data['type']}' can't be deserialized as '{cls.__name__}'")
|
||||
|
||||
valid_init_param_names = _init_parameter_names(cls)
|
||||
|
||||
# Automatically detect and deserialize objects with from_dict methods
|
||||
for key, value in init_params.items():
|
||||
if isinstance(value, dict) and "type" in value:
|
||||
type_value = value.get("type")
|
||||
# Special handling for Secret (type == "env_var")
|
||||
if type_value == "env_var":
|
||||
init_params[key] = Secret.from_dict(value)
|
||||
# Special handling for ComponentDevice (type == "single" or "multiple")
|
||||
elif _is_serialized_component_device(value):
|
||||
init_params[key] = ComponentDevice.from_dict(value)
|
||||
# If type looks like a fully qualified class name, try to import it and deserialize
|
||||
elif isinstance(type_value, str) and "." in type_value:
|
||||
# Reject before importing if the parent class does not accept this parameter.
|
||||
# This blocks YAML that smuggles untrusted classes into unused parameter slots.
|
||||
if valid_init_param_names is not None and key not in valid_init_param_names:
|
||||
known_params = (
|
||||
f"Valid parameters are: {', '.join(repr(n) for n in sorted(valid_init_param_names))}."
|
||||
if valid_init_param_names
|
||||
else f"'{cls.__name__}' accepts no init parameters."
|
||||
)
|
||||
raise DeserializationError(
|
||||
f"Refusing to deserialize unknown parameter '{key}' for '{cls.__name__}'. {known_params} "
|
||||
f"Correct the parameter name or remove it from the serialized data."
|
||||
)
|
||||
try:
|
||||
imported_class = import_class_by_name(type_value)
|
||||
if hasattr(imported_class, "from_dict") and callable(imported_class.from_dict):
|
||||
init_params[key] = imported_class.from_dict(value)
|
||||
else:
|
||||
init_params[key] = default_from_dict(imported_class, value)
|
||||
except (ImportError, DeserializationError) as e:
|
||||
raise type(e)(f"Failed to deserialize '{key}': {e}") from e
|
||||
|
||||
return cls(**init_params)
|
||||
|
||||
|
||||
def _init_parameter_names(cls: type[object]) -> set[str] | None:
|
||||
"""
|
||||
Return the set of init parameter names accepted by `cls`.
|
||||
|
||||
Returns `None` if the constructor accepts arbitrary keyword arguments (`**kwargs`) — in
|
||||
which case we cannot validate keys.
|
||||
"""
|
||||
try:
|
||||
signature = inspect.signature(cls.__init__)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
names: set[str] = set()
|
||||
for name, param in signature.parameters.items():
|
||||
if name == "self":
|
||||
continue
|
||||
if param.kind is inspect.Parameter.VAR_KEYWORD:
|
||||
# Constructor accepts **kwargs; we cannot tell whether `key` is a real parameter.
|
||||
return None
|
||||
if param.kind is inspect.Parameter.VAR_POSITIONAL:
|
||||
continue
|
||||
names.add(name)
|
||||
return names
|
||||
|
||||
|
||||
def import_class_by_name(fully_qualified_name: str) -> type[object]:
|
||||
"""
|
||||
Utility function to import (load) a class object based on its fully qualified class name.
|
||||
|
||||
This function dynamically imports a class based on its string name.
|
||||
It splits the name into module path and class name, imports the module,
|
||||
and returns the class object.
|
||||
|
||||
For security, the module path is checked against the deserialization allowlist
|
||||
(see :mod:`haystack.core.serialization_security`). Modules outside the allowlist
|
||||
are rejected with a :class:`DeserializationError`.
|
||||
|
||||
:param fully_qualified_name: the fully qualified class name as a string
|
||||
:returns: the class object.
|
||||
:raises ImportError: If the class cannot be imported or found.
|
||||
:raises DeserializationError: If the module is not on the deserialization allowlist.
|
||||
"""
|
||||
return _import_class_by_name(fully_qualified_name)
|
||||
@@ -0,0 +1,241 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""
|
||||
Security primitives for pipeline deserialization.
|
||||
|
||||
This module provides an allowlist mechanism that gates arbitrary imports.
|
||||
|
||||
Three ways to extend the allowlist:
|
||||
- Per-call kwarg: `Pipeline.load(..., allowed_modules=["mypkg.*"])`
|
||||
- Process-wide programmatic API: :func:`allow_deserialization_module`
|
||||
- Environment variable: `HAYSTACK_DESERIALIZATION_ALLOWLIST="mypkg.*,otherpkg.*"`
|
||||
|
||||
The two-mode loading API (`unsafe=True`) bypasses the allowlist entirely.
|
||||
"""
|
||||
|
||||
import builtins
|
||||
import contextvars
|
||||
import fnmatch
|
||||
import os
|
||||
from collections.abc import Iterable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from haystack.core.errors import DeserializationError
|
||||
|
||||
# The default allowlist covers Haystack's own packages plus a small set of standard-library type modules
|
||||
# that are commonly referenced in serialized type annotations (e.g. `typing.List[str]`,
|
||||
# `collections.deque`). Importing these modules has no meaningful side effects on its own.
|
||||
DEFAULT_ALLOWED_MODULES: tuple[str, ...] = (
|
||||
"haystack",
|
||||
"haystack_integrations",
|
||||
"haystack_experimental",
|
||||
"builtins",
|
||||
"typing",
|
||||
"collections",
|
||||
)
|
||||
DESERIALIZATION_ALLOWLIST_ENV_VAR = "HAYSTACK_DESERIALIZATION_ALLOWLIST"
|
||||
|
||||
# `builtins` is on the default allowlist because deserialization legitimately needs builtin *types*
|
||||
# (e.g. `builtins.str`, used in serialized type annotations and as nested `{"type": ...}` class
|
||||
# references) and harmless builtin callables that Haystack's own serializer emits (e.g.
|
||||
# `serialize_callable(print)` -> `"builtins.print"`). The module-granular allowlist is too coarse
|
||||
# to separate those from dangerous members, so the two builtin-resolving contexts are gated
|
||||
# differently:
|
||||
# - Type / class contexts (`deserialize_type`, `import_class_by_name`) require the resolved
|
||||
# builtin to be a `type` (see :func:`_check_builtin_is_type`). That lets every builtin type
|
||||
# through while rejecting every builtin *function*, with no denylist to maintain.
|
||||
# - The callable context (`deserialize_callable`) genuinely returns functions, so it instead
|
||||
# rejects the dangerous builtin *callables* named below (see :func:`_check_not_denied_builtin`).
|
||||
_DENIED_BUILTIN_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"eval", # arbitrary code execution
|
||||
"exec", # arbitrary code execution
|
||||
"compile", # arbitrary code compilation
|
||||
"__import__", # dynamic import of any module (gateway to os/subprocess/...)
|
||||
"open", # filesystem read/write
|
||||
"getattr", # attribute-traversal gadget (classic sandbox escape)
|
||||
"setattr", # arbitrary attribute mutation
|
||||
"delattr", # arbitrary attribute deletion
|
||||
"globals", # access to module namespaces
|
||||
"locals", # access to local namespaces
|
||||
"vars", # access to object/module namespaces
|
||||
"breakpoint", # runs the PYTHONBREAKPOINT hook
|
||||
"__build_class__", # dynamic class creation
|
||||
"type", # dynamic class creation via type(name, bases, dict)
|
||||
}
|
||||
)
|
||||
|
||||
# Resolve names to objects once so callers can match by identity, which also catches aliases that
|
||||
# reach the same builtin via a different import path (e.g. `io.open is builtins.open`).
|
||||
_DENIED_BUILTIN_OBJECTS: frozenset = frozenset([getattr(builtins, name) for name in _DENIED_BUILTIN_NAMES])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DeserializationContext:
|
||||
extra_allowed: tuple[str, ...] = field(default_factory=tuple)
|
||||
unsafe: bool = False
|
||||
|
||||
|
||||
_current_context: contextvars.ContextVar[_DeserializationContext | None] = contextvars.ContextVar(
|
||||
"haystack_deserialization_context", default=None
|
||||
)
|
||||
|
||||
|
||||
def _get_context() -> _DeserializationContext:
|
||||
ctx = _current_context.get()
|
||||
return ctx if ctx is not None else _DeserializationContext()
|
||||
|
||||
|
||||
# Process-wide patterns set via allow_deserialization_module.
|
||||
_extra_allowed_modules: list[str] = []
|
||||
|
||||
|
||||
def allow_deserialization_module(pattern: str) -> None:
|
||||
"""
|
||||
Add a module pattern to the process-wide deserialization allowlist.
|
||||
|
||||
Once added, classes from modules matching the pattern can be deserialized from YAML / dict
|
||||
representations until the process exits.
|
||||
|
||||
A pattern matches a module name if:
|
||||
- The pattern contains `*`, `?` or `[` — :mod:`fnmatch` semantics are used.
|
||||
- Otherwise the pattern is treated as a prefix: a module matches if it equals the pattern or
|
||||
is a submodule of it (i.e. starts with `pattern + "."`). A trailing `.*` is stripped
|
||||
before this comparison, so `"mypkg"` and `"mypkg.*"` behave identically.
|
||||
|
||||
:param pattern:
|
||||
The module pattern to allow.
|
||||
"""
|
||||
if pattern not in _extra_allowed_modules:
|
||||
_extra_allowed_modules.append(pattern)
|
||||
|
||||
|
||||
def _module_matches(module_name: str, pattern: str) -> bool:
|
||||
"""Return whether `module_name` matches the given allowlist `pattern`."""
|
||||
# `pkg.*` (where the part before `.*` has no other wildcards) is treated as a prefix match —
|
||||
# matches `pkg` and any submodule. This is the most common form, and we want it to match
|
||||
# the bare top-level package too (which true fnmatch wouldn't, since `pkg.*` requires a
|
||||
# literal `.` to follow). Patterns like `j*on.*` keep their wildcards and fall through to
|
||||
# fnmatch so the semantics stay consistent.
|
||||
if pattern.endswith(".*") and not any(c in pattern[:-2] for c in "*?["):
|
||||
prefix = pattern[:-2]
|
||||
return module_name == prefix or module_name.startswith(prefix + ".")
|
||||
if any(c in pattern for c in "*?["):
|
||||
return fnmatch.fnmatchcase(module_name, pattern)
|
||||
return module_name == pattern or module_name.startswith(pattern + ".")
|
||||
|
||||
|
||||
def _patterns_from_env() -> list[str]:
|
||||
raw = os.environ.get(DESERIALIZATION_ALLOWLIST_ENV_VAR, "")
|
||||
return [p.strip() for p in raw.split(",") if p.strip()]
|
||||
|
||||
|
||||
def _is_module_allowed(module_name: str) -> bool:
|
||||
"""Return whether `module_name` is on the active deserialization allowlist."""
|
||||
ctx = _get_context()
|
||||
if ctx.unsafe:
|
||||
return True
|
||||
patterns: list[str] = []
|
||||
patterns.extend(DEFAULT_ALLOWED_MODULES)
|
||||
patterns.extend(_extra_allowed_modules)
|
||||
patterns.extend(_patterns_from_env())
|
||||
patterns.extend(ctx.extra_allowed)
|
||||
return any(_module_matches(module_name, p) for p in patterns)
|
||||
|
||||
|
||||
def _check_module_allowed(module_name: str) -> None:
|
||||
"""Raise :class:`DeserializationError` if `module_name` is not on the allowlist."""
|
||||
if _is_module_allowed(module_name):
|
||||
return
|
||||
raise DeserializationError(
|
||||
f"Refusing to deserialize a class from module '{module_name}': the module is not on the "
|
||||
f"trusted-module allowlist. If you trust the source of this serialized data, you can either:\n"
|
||||
f" - extend the allowlist for this call: "
|
||||
f"Pipeline.load(..., allowed_modules=['{module_name}']),\n"
|
||||
f" - extend it process-wide via haystack.core.serialization.allow_deserialization_module"
|
||||
f"('{module_name}') or the {DESERIALIZATION_ALLOWLIST_ENV_VAR} environment variable,\n"
|
||||
f" - or bypass the allowlist entirely: Pipeline.load(..., unsafe=True)."
|
||||
)
|
||||
|
||||
|
||||
def _is_denied_builtin(resolved: object) -> bool:
|
||||
"""
|
||||
Return whether `resolved` is one of the builtins denied for callable deserialization.
|
||||
|
||||
Matches by identity (not membership) so an unhashable resolved object never raises.
|
||||
"""
|
||||
return any(resolved is denied for denied in _DENIED_BUILTIN_OBJECTS)
|
||||
|
||||
|
||||
def _check_not_denied_builtin(resolved: object, handle: str) -> None:
|
||||
"""
|
||||
Reject `resolved` if it is a builtin callable that is unsafe to resolve from serialized data.
|
||||
|
||||
Used by the callable-resolution path (`deserialize_callable`). Raises
|
||||
:class:`DeserializationError` for the primitives in :data:`_DENIED_BUILTIN_NAMES`, which can
|
||||
execute code, import modules, touch the filesystem, or escape via attribute/namespace access.
|
||||
The block applies even though `builtins` is on the allowlist, because the allowlist is
|
||||
module-granular. It is intentionally bypassed in `unsafe=True` mode, which disables all
|
||||
deserialization safety checks by design.
|
||||
|
||||
:param resolved:
|
||||
The object resolved from the serialized handle.
|
||||
:param handle:
|
||||
The original serialized handle, used only for the error message.
|
||||
"""
|
||||
if _get_context().unsafe:
|
||||
return
|
||||
if _is_denied_builtin(resolved):
|
||||
name = getattr(resolved, "__name__", str(resolved))
|
||||
raise DeserializationError(
|
||||
f"Refusing to deserialize '{handle}': it resolves to the builtin '{name}', which is "
|
||||
f"blocked because it can be used to execute code, import modules, access the "
|
||||
f"filesystem, or escape via attribute access. If you trust the source of this data, "
|
||||
f"load it with unsafe=True to bypass deserialization safety checks."
|
||||
)
|
||||
|
||||
|
||||
def _check_builtin_is_type(resolved: object, handle: str) -> None:
|
||||
"""
|
||||
Reject a `builtins` member resolved in a type/class context that is not a `type`.
|
||||
|
||||
Used by `deserialize_type` and `import_class_by_name`, which resolve type annotations and class
|
||||
references — always classes. Requiring the resolved `builtins` member to be a `type` lets every
|
||||
builtin type through (e.g. `str`, `memoryview`) while rejecting every builtin *function* (e.g.
|
||||
`eval`, `exec`, `getattr`), with no denylist to maintain. Bypassed in `unsafe=True` mode.
|
||||
|
||||
:param resolved:
|
||||
The object resolved from the serialized handle.
|
||||
:param handle:
|
||||
The original serialized handle, used only for the error message.
|
||||
"""
|
||||
if _get_context().unsafe:
|
||||
return
|
||||
if not isinstance(resolved, type):
|
||||
raise DeserializationError(
|
||||
f"Refusing to deserialize '{handle}': it resolves to a builtin that is not a type and "
|
||||
f"cannot be used as a type annotation or class reference. If you trust the source of "
|
||||
f"this data, load it with unsafe=True to bypass deserialization safety checks."
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _deserialization_context(allowed_modules: Iterable[str] | None = None, unsafe: bool = False) -> Iterator[None]:
|
||||
"""
|
||||
Context manager that activates a per-call deserialization context.
|
||||
|
||||
Patterns from `allowed_modules` are appended to the parent context's patterns, and `unsafe`
|
||||
is OR-ed with the parent's `unsafe` flag — so this never narrows the active permissions.
|
||||
The previous context is restored on exit.
|
||||
"""
|
||||
parent = _get_context()
|
||||
extra = parent.extra_allowed + (tuple(allowed_modules) if allowed_modules else ())
|
||||
merged_unsafe = parent.unsafe or unsafe
|
||||
token = _current_context.set(_DeserializationContext(extra_allowed=extra, unsafe=merged_unsafe))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_current_context.reset(token)
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .super_component import SuperComponent, super_component
|
||||
|
||||
__all__ = ["SuperComponent", "super_component"]
|
||||
@@ -0,0 +1,635 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import functools
|
||||
from pathlib import Path
|
||||
from types import new_class
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from haystack import logging
|
||||
from haystack.core.component.component import component
|
||||
from haystack.core.pipeline.pipeline import Pipeline
|
||||
from haystack.core.pipeline.utils import parse_connect_string
|
||||
from haystack.core.serialization import default_from_dict, default_to_dict, generate_qualified_class_name
|
||||
from haystack.core.super_component.utils import _delegate_default, _is_compatible
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class InvalidMappingTypeError(Exception):
|
||||
"""Raised when input or output mappings have invalid types or type conflicts."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMappingValueError(Exception):
|
||||
"""Raised when input or output mappings have invalid values or missing components/sockets."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@component
|
||||
class _SuperComponent:
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Pipeline,
|
||||
input_mapping: dict[str, list[str]] | None = None,
|
||||
output_mapping: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a SuperComponent with optional input and output mappings.
|
||||
|
||||
:param pipeline: The pipeline instance to be wrapped
|
||||
:param input_mapping: A dictionary mapping component input names to pipeline input socket paths.
|
||||
If not provided, a default input mapping will be created based on all pipeline inputs.
|
||||
Example:
|
||||
```python
|
||||
input_mapping={
|
||||
"query": ["retriever.query", "prompt_builder.query"],
|
||||
}
|
||||
```
|
||||
:param output_mapping: A dictionary mapping pipeline output socket paths to component output names.
|
||||
If not provided, a default output mapping will be created based on all pipeline outputs.
|
||||
Example:
|
||||
```python
|
||||
output_mapping={
|
||||
"retriever.documents": "documents",
|
||||
"generator.replies": "replies",
|
||||
}
|
||||
```
|
||||
:raises InvalidMappingError: Raised if any mapping is invalid or type conflicts occur
|
||||
:raises ValueError: Raised if no pipeline is provided
|
||||
"""
|
||||
if pipeline is None:
|
||||
raise ValueError("Pipeline must be provided to SuperComponent.")
|
||||
|
||||
self.pipeline: Pipeline = pipeline
|
||||
|
||||
# Determine input types based on pipeline and mapping
|
||||
pipeline_inputs = self.pipeline.inputs()
|
||||
resolved_input_mapping = (
|
||||
input_mapping if input_mapping is not None else self._create_input_mapping(pipeline_inputs)
|
||||
)
|
||||
self._validate_input_mapping(pipeline_inputs, resolved_input_mapping)
|
||||
input_types = self._resolve_input_types_from_mapping(pipeline_inputs, resolved_input_mapping)
|
||||
# Set input types on the component
|
||||
for input_name, info in input_types.items():
|
||||
component.set_input_type(self, name=input_name, **info)
|
||||
|
||||
self.input_mapping: dict[str, list[str]] = resolved_input_mapping
|
||||
self._original_input_mapping = input_mapping
|
||||
|
||||
# Set output types based on pipeline and mapping
|
||||
leaf_pipeline_outputs = self.pipeline.outputs()
|
||||
all_possible_pipeline_outputs = self.pipeline.outputs(include_components_with_connected_outputs=True)
|
||||
|
||||
resolved_output_mapping = (
|
||||
output_mapping if output_mapping is not None else self._create_output_mapping(leaf_pipeline_outputs)
|
||||
)
|
||||
self._validate_output_mapping(all_possible_pipeline_outputs, resolved_output_mapping)
|
||||
output_types = self._resolve_output_types_from_mapping(all_possible_pipeline_outputs, resolved_output_mapping)
|
||||
# Set output types on the component
|
||||
component.set_output_types(self, **output_types)
|
||||
self.output_mapping: dict[str, str] = resolved_output_mapping
|
||||
self._original_output_mapping = output_mapping
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warms up the SuperComponent by warming up the wrapped pipeline.
|
||||
"""
|
||||
self.pipeline.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warms up the SuperComponent by warming up the wrapped pipeline on the serving event loop.
|
||||
"""
|
||||
await self.pipeline.warm_up_async()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous resources held by the wrapped pipeline's components.
|
||||
"""
|
||||
self.pipeline.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the async resources held by the wrapped pipeline's components.
|
||||
"""
|
||||
await self.pipeline.close_async()
|
||||
|
||||
def run(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Runs the wrapped pipeline with the provided inputs.
|
||||
|
||||
Steps:
|
||||
1. Maps the inputs from kwargs to pipeline component inputs
|
||||
2. Runs the pipeline
|
||||
3. Maps the pipeline outputs to the SuperComponent's outputs
|
||||
|
||||
:param kwargs: Keyword arguments matching the SuperComponent's input names
|
||||
:returns:
|
||||
Dictionary containing the SuperComponent's output values
|
||||
"""
|
||||
# `is not`, not `!=`: numpy/pandas/torch override `__ne__` element-wise and would crash here.
|
||||
filtered_inputs = {param: value for param, value in kwargs.items() if value is not _delegate_default}
|
||||
pipeline_inputs = self._map_explicit_inputs(input_mapping=self.input_mapping, inputs=filtered_inputs)
|
||||
include_outputs_from = self._get_include_outputs_from()
|
||||
pipeline_outputs = self.pipeline.run(data=pipeline_inputs, include_outputs_from=include_outputs_from)
|
||||
return self._map_explicit_outputs(pipeline_outputs, self.output_mapping)
|
||||
|
||||
def _get_include_outputs_from(self) -> set[str]:
|
||||
# Collecting the component names from output_mapping
|
||||
return {self._split_component_path(path)[0] for path in self.output_mapping.keys()}
|
||||
|
||||
async def run_async(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Runs the wrapped pipeline with the provided inputs async.
|
||||
|
||||
Steps:
|
||||
1. Maps the inputs from kwargs to pipeline component inputs
|
||||
2. Runs the pipeline async
|
||||
3. Maps the pipeline outputs to the SuperComponent's outputs
|
||||
|
||||
:param kwargs: Keyword arguments matching the SuperComponent's input names
|
||||
:returns:
|
||||
Dictionary containing the SuperComponent's output values
|
||||
"""
|
||||
# `is not`, not `!=`: numpy/pandas/torch override `__ne__` element-wise and would crash here.
|
||||
filtered_inputs = {param: value for param, value in kwargs.items() if value is not _delegate_default}
|
||||
pipeline_inputs = self._map_explicit_inputs(input_mapping=self.input_mapping, inputs=filtered_inputs)
|
||||
pipeline_outputs = await self.pipeline.run_async(data=pipeline_inputs)
|
||||
return self._map_explicit_outputs(pipeline_outputs, self.output_mapping)
|
||||
|
||||
@staticmethod
|
||||
def _split_component_path(path: str) -> tuple[str, str]:
|
||||
"""
|
||||
Splits a component path into a component name and a socket name.
|
||||
|
||||
:param path: A string in the format "component_name.socket_name".
|
||||
:returns:
|
||||
A tuple containing (component_name, socket_name).
|
||||
:raises InvalidMappingValueError:
|
||||
If the path format is incorrect.
|
||||
"""
|
||||
comp_name, socket_name = parse_connect_string(path)
|
||||
if socket_name is None:
|
||||
raise InvalidMappingValueError(f"Invalid path format: '{path}'. Expected 'component_name.socket_name'.")
|
||||
return comp_name, socket_name
|
||||
|
||||
def _validate_input_mapping(
|
||||
self, pipeline_inputs: dict[str, dict[str, Any]], input_mapping: dict[str, list[str]]
|
||||
) -> None:
|
||||
"""
|
||||
Validates the input mapping to ensure that specified components and sockets exist in the pipeline.
|
||||
|
||||
:param pipeline_inputs: A dictionary containing pipeline input specifications.
|
||||
:param input_mapping: A dictionary mapping wrapper input names to pipeline socket paths.
|
||||
:raises InvalidMappingTypeError:
|
||||
If the input mapping is of invalid type or contains invalid types.
|
||||
:raises InvalidMappingValueError:
|
||||
If the input mapping contains nonexistent components or sockets.
|
||||
"""
|
||||
if not isinstance(input_mapping, dict):
|
||||
raise InvalidMappingTypeError("input_mapping must be a dictionary")
|
||||
|
||||
for wrapper_input_name, pipeline_input_paths in input_mapping.items():
|
||||
if not isinstance(pipeline_input_paths, list):
|
||||
raise InvalidMappingTypeError(f"Input paths for '{wrapper_input_name}' must be a list of strings.")
|
||||
for path in pipeline_input_paths:
|
||||
comp_name, socket_name = self._split_component_path(path)
|
||||
if comp_name not in pipeline_inputs:
|
||||
raise InvalidMappingValueError(
|
||||
f"Component '{comp_name}' not found in pipeline inputs.\n"
|
||||
f"Available components: {list(pipeline_inputs.keys())}"
|
||||
)
|
||||
if socket_name not in pipeline_inputs[comp_name]:
|
||||
raise InvalidMappingValueError(
|
||||
f"Input socket '{socket_name}' not found in component '{comp_name}'.\n"
|
||||
f"Available inputs for '{comp_name}': {list(pipeline_inputs[comp_name].keys())}"
|
||||
)
|
||||
|
||||
def _resolve_input_types_from_mapping(
|
||||
self, pipeline_inputs: dict[str, dict[str, Any]], input_mapping: dict[str, list[str]]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Resolves and validates input types based on the provided input mapping.
|
||||
|
||||
This function ensures that all mapped pipeline inputs are compatible, consolidating types
|
||||
when multiple mappings exist. It also determines whether an input is mandatory or has a default value.
|
||||
|
||||
:param pipeline_inputs: A dictionary containing pipeline input specifications.
|
||||
:param input_mapping: A dictionary mapping SuperComponent inputs to pipeline socket paths.
|
||||
:returns:
|
||||
A dictionary specifying the resolved input types and their properties.
|
||||
:raises InvalidMappingTypeError:
|
||||
If the input mapping contains incompatible types.
|
||||
"""
|
||||
aggregated_inputs: dict[str, dict[str, Any]] = {}
|
||||
for wrapper_input_name, pipeline_input_paths in input_mapping.items():
|
||||
for path in pipeline_input_paths:
|
||||
comp_name, socket_name = self._split_component_path(path)
|
||||
socket_info = pipeline_inputs[comp_name][socket_name]
|
||||
|
||||
# Add to aggregated inputs
|
||||
existing_socket_info = aggregated_inputs.get(wrapper_input_name)
|
||||
if existing_socket_info is None:
|
||||
aggregated_inputs[wrapper_input_name] = {"type": socket_info["type"]}
|
||||
if not socket_info["is_mandatory"]:
|
||||
aggregated_inputs[wrapper_input_name]["default"] = _delegate_default
|
||||
continue
|
||||
|
||||
is_compatible, common_type = _is_compatible(existing_socket_info["type"], socket_info["type"])
|
||||
|
||||
if not is_compatible:
|
||||
raise InvalidMappingTypeError(
|
||||
f"Type conflict for input '{socket_name}' from component '{comp_name}'. "
|
||||
f"Existing type: {existing_socket_info['type']}, new type: {socket_info['type']}."
|
||||
)
|
||||
|
||||
# Use the common type for the aggregated input
|
||||
aggregated_inputs[wrapper_input_name]["type"] = common_type
|
||||
|
||||
# If any socket requires mandatory inputs then the aggregated input is also considered mandatory.
|
||||
# So we use the type of the mandatory input and remove the default value if it exists.
|
||||
if socket_info["is_mandatory"]:
|
||||
aggregated_inputs[wrapper_input_name].pop("default", None)
|
||||
|
||||
return aggregated_inputs
|
||||
|
||||
@staticmethod
|
||||
def _create_input_mapping(pipeline_inputs: dict[str, dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Create an input mapping from pipeline inputs.
|
||||
|
||||
:param pipeline_inputs: Dictionary of pipeline input specifications
|
||||
:returns:
|
||||
Dictionary mapping SuperComponent input names to pipeline socket paths
|
||||
"""
|
||||
input_mapping: dict[str, list[str]] = {}
|
||||
for comp_name, inputs_dict in pipeline_inputs.items():
|
||||
for socket_name in inputs_dict.keys():
|
||||
existing_socket_info = input_mapping.get(socket_name)
|
||||
if existing_socket_info is None:
|
||||
input_mapping[socket_name] = [f"{comp_name}.{socket_name}"]
|
||||
continue
|
||||
input_mapping[socket_name].append(f"{comp_name}.{socket_name}")
|
||||
return input_mapping
|
||||
|
||||
def _validate_output_mapping(
|
||||
self, pipeline_outputs: dict[str, dict[str, Any]], output_mapping: dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Validates the output mapping to ensure that specified components and sockets exist in the pipeline.
|
||||
|
||||
:param pipeline_outputs: A dictionary containing pipeline output specifications.
|
||||
:param output_mapping: A dictionary mapping pipeline socket paths to wrapper output names.
|
||||
:raises InvalidMappingTypeError:
|
||||
If the output mapping is of invalid type or contains invalid types.
|
||||
:raises InvalidMappingValueError:
|
||||
If the output mapping contains nonexistent components or sockets.
|
||||
"""
|
||||
for pipeline_output_path, wrapper_output_name in output_mapping.items():
|
||||
if not isinstance(wrapper_output_name, str):
|
||||
raise InvalidMappingTypeError("Output names in output_mapping must be strings.")
|
||||
comp_name, socket_name = self._split_component_path(pipeline_output_path)
|
||||
if comp_name not in pipeline_outputs:
|
||||
raise InvalidMappingValueError(f"Component '{comp_name}' not found among pipeline outputs.")
|
||||
if socket_name not in pipeline_outputs[comp_name]:
|
||||
raise InvalidMappingValueError(f"Output socket '{socket_name}' not found in component '{comp_name}'.")
|
||||
|
||||
def _resolve_output_types_from_mapping(
|
||||
self, pipeline_outputs: dict[str, dict[str, Any]], output_mapping: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Resolves and validates output types based on the provided output mapping.
|
||||
|
||||
This function ensures that all mapped pipeline outputs are correctly assigned to
|
||||
the corresponding SuperComponent outputs while preventing duplicate output names.
|
||||
|
||||
:param pipeline_outputs: A dictionary containing pipeline output specifications.
|
||||
:param output_mapping: A dictionary mapping pipeline output socket paths to SuperComponent output names.
|
||||
:returns:
|
||||
A dictionary mapping SuperComponent output names to their resolved types.
|
||||
:raises InvalidMappingValueError:
|
||||
If the output mapping contains duplicate output names.
|
||||
"""
|
||||
resolved_outputs = {}
|
||||
for pipeline_output_path, wrapper_output_name in output_mapping.items():
|
||||
comp_name, socket_name = self._split_component_path(pipeline_output_path)
|
||||
if wrapper_output_name in resolved_outputs:
|
||||
raise InvalidMappingValueError(f"Duplicate output name '{wrapper_output_name}' in output_mapping.")
|
||||
resolved_outputs[wrapper_output_name] = pipeline_outputs[comp_name][socket_name]["type"]
|
||||
return resolved_outputs
|
||||
|
||||
@staticmethod
|
||||
def _create_output_mapping(pipeline_outputs: dict[str, dict[str, Any]]) -> dict[str, str]:
|
||||
"""
|
||||
Create an output mapping from pipeline outputs.
|
||||
|
||||
:param pipeline_outputs: Dictionary of pipeline output specifications
|
||||
:returns:
|
||||
Dictionary mapping pipeline socket paths to SuperComponent output names
|
||||
:raises InvalidMappingValueError:
|
||||
If there are output name conflicts between components
|
||||
"""
|
||||
output_mapping = {}
|
||||
used_output_names: set[str] = set()
|
||||
for comp_name, outputs_dict in pipeline_outputs.items():
|
||||
for socket_name in outputs_dict.keys():
|
||||
if socket_name in used_output_names:
|
||||
raise InvalidMappingValueError(
|
||||
f"Output name conflict: '{socket_name}' is produced by multiple components. "
|
||||
"Please provide an output_mapping to resolve this conflict."
|
||||
)
|
||||
used_output_names.add(socket_name)
|
||||
output_mapping[f"{comp_name}.{socket_name}"] = socket_name
|
||||
return output_mapping
|
||||
|
||||
def _map_explicit_inputs(
|
||||
self, input_mapping: dict[str, list[str]], inputs: dict[str, Any]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Map inputs according to explicit input mapping.
|
||||
|
||||
:param input_mapping: Mapping configuration for inputs
|
||||
:param inputs: Input arguments provided to wrapper
|
||||
:return: Dictionary of mapped pipeline inputs
|
||||
"""
|
||||
pipeline_inputs: dict[str, dict[str, Any]] = {}
|
||||
for wrapper_input_name, pipeline_input_paths in input_mapping.items():
|
||||
if wrapper_input_name not in inputs:
|
||||
continue
|
||||
|
||||
for socket_path in pipeline_input_paths:
|
||||
comp_name, input_name = self._split_component_path(socket_path)
|
||||
if comp_name not in pipeline_inputs:
|
||||
pipeline_inputs[comp_name] = {}
|
||||
pipeline_inputs[comp_name][input_name] = inputs[wrapper_input_name]
|
||||
|
||||
return pipeline_inputs
|
||||
|
||||
def _map_explicit_outputs(
|
||||
self, pipeline_outputs: dict[str, dict[str, Any]], output_mapping: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Map outputs according to explicit output mapping.
|
||||
|
||||
:param pipeline_outputs: Raw outputs from pipeline execution
|
||||
:param output_mapping: Output mapping configuration
|
||||
:return: Dictionary of mapped outputs
|
||||
"""
|
||||
outputs: dict[str, Any] = {}
|
||||
for pipeline_output_path, wrapper_output_name in output_mapping.items():
|
||||
comp_name, socket_name = self._split_component_path(pipeline_output_path)
|
||||
if comp_name in pipeline_outputs and socket_name in pipeline_outputs[comp_name]:
|
||||
outputs[wrapper_output_name] = pipeline_outputs[comp_name][socket_name]
|
||||
return outputs
|
||||
|
||||
def _to_super_component_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert to a SuperComponent dictionary representation.
|
||||
|
||||
:return: Dictionary containing serialized SuperComponent data
|
||||
"""
|
||||
serialized_pipeline = self.pipeline.to_dict()
|
||||
serialized = default_to_dict(
|
||||
self,
|
||||
pipeline=serialized_pipeline,
|
||||
input_mapping=self._original_input_mapping,
|
||||
output_mapping=self._original_output_mapping,
|
||||
)
|
||||
serialized["type"] = generate_qualified_class_name(SuperComponent)
|
||||
return serialized
|
||||
|
||||
|
||||
@component
|
||||
class SuperComponent(_SuperComponent):
|
||||
"""
|
||||
A class for creating super components that wrap around a Pipeline.
|
||||
|
||||
This component allows for remapping of input and output socket names between the wrapped pipeline and the
|
||||
SuperComponent's input and output names. This is useful for creating higher-level components that abstract
|
||||
away the details of the wrapped pipeline.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Pipeline, SuperComponent
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.builders import ChatPromptBuilder
|
||||
from haystack.components.retrievers import InMemoryBM25Retriever
|
||||
from haystack.dataclasses.chat_message import ChatMessage
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.dataclasses import Document
|
||||
|
||||
document_store = InMemoryDocumentStore()
|
||||
documents = [
|
||||
Document(content="Paris is the capital of France."),
|
||||
Document(content="London is the capital of England."),
|
||||
]
|
||||
document_store.write_documents(documents)
|
||||
|
||||
prompt_template = [
|
||||
ChatMessage.from_user(
|
||||
'''
|
||||
According to the following documents:
|
||||
{% for document in documents %}
|
||||
{{document.content}}
|
||||
{% endfor %}
|
||||
Answer the given question: {{query}}
|
||||
Answer:
|
||||
'''
|
||||
)
|
||||
]
|
||||
|
||||
prompt_builder = ChatPromptBuilder(template=prompt_template, required_variables="*")
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("retriever", InMemoryBM25Retriever(document_store=document_store))
|
||||
pipeline.add_component("prompt_builder", prompt_builder)
|
||||
pipeline.add_component("llm", OpenAIChatGenerator())
|
||||
pipeline.connect("retriever.documents", "prompt_builder.documents")
|
||||
pipeline.connect("prompt_builder.prompt", "llm.messages")
|
||||
|
||||
# Create a super component with simplified input/output mapping
|
||||
wrapper = SuperComponent(
|
||||
pipeline=pipeline,
|
||||
input_mapping={
|
||||
"query": ["retriever.query", "prompt_builder.query"],
|
||||
},
|
||||
output_mapping={"llm.replies": "replies"}
|
||||
)
|
||||
|
||||
# Run the pipeline with simplified interface
|
||||
result = wrapper.run(query="What is the capital of France?")
|
||||
print(result)
|
||||
{'replies': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>,
|
||||
_content=[TextContent(text='The capital of France is Paris.')],...)
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the SuperComponent into a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return self._to_super_component_dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "SuperComponent":
|
||||
"""
|
||||
Deserializes the SuperComponent from a dictionary.
|
||||
|
||||
:param data: The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized SuperComponent.
|
||||
"""
|
||||
# `is_pipeline_async` is a legacy key kept only for backward compatibility.
|
||||
data["init_parameters"].pop("is_pipeline_async", None)
|
||||
pipeline = Pipeline.from_dict(data["init_parameters"]["pipeline"])
|
||||
data["init_parameters"]["pipeline"] = pipeline
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def show(self, server_url: str = "https://mermaid.ink", params: dict | None = None, timeout: int = 30) -> None:
|
||||
"""
|
||||
Display an image representing this SuperComponent's underlying pipeline in a Jupyter notebook.
|
||||
|
||||
This function generates a diagram of the Pipeline using a Mermaid server and displays it directly in
|
||||
the notebook.
|
||||
|
||||
:param server_url:
|
||||
The base URL of the Mermaid server used for rendering (default: 'https://mermaid.ink').
|
||||
See https://github.com/jihchi/mermaid.ink and https://github.com/mermaid-js/mermaid-live-editor for more
|
||||
info on how to set up your own Mermaid server.
|
||||
|
||||
:param params:
|
||||
Dictionary of customization parameters to modify the output. Refer to Mermaid documentation for more details
|
||||
Supported keys:
|
||||
- format: Output format ('img', 'svg', or 'pdf'). Default: 'img'.
|
||||
- type: Image type for /img endpoint ('jpeg', 'png', 'webp'). Default: 'png'.
|
||||
- theme: Mermaid theme ('default', 'neutral', 'dark', 'forest'). Default: 'neutral'.
|
||||
- bgColor: Background color in hexadecimal (e.g., 'FFFFFF') or named format (e.g., '!white').
|
||||
- width: Width of the output image (integer).
|
||||
- height: Height of the output image (integer).
|
||||
- scale: Scaling factor (1–3). Only applicable if 'width' or 'height' is specified.
|
||||
- fit: Whether to fit the diagram size to the page (PDF only, boolean).
|
||||
- paper: Paper size for PDFs (e.g., 'a4', 'a3'). Ignored if 'fit' is true.
|
||||
- landscape: Landscape orientation for PDFs (boolean). Ignored if 'fit' is true.
|
||||
|
||||
:param timeout:
|
||||
Timeout in seconds for the request to the Mermaid server.
|
||||
|
||||
:raises PipelineDrawingError:
|
||||
If the function is called outside of a Jupyter notebook or if there is an issue with rendering.
|
||||
"""
|
||||
self.pipeline.show(server_url=server_url, params=params, timeout=timeout)
|
||||
|
||||
def draw(
|
||||
self, path: Path, server_url: str = "https://mermaid.ink", params: dict | None = None, timeout: int = 30
|
||||
) -> None:
|
||||
"""
|
||||
Save an image representing this SuperComponent's underlying pipeline to the specified file path.
|
||||
|
||||
This function generates a diagram of the Pipeline using the Mermaid server and saves it to the provided path.
|
||||
|
||||
:param path:
|
||||
The file path where the generated image will be saved.
|
||||
:param server_url:
|
||||
The base URL of the Mermaid server used for rendering (default: 'https://mermaid.ink').
|
||||
See https://github.com/jihchi/mermaid.ink and https://github.com/mermaid-js/mermaid-live-editor for more
|
||||
info on how to set up your own Mermaid server.
|
||||
:param params:
|
||||
Dictionary of customization parameters to modify the output. Refer to Mermaid documentation for more details
|
||||
Supported keys:
|
||||
- format: Output format ('img', 'svg', or 'pdf'). Default: 'img'.
|
||||
- type: Image type for /img endpoint ('jpeg', 'png', 'webp'). Default: 'png'.
|
||||
- theme: Mermaid theme ('default', 'neutral', 'dark', 'forest'). Default: 'neutral'.
|
||||
- bgColor: Background color in hexadecimal (e.g., 'FFFFFF') or named format (e.g., '!white').
|
||||
- width: Width of the output image (integer).
|
||||
- height: Height of the output image (integer).
|
||||
- scale: Scaling factor (1–3). Only applicable if 'width' or 'height' is specified.
|
||||
- fit: Whether to fit the diagram size to the page (PDF only, boolean).
|
||||
- paper: Paper size for PDFs (e.g., 'a4', 'a3'). Ignored if 'fit' is true.
|
||||
- landscape: Landscape orientation for PDFs (boolean). Ignored if 'fit' is true.
|
||||
|
||||
:param timeout:
|
||||
Timeout in seconds for the request to the Mermaid server.
|
||||
|
||||
:raises PipelineDrawingError:
|
||||
If there is an issue with rendering or saving the image.
|
||||
"""
|
||||
self.pipeline.draw(path=path, server_url=server_url, params=params, timeout=timeout)
|
||||
|
||||
|
||||
def super_component(cls: type[T]) -> type[T]:
|
||||
"""
|
||||
Decorator that converts a class into a SuperComponent.
|
||||
|
||||
This decorator:
|
||||
1. Creates a new class that inherits from SuperComponent
|
||||
2. Copies all methods and attributes from the original class
|
||||
3. Adds initialization logic to properly set up the SuperComponent
|
||||
|
||||
The decorated class should define:
|
||||
- pipeline: A Pipeline instance in the __init__ method
|
||||
- input_mapping: Dictionary mapping component inputs to pipeline inputs (optional)
|
||||
- output_mapping: Dictionary mapping pipeline outputs to component outputs (optional)
|
||||
"""
|
||||
logger.debug("Registering {cls} as a super_component", cls=cls)
|
||||
|
||||
# Store the original __init__ method
|
||||
original_init = cls.__init__
|
||||
|
||||
# Create a new __init__ method that will initialize both the original class and SuperComponent
|
||||
def init_wrapper(self: Any, *args: Any, **kwargs: Any) -> None:
|
||||
# Call the original __init__ to set up pipeline and mappings
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
# Verify required attributes
|
||||
if not hasattr(self, "pipeline"):
|
||||
raise ValueError(f"Class {cls.__name__} decorated with @super_component must define a 'pipeline' attribute")
|
||||
|
||||
# Initialize SuperComponent
|
||||
_SuperComponent.__init__(
|
||||
self,
|
||||
pipeline=self.pipeline,
|
||||
input_mapping=getattr(self, "input_mapping", None),
|
||||
output_mapping=getattr(self, "output_mapping", None),
|
||||
)
|
||||
|
||||
# Preserve original init's signature for IDEs/docs/tools
|
||||
init_wrapper = functools.wraps(original_init)(init_wrapper)
|
||||
|
||||
# Function to copy namespace from the original class
|
||||
def copy_class_namespace(namespace: dict[str, Any]) -> None:
|
||||
"""Copy all attributes from the original class except special ones."""
|
||||
for key, val in dict(cls.__dict__).items():
|
||||
# Skip special attributes that should be recreated
|
||||
if key in ("__dict__", "__weakref__"):
|
||||
continue
|
||||
|
||||
# Override __init__ with our wrapper
|
||||
if key == "__init__":
|
||||
namespace["__init__"] = init_wrapper
|
||||
continue
|
||||
|
||||
namespace[key] = val
|
||||
|
||||
# Create a new class inheriting from SuperComponent with the original methods
|
||||
# We use (SuperComponent,) + cls.__bases__ to make the new class inherit from
|
||||
# SuperComponent and all the original class's bases
|
||||
new_cls = new_class(cls.__name__, (_SuperComponent,) + cls.__bases__, {}, copy_class_namespace)
|
||||
|
||||
# Copy other class attributes
|
||||
new_cls.__module__ = cls.__module__
|
||||
new_cls.__qualname__ = cls.__qualname__
|
||||
new_cls.__doc__ = cls.__doc__
|
||||
|
||||
# Apply the component decorator to the new class
|
||||
return component(new_cls)
|
||||
@@ -0,0 +1,191 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from types import UnionType
|
||||
from typing import Annotated, Any, get_args, get_origin
|
||||
|
||||
from haystack.core.component.types import HAYSTACK_GREEDY_VARIADIC_ANNOTATION, HAYSTACK_VARIADIC_ANNOTATION
|
||||
from haystack.utils.type_serialization import _build_pep604_union_type, _is_union_type
|
||||
|
||||
|
||||
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]:
|
||||
"""
|
||||
Check if two types are compatible (bidirectional/symmetric check).
|
||||
|
||||
:param type1: First type to compare
|
||||
:param type2: Second type to compare
|
||||
:param unwrap_nested: If True, recursively unwraps nested Optional and Variadic types.
|
||||
If False, only unwraps at the top level.
|
||||
:return: Tuple of (True if types are compatible, common type if compatible)
|
||||
"""
|
||||
type1_unwrapped = _unwrap_all(type1, recursive=unwrap_nested)
|
||||
type2_unwrapped = _unwrap_all(type2, recursive=unwrap_nested)
|
||||
|
||||
return _types_are_compatible(type1_unwrapped, type2_unwrapped)
|
||||
|
||||
|
||||
def _types_are_compatible(type1: type | UnionType, type2: type | UnionType) -> tuple[bool, type | UnionType | None]:
|
||||
"""
|
||||
Core type compatibility check implementing symmetric matching.
|
||||
|
||||
:param type1: First unwrapped type to compare
|
||||
:param type2: Second unwrapped type to compare
|
||||
:return: True if types are compatible, False otherwise
|
||||
"""
|
||||
# Handle Any type
|
||||
if type1 is Any:
|
||||
return True, type2
|
||||
if type2 is Any:
|
||||
return True, type1
|
||||
|
||||
# Direct equality
|
||||
if type1 == type2:
|
||||
return True, type1
|
||||
|
||||
type1_origin = get_origin(type1)
|
||||
type2_origin = get_origin(type2)
|
||||
|
||||
# Handle Union types (including X | Y syntax)
|
||||
if _is_union_type(type1_origin) or _is_union_type(type2_origin):
|
||||
return _check_union_compatibility(type1, type2, type1_origin, type2_origin)
|
||||
|
||||
# Handle non-Union types
|
||||
return _check_non_union_compatibility(type1, type2, type1_origin, type2_origin)
|
||||
|
||||
|
||||
def _check_union_compatibility(
|
||||
type1: type | UnionType, type2: type | UnionType, type1_origin: Any, type2_origin: Any
|
||||
) -> tuple[bool, type | UnionType | None]:
|
||||
"""Handle all Union type compatibility cases (including X | Y syntax)."""
|
||||
if _is_union_type(type1_origin) and not _is_union_type(type2_origin):
|
||||
# Find all compatible types from the union
|
||||
compatible_types = []
|
||||
for union_arg in get_args(type1):
|
||||
is_compat, common = _types_are_compatible(union_arg, type2)
|
||||
if is_compat and common is not None:
|
||||
compatible_types.append(common)
|
||||
if compatible_types:
|
||||
result_type = _build_pep604_union_type(compatible_types)
|
||||
return True, result_type
|
||||
return False, None
|
||||
|
||||
if _is_union_type(type2_origin) and not _is_union_type(type1_origin):
|
||||
# Find all compatible types from the union
|
||||
compatible_types = []
|
||||
for union_arg in get_args(type2):
|
||||
is_compat, common = _types_are_compatible(type1, union_arg)
|
||||
if is_compat and common is not None:
|
||||
compatible_types.append(common)
|
||||
if compatible_types:
|
||||
result_type = _build_pep604_union_type(compatible_types)
|
||||
return True, result_type
|
||||
return False, None
|
||||
|
||||
# Both are Union types
|
||||
compatible_types = []
|
||||
for arg1 in get_args(type1):
|
||||
for arg2 in get_args(type2):
|
||||
is_compat, common = _types_are_compatible(arg1, arg2)
|
||||
if is_compat and common is not None:
|
||||
compatible_types.append(common)
|
||||
|
||||
if compatible_types:
|
||||
result_type = _build_pep604_union_type(compatible_types)
|
||||
return True, result_type
|
||||
return False, None
|
||||
|
||||
|
||||
def _check_non_union_compatibility(
|
||||
type1: type | UnionType, type2: type | UnionType, type1_origin: Any, type2_origin: Any
|
||||
) -> tuple[bool, type | UnionType | None]:
|
||||
"""Handle non-Union type compatibility cases."""
|
||||
# If no origin, compare types directly
|
||||
if not type1_origin and not type2_origin:
|
||||
if type1 == type2:
|
||||
return True, type1
|
||||
return False, None
|
||||
|
||||
# Both must have origins and they must be equal
|
||||
if not (type1_origin and type2_origin and type1_origin == type2_origin):
|
||||
return False, None
|
||||
|
||||
# Compare generic type arguments
|
||||
type1_args = get_args(type1)
|
||||
type2_args = get_args(type2)
|
||||
|
||||
if len(type1_args) != len(type2_args):
|
||||
return False, None
|
||||
|
||||
# Check if all arguments are compatible
|
||||
common_args = []
|
||||
for t1_arg, t2_arg in zip(type1_args, type2_args, strict=True):
|
||||
is_compat, common = _types_are_compatible(t1_arg, t2_arg)
|
||||
if not is_compat:
|
||||
return False, None
|
||||
common_args.append(common)
|
||||
|
||||
# Reconstruct the type with common arguments
|
||||
return True, type1_origin[tuple(common_args)]
|
||||
|
||||
|
||||
def _unwrap_all(t: type | UnionType, recursive: bool) -> type | UnionType:
|
||||
"""
|
||||
Unwrap a type until no more unwrapping is possible.
|
||||
|
||||
:param t: Type to unwrap
|
||||
:param recursive: If True, recursively unwraps nested types
|
||||
:return: The fully unwrapped type
|
||||
"""
|
||||
# First handle top-level Variadic/GreedyVariadic
|
||||
if _is_variadic_type(t):
|
||||
t = _unwrap_variadics(t, recursive=recursive)
|
||||
else:
|
||||
# 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)):
|
||||
unwrapped_args = tuple(_unwrap_all(arg, recursive) for arg in args)
|
||||
# types.UnionType (PEP 604 X | Y) is not subscriptable, so we use _build_pep604_union_type
|
||||
if origin is UnionType:
|
||||
t = _build_pep604_union_type(list(unwrapped_args))
|
||||
else:
|
||||
t = origin[unwrapped_args]
|
||||
|
||||
return t
|
||||
|
||||
|
||||
def _is_variadic_type(t: type | UnionType) -> bool:
|
||||
"""Check if type is a Variadic or GreedyVariadic type."""
|
||||
origin = get_origin(t)
|
||||
if origin is Annotated:
|
||||
args = get_args(t)
|
||||
return len(args) >= 2 and args[1] in (HAYSTACK_VARIADIC_ANNOTATION, HAYSTACK_GREEDY_VARIADIC_ANNOTATION) # noqa: PLR2004
|
||||
return False
|
||||
|
||||
|
||||
def _unwrap_variadics(t: type | UnionType, recursive: bool) -> type | UnionType:
|
||||
"""
|
||||
Unwrap Variadic or GreedyVariadic annotated types.
|
||||
|
||||
:param t: Type to unwrap
|
||||
:param recursive: If True, recursively unwraps nested types
|
||||
:return: Unwrapped type if it was a variadic type, original type otherwise
|
||||
"""
|
||||
if not _is_variadic_type(t):
|
||||
return t
|
||||
|
||||
args = get_args(t)
|
||||
# Get the Iterable[X] type and extract X
|
||||
iterable_type = args[0]
|
||||
inner_type = get_args(iterable_type)[0]
|
||||
|
||||
# Only recursively unwrap if requested
|
||||
if recursive:
|
||||
return _unwrap_all(inner_type, recursive)
|
||||
return inner_type
|
||||
@@ -0,0 +1,299 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import collections.abc
|
||||
from enum import Enum
|
||||
from types import NoneType, UnionType
|
||||
from typing import Any, Union, get_args, get_origin
|
||||
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
|
||||
class ConversionStrategy(Enum):
|
||||
"""
|
||||
Strategies for converting values between compatible types in pipeline connections.
|
||||
"""
|
||||
|
||||
CHAT_MESSAGE_TO_STR = "chat_message_to_str"
|
||||
STR_TO_CHAT_MESSAGE = "str_to_chat_message"
|
||||
WRAP = "wrap"
|
||||
WRAP_CHAT_MESSAGE_TO_STR = "wrap_chat_message_to_str"
|
||||
WRAP_STR_TO_CHAT_MESSAGE = "wrap_str_to_chat_message"
|
||||
UNWRAP = "unwrap"
|
||||
UNWRAP_STR_TO_CHAT_MESSAGE = "unwrap_str_to_chat_message"
|
||||
UNWRAP_CHAT_MESSAGE_TO_STR = "unwrap_chat_message_to_str"
|
||||
|
||||
|
||||
ConversionStrategyType = ConversionStrategy | None
|
||||
|
||||
|
||||
def _type_name(type_: Any) -> str:
|
||||
"""
|
||||
Util methods to get a nice readable representation of a type.
|
||||
|
||||
Handles Optional and Literal in a special way to make it more readable.
|
||||
"""
|
||||
# Literal args are strings, so we wrap them in quotes to make it clear
|
||||
if isinstance(type_, str):
|
||||
return f"'{type_}'"
|
||||
|
||||
if type_ is NoneType:
|
||||
return "None"
|
||||
|
||||
args = get_args(type_)
|
||||
|
||||
if isinstance(type_, UnionType):
|
||||
return " | ".join([_type_name(a) for a in args])
|
||||
|
||||
name = getattr(type_, "__name__", str(type_))
|
||||
if name.startswith("typing."):
|
||||
name = name[7:]
|
||||
if "[" in name:
|
||||
name = name.split("[")[0]
|
||||
|
||||
if name == "Union" and NoneType in args and len(args) == 2:
|
||||
# Optional is technically a Union of type and None
|
||||
# but we want to display it as Optional
|
||||
name = "Optional"
|
||||
|
||||
if args:
|
||||
args_str = ", ".join([_type_name(a) for a in args if a is not NoneType])
|
||||
return f"{name}[{args_str}]"
|
||||
|
||||
return f"{name}"
|
||||
|
||||
|
||||
def _safe_get_origin(_type: type | UnionType) -> Any:
|
||||
"""
|
||||
Safely retrieves the origin type of a generic alias or returns the type itself if it's a built-in.
|
||||
|
||||
This function extends the behavior of `typing.get_origin()` by also handling plain built-in types
|
||||
like `list`, `dict`, etc., which `get_origin()` would normally return `None` for.
|
||||
|
||||
:param _type: A type or generic alias (e.g., `list`, `list[int]`, `dict[str, int]`).
|
||||
|
||||
:returns: The origin type (e.g., `list`, `dict`), or `None` if the input is not a type.
|
||||
"""
|
||||
origin = get_origin(_type) or (_type if isinstance(_type, type) else None)
|
||||
# We want to treat typing.Union and UnionType as the same for compatibility checks.
|
||||
# So we convert UnionType to Union if it is detected.
|
||||
if origin is UnionType:
|
||||
origin = Union
|
||||
return origin
|
||||
|
||||
|
||||
def _contains_type(container: Any, target: Any) -> bool:
|
||||
"""Checks if the container type includes the target type"""
|
||||
if container == target:
|
||||
return True
|
||||
return _safe_get_origin(container) is Union and target in get_args(container)
|
||||
|
||||
|
||||
def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool: # noqa: PLR0911
|
||||
"""
|
||||
Checks whether the sender type is equal to or a subtype of the receiver type under strict validation.
|
||||
|
||||
Note: this method has no pretense to perform complete type matching.
|
||||
Consider simplifying the typing of your components if you observe unexpected errors during component connection.
|
||||
|
||||
:param sender: The sender type.
|
||||
:param receiver: The receiver type.
|
||||
:return: True if the sender type is strictly compatible with the receiver type, False otherwise.
|
||||
"""
|
||||
if sender == receiver or receiver is Any:
|
||||
return True
|
||||
|
||||
if sender is Any:
|
||||
return False
|
||||
|
||||
try:
|
||||
if issubclass(sender, receiver):
|
||||
return True
|
||||
except TypeError: # typing classes can't be used with issubclass, so we deal with them below
|
||||
pass
|
||||
|
||||
sender_origin = _safe_get_origin(sender)
|
||||
receiver_origin = _safe_get_origin(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)):
|
||||
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))
|
||||
|
||||
# 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:
|
||||
return _check_callable_compatibility(sender_args, receiver_args)
|
||||
|
||||
# Handle bare types
|
||||
if not sender_args and sender_origin:
|
||||
sender_args = (Any,)
|
||||
if not receiver_args and receiver_origin:
|
||||
receiver_args = (Any,) * (len(sender_args) if sender_args else 1)
|
||||
|
||||
return not (len(sender_args) > len(receiver_args)) and all(
|
||||
_strict_types_are_compatible(*args) for args in zip(sender_args, receiver_args, strict=False)
|
||||
)
|
||||
|
||||
|
||||
def _check_callable_compatibility(sender_args: tuple[Any, ...], receiver_args: tuple[Any, ...]) -> bool:
|
||||
"""Helper function to check compatibility of Callable types"""
|
||||
if not receiver_args:
|
||||
return True
|
||||
if not sender_args:
|
||||
sender_args = ([Any] * len(receiver_args[0]), Any)
|
||||
# Standard Callable has two elements in args: argument list and return type
|
||||
if len(sender_args) != 2 or len(receiver_args) != 2:
|
||||
return False
|
||||
# Return types must be compatible
|
||||
if not _strict_types_are_compatible(sender_args[1], receiver_args[1]):
|
||||
return False
|
||||
# Input Arguments must be of same length
|
||||
if len(sender_args[0]) != len(receiver_args[0]):
|
||||
return False
|
||||
return all(_strict_types_are_compatible(sender_args[0][i], receiver_args[0][i]) for i in range(len(sender_args[0])))
|
||||
|
||||
|
||||
def _get_conversion_strategy(sender: Any, receiver: Any) -> ConversionStrategyType: # noqa: PLR0911
|
||||
"""
|
||||
Determines whether a conversion exists from sender to receiver.
|
||||
|
||||
:param sender: The sender type.
|
||||
:param receiver: The receiver type.
|
||||
|
||||
:returns: The ConversionStrategy if conversion is required and supported, otherwise None.
|
||||
"""
|
||||
# If sender is a Union, it's only compatible if ALL its types are compatible with the same strategy
|
||||
if _safe_get_origin(sender) is Union:
|
||||
strategies = {_get_conversion_strategy(arg, receiver) for arg in get_args(sender)}
|
||||
if len(strategies) == 1:
|
||||
return strategies.pop()
|
||||
return None
|
||||
|
||||
# If receiver is a Union, it's compatible if ANY of its types are compatible.
|
||||
# We prefer strategies that don't require type conversion if possible.
|
||||
if _safe_get_origin(receiver) is Union:
|
||||
strategies = {_get_conversion_strategy(sender, arg) for arg in get_args(receiver)} - {None}
|
||||
for preferred in (ConversionStrategy.WRAP, ConversionStrategy.UNWRAP):
|
||||
if preferred in strategies:
|
||||
return preferred
|
||||
return strategies.pop() if strategies else None
|
||||
|
||||
# ChatMessage -> str
|
||||
if sender is ChatMessage and receiver is str:
|
||||
return ConversionStrategy.CHAT_MESSAGE_TO_STR
|
||||
|
||||
# str -> ChatMessage
|
||||
if sender is str and receiver is ChatMessage:
|
||||
return ConversionStrategy.STR_TO_CHAT_MESSAGE
|
||||
|
||||
# Wrap: T -> List[T]
|
||||
if _safe_get_origin(receiver) is list and (args := get_args(receiver)):
|
||||
inner = args[0]
|
||||
if _strict_types_are_compatible(sender, inner):
|
||||
return ConversionStrategy.WRAP
|
||||
# Wrap + conversion
|
||||
if _contains_type(sender, ChatMessage) and _contains_type(inner, str):
|
||||
return ConversionStrategy.WRAP_CHAT_MESSAGE_TO_STR
|
||||
if _contains_type(sender, str) and _contains_type(inner, ChatMessage):
|
||||
return ConversionStrategy.WRAP_STR_TO_CHAT_MESSAGE
|
||||
|
||||
# Unwrap: list[T] -> T, restricted to str / ChatMessage to avoid silent drop of list[1:].
|
||||
if _safe_get_origin(sender) is list and (args := get_args(sender)):
|
||||
inner = args[0]
|
||||
# Guard against multi-level unwrap (e.g. list[list[str]] -> list[str])
|
||||
if (
|
||||
_safe_get_origin(receiver) is not list
|
||||
and inner in (str, ChatMessage)
|
||||
and _strict_types_are_compatible(inner, receiver)
|
||||
):
|
||||
return ConversionStrategy.UNWRAP
|
||||
# Unwrap + conversion
|
||||
# Check that all possible types in the sender list can be converted to the receiver type
|
||||
# using the same strategy by recursively calling _get_conversion_strategy on each inner element type.
|
||||
inner_strategy = _get_conversion_strategy(inner, receiver)
|
||||
if inner_strategy == ConversionStrategy.STR_TO_CHAT_MESSAGE:
|
||||
return ConversionStrategy.UNWRAP_STR_TO_CHAT_MESSAGE
|
||||
if inner_strategy == ConversionStrategy.CHAT_MESSAGE_TO_STR:
|
||||
return ConversionStrategy.UNWRAP_CHAT_MESSAGE_TO_STR
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _types_are_compatible(
|
||||
sender: Any, receiver: Any, type_validation: bool = True
|
||||
) -> tuple[bool, ConversionStrategyType]:
|
||||
"""
|
||||
Determines whether two types are compatible, optionally allowing conversion.
|
||||
|
||||
:param sender: The sender type.
|
||||
:param receiver: The receiver type.
|
||||
:param type_validation: If False, all types are considered compatible.
|
||||
|
||||
:returns: A tuple of (is_compatible, conversion_strategy) where:
|
||||
- is_compatible is True if the types are strictly compatible or can be converted.
|
||||
- conversion_strategy is a ConversionStrategy if conversion is required, otherwise None
|
||||
(including when types are strictly compatible, incompatible, or type validation is disabled).
|
||||
"""
|
||||
if not type_validation:
|
||||
return True, None
|
||||
|
||||
if _strict_types_are_compatible(sender, receiver):
|
||||
return True, None
|
||||
|
||||
conversion_strategy = _get_conversion_strategy(sender, receiver)
|
||||
if conversion_strategy:
|
||||
return True, conversion_strategy
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def _chat_message_to_str(value: Any) -> str:
|
||||
if value.text is None:
|
||||
raise ValueError("Cannot convert `ChatMessage` to `str` because it has no text. ")
|
||||
return value.text
|
||||
|
||||
|
||||
def _get_first_item(value: list[Any]) -> Any:
|
||||
"""Returns the only element of a one-element list. Raises on empty or multi-element input."""
|
||||
if not value:
|
||||
raise ValueError("Cannot get first item of an empty list. ")
|
||||
if len(value) > 1:
|
||||
raise ValueError(
|
||||
f"Cannot unwrap a list of {len(value)} items to a single value: "
|
||||
"a list-to-scalar connection only accepts one-element lists; otherwise items would be silently dropped."
|
||||
)
|
||||
return value[0]
|
||||
|
||||
|
||||
def _convert_value(value: Any, conversion_strategy: ConversionStrategy) -> Any: # noqa: PLR0911
|
||||
"""
|
||||
Converts a value using the specified conversion strategy.
|
||||
"""
|
||||
match conversion_strategy:
|
||||
case ConversionStrategy.CHAT_MESSAGE_TO_STR:
|
||||
return _chat_message_to_str(value)
|
||||
case ConversionStrategy.STR_TO_CHAT_MESSAGE:
|
||||
return ChatMessage.from_user(value)
|
||||
case ConversionStrategy.WRAP:
|
||||
return [value]
|
||||
case ConversionStrategy.WRAP_CHAT_MESSAGE_TO_STR:
|
||||
return [_chat_message_to_str(value)]
|
||||
case ConversionStrategy.WRAP_STR_TO_CHAT_MESSAGE:
|
||||
return [ChatMessage.from_user(value)]
|
||||
case ConversionStrategy.UNWRAP:
|
||||
return _get_first_item(value)
|
||||
case ConversionStrategy.UNWRAP_STR_TO_CHAT_MESSAGE:
|
||||
return ChatMessage.from_user(_get_first_item(value))
|
||||
case ConversionStrategy.UNWRAP_CHAT_MESSAGE_TO_STR:
|
||||
return _chat_message_to_str(_get_first_item(value))
|
||||
Reference in New Issue
Block a user