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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+7
View File
@@ -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"]
+8
View File
@@ -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"]
+644
View File
@@ -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()
+143
View File
@@ -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()])
+137
View File
@@ -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)
+175
View File
@@ -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)
+7
View File
@@ -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
+345
View File
@@ -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,
}
+215
View File
@@ -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
+51
View File
@@ -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)
}
+432
View File
@@ -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 (13). 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{{&ast;}}--"{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{{&ast;}}'
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
+207
View File
@@ -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
+381
View File
@@ -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)
+241
View File
@@ -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 (13). 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 (13). 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)
+191
View File
@@ -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
+299
View File
@@ -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))