Files
wehub-resource-sync c56bef871b
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
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

180 lines
6.6 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import ast
import contextlib
from collections.abc import Callable
from typing import Any, TypeAlias
import jinja2.runtime
from jinja2 import TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment
from jinja2.sandbox import SandboxedEnvironment
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
logger = logging.getLogger(__name__)
class OutputAdaptationException(Exception):
"""Exception raised when there is an error during output adaptation."""
@component
class OutputAdapter:
"""
Adapts output of a Component using Jinja templates.
Usage example:
```python
from haystack import Document
from haystack.components.converters import OutputAdapter
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
documents = [Document(content="Test content")]
result = adapter.run(documents=documents)
assert result["output"] == "Test content"
```
"""
def __init__(
self,
template: str,
output_type: TypeAlias,
custom_filters: dict[str, Callable] | None = None,
unsafe: bool = False,
) -> None:
"""
Create an OutputAdapter component.
:param template:
A Jinja template that defines how to adapt the input data.
The variables in the template define the input of this instance.
e.g.
With this template:
```
{{ documents[0].content }}
```
The Component input will be `documents`.
:param output_type:
The type of output this instance will return.
:param custom_filters:
A dictionary of custom Jinja filters used in the template.
:param unsafe:
Enable execution of arbitrary code in the Jinja template.
This should only be used if you trust the source of the template as it can be lead to remote code execution.
"""
self.custom_filters = {**(custom_filters or {})}
input_types: set[str] = set()
self._unsafe = unsafe
if self._unsafe:
msg = (
"Unsafe mode is enabled. This allows execution of arbitrary code in the Jinja template. "
"Use this only if you trust the source of the template."
)
logger.warning(msg)
self._env = (
NativeEnvironment() if self._unsafe else SandboxedEnvironment(undefined=jinja2.runtime.StrictUndefined)
)
try:
self._env.parse(template) # Validate template syntax
self.template = template
except TemplateSyntaxError as e:
raise ValueError(f"Invalid Jinja template '{template}': {e}") from e
for name, filter_func in self.custom_filters.items():
self._env.filters[name] = filter_func
# b) extract variables in the template
assigned_variables, template_variables = _extract_template_variables_and_assignments(
env=self._env, template=self.template
)
route_input_names = template_variables - assigned_variables
input_types.update(route_input_names)
# the env is not needed, discarded automatically
component.set_input_types(self, **dict.fromkeys(input_types, Any))
component.set_output_types(self, output=output_type)
self.output_type = output_type
def run(self, **kwargs: Any) -> dict[str, Any]:
"""
Renders the Jinja template with the provided inputs.
:param kwargs:
Must contain all variables used in the `template` string.
:returns:
A dictionary with the following keys:
- `output`: Rendered Jinja template.
:raises OutputAdaptationException: If template rendering fails.
"""
# check if kwargs are empty
if not kwargs:
raise ValueError("No input data provided for output adaptation")
for name, filter_func in self.custom_filters.items():
self._env.filters[name] = filter_func
adapted_outputs = {}
try:
adapted_output_template = self._env.from_string(self.template)
output_result = adapted_output_template.render(**kwargs)
if isinstance(output_result, jinja2.runtime.Undefined):
raise OutputAdaptationException(f"Undefined variable in the template {self.template}; kwargs: {kwargs}") # noqa: TRY301
# We suppress the exception in case the output is already a string, otherwise
# we try to evaluate it and would fail.
# This must be done cause the output could be different literal structures.
# This doesn't support any user types.
with contextlib.suppress(Exception):
if not self._unsafe:
output_result = ast.literal_eval(output_result)
adapted_outputs["output"] = output_result
except Exception as e:
raise OutputAdaptationException(f"Error adapting {self.template} with {kwargs}: {e}") from e
return adapted_outputs
def to_dict(self) -> dict[str, Any]:
"""
Serializes the component to a dictionary.
:returns:
Dictionary with serialized data.
"""
se_filters = {name: serialize_callable(filter_func) for name, filter_func in self.custom_filters.items()}
return default_to_dict(
self,
template=self.template,
output_type=serialize_type(self.output_type),
custom_filters=se_filters,
unsafe=self._unsafe,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "OutputAdapter":
"""
Deserializes the component from a dictionary.
:param data:
The dictionary to deserialize from.
:returns:
The deserialized component.
"""
init_params = data.get("init_parameters", {})
init_params["output_type"] = deserialize_type(init_params["output_type"])
custom_filters = init_params.get("custom_filters", {})
if custom_filters:
init_params["custom_filters"] = {
name: deserialize_callable(filter_func) if filter_func else None
for name, filter_func in custom_filters.items()
}
return default_from_dict(cls, data)