Coverage for haystack/core/serialization.py: 95%
129 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import inspect
6from collections.abc import Callable, Iterable
7from dataclasses import dataclass
8from typing import Any, TypeVar
10from haystack.core.component.component import _hook_component_init
11from haystack.core.errors import DeserializationError, SerializationError
13# `allow_deserialization_module` is re-exported here to enable all serialization-specific imports
14# from haystack.core.serialization.
15# The redundant `as` alias marks it as an intentional re-export so ruff does not flag it (F401).
16from haystack.core.serialization_security import allow_deserialization_module as allow_deserialization_module
17from haystack.core.serialization_security import mark_deserialization_internal
18from haystack.utils.auth import Secret
19from haystack.utils.device import ComponentDevice
20from haystack.utils.type_serialization import _import_class_by_name
22T = TypeVar("T")
25@dataclass(frozen=True)
26class DeserializationCallbacks:
27 """
28 Callback functions that are invoked in specific stages of the pipeline deserialization process.
30 :param component_pre_init:
31 Invoked just before a component instance is
32 initialized. Receives the following inputs:
33 `component_name` (`str`), `component_class` (`Type`), `init_params` (`dict[str, Any]`).
35 The callback is allowed to modify the `init_params`
36 dictionary, which contains all the parameters that
37 are passed to the component's constructor.
38 """
40 component_pre_init: Callable | None = None
43def component_to_dict(obj: Any, name: str) -> dict[str, Any]:
44 """
45 Converts a component instance into a dictionary.
47 If a `to_dict` method is present in the component instance, that will be used instead of the default method.
49 :param obj:
50 The component to be serialized.
51 :param name:
52 The name of the component.
53 :returns:
54 A dictionary representation of the component.
56 :raises SerializationError:
57 If the component doesn't have a `to_dict` method.
58 If the values of the init parameters can't be determined.
59 If a non-basic Python type is used in the serialized data.
60 """
61 if hasattr(obj, "to_dict"):
62 data = obj.to_dict()
63 else:
64 init_parameters = {}
65 for param_name, param in inspect.signature(obj.__init__).parameters.items():
66 # Ignore `args` and `kwargs`, used by the default constructor
67 if param_name in ("args", "kwargs"):
68 continue
69 try:
70 # This only works if the Component constructor assigns the init
71 # parameter to an instance variable or property with the same name
72 param_value = getattr(obj, param_name)
73 except AttributeError as e:
74 # If the parameter doesn't have a default value, raise an error
75 if param.default is param.empty:
76 raise SerializationError(
77 f"Cannot determine the value of the init parameter '{param_name}' "
78 f"for the class {obj.__class__.__name__}."
79 f"You can fix this error by assigning 'self.{param_name} = {param_name}' or adding a "
80 f"custom serialization method 'to_dict' to the class."
81 ) from e
82 # In case the init parameter was not assigned, we use the default value
83 param_value = param.default
84 init_parameters[param_name] = param_value
86 data = default_to_dict(obj, **init_parameters)
88 _validate_component_to_dict_output(obj, name, data)
89 return data
92def _validate_component_to_dict_output(component: Any, name: str, data: dict[str, Any]) -> None:
93 # Ensure that only basic Python types are used in the serde data.
94 def is_allowed_type(obj: Any) -> bool:
95 return isinstance(obj, (str, int, float, bool, list, dict, set, tuple, type(None)))
97 def check_iterable(iterable: Iterable[Any]) -> None:
98 for v in iterable:
99 if not is_allowed_type(v):
100 raise SerializationError(
101 f"Component '{name}' of type '{type(component).__name__}' has an unsupported value "
102 f"of type '{type(v).__name__}' in the serialized data."
103 )
104 if isinstance(v, (list, set, tuple)):
105 check_iterable(v)
106 elif isinstance(v, dict):
107 check_dict(v)
109 def check_dict(d: dict[str, Any]) -> None:
110 if any(not isinstance(k, str) for k in d):
111 raise SerializationError(
112 f"Component '{name}' of type '{type(component).__name__}' has a non-string key in the serialized data."
113 )
115 for k, v in d.items():
116 if not is_allowed_type(v):
117 raise SerializationError(
118 f"Component '{name}' of type '{type(component).__name__}' has an unsupported value "
119 f"of type '{type(v).__name__}' in the serialized data under key '{k}'."
120 )
121 if isinstance(v, (list, set, tuple)):
122 check_iterable(v)
123 elif isinstance(v, dict):
124 check_dict(v)
126 check_dict(data)
129def generate_qualified_class_name(cls: type[object]) -> str:
130 """
131 Generates a qualified class name for a class.
133 :param cls:
134 The class whose qualified name is to be generated.
135 :returns:
136 The qualified name of the class.
137 """
138 return f"{cls.__module__}.{cls.__name__}"
141def component_from_dict(
142 cls: type[object], data: dict[str, Any], name: str, callbacks: DeserializationCallbacks | None = None
143) -> Any:
144 """
145 Creates a component instance from a dictionary.
147 If a `from_dict` method is present in the component class, that will be used instead of the default method.
149 :param cls:
150 The class to be used for deserialization.
151 :param data:
152 The serialized data.
153 :param name:
154 The name of the component.
155 :param callbacks:
156 Callbacks to invoke during deserialization.
157 :returns:
158 The deserialized component.
159 """
161 def component_pre_init_callback(component_cls: type, init_params: dict[str, Any]) -> None:
162 assert callbacks is not None
163 assert callbacks.component_pre_init is not None
164 callbacks.component_pre_init(name, component_cls, init_params)
166 def do_from_dict() -> Any:
167 if hasattr(cls, "from_dict"):
168 return cls.from_dict(data)
170 return default_from_dict(cls, data)
172 if callbacks is None or callbacks.component_pre_init is None:
173 return do_from_dict()
175 with _hook_component_init(component_pre_init_callback):
176 return do_from_dict()
179def default_to_dict(obj: Any, **init_parameters: Any) -> dict[str, Any]:
180 """
181 Utility function to serialize an object to a dictionary.
183 This is mostly necessary for components but can be used by any object.
184 `init_parameters` are parameters passed to the object class `__init__`.
185 They must be defined explicitly as they'll be used when creating a new
186 instance of `obj` with `from_dict`. Omitting them might cause deserialisation
187 errors or unexpected behaviours later, when calling `from_dict`.
189 Objects in `init_parameters` that have a `to_dict()` method are automatically
190 serialized by calling that method.
192 This is the format used for saved pipeline files (`Pipeline.dump`/`Pipeline.load`). Don't merge
193 it with `base_serialization._serialize_value_with_schema` — that one uses a different envelope
194 for a different job (arbitrary runtime values, not Components) and changing either would break
195 saved files.
197 An example usage:
199 ```python
200 class MyClass:
201 def __init__(self, my_param: int = 10) -> None:
202 self.my_param = my_param
204 def to_dict(self):
205 return default_to_dict(self, my_param=self.my_param)
208 obj = MyClass(my_param=1000)
209 data = obj.to_dict()
210 assert data == {
211 "type": "MyClass",
212 "init_parameters": {
213 "my_param": 1000,
214 },
215 }
216 ```
218 :param obj:
219 The object to be serialized.
220 :param init_parameters:
221 The parameters used to create a new instance of the class.
222 :returns:
223 A dictionary representation of the instance.
224 """
225 # Automatically serialize objects that have a to_dict method
226 serialized_params = {}
227 for key, value in init_parameters.items():
228 if value is not None and hasattr(value, "to_dict") and callable(value.to_dict):
229 serialized_params[key] = value.to_dict()
230 else:
231 serialized_params[key] = value
233 return {"type": generate_qualified_class_name(type(obj)), "init_parameters": serialized_params}
236def _is_serialized_component_device(value: Any) -> bool:
237 """
238 Check if a value is a serialized ComponentDevice dictionary.
240 A dictionary is considered a serialized ComponentDevice if:
241 - It has "type": "single" and a "device" key with a string value, or
242 - It has "type": "multiple" and a "device_map" key with a dict value
244 This matches the structure produced by ComponentDevice.to_dict().
245 """
246 if not isinstance(value, dict):
247 return False
249 type_value = value.get("type")
250 if type_value == "single":
251 return "device" in value and isinstance(value["device"], str)
252 if type_value == "multiple":
253 return "device_map" in value and isinstance(value["device_map"], dict)
254 return False
257def default_from_dict(cls: type[T], data: dict[str, Any]) -> T:
258 """
259 Utility function to deserialize a dictionary to an object.
261 This is mostly necessary for components but can be used by any object. Reverses the
262 `{"type": ..., "init_parameters": ...}` envelope produced by `default_to_dict` — see that
263 function's docstring for why this envelope is not interchangeable with
264 `haystack.utils.base_serialization._serialize_value_with_schema`'s.
266 The function will raise a `DeserializationError` if the `type` field in `data` is
267 missing or it doesn't match the type of `cls`.
269 If `data` contains an `init_parameters` field it will be used as parameters to create
270 a new instance of `cls`.
272 Serialized Secret dictionaries in `init_parameters` are automatically detected and
273 deserialized. A dictionary is considered a serialized Secret if it has a "type" key
274 with value "env_var".
276 Serialized ComponentDevice dictionaries in `init_parameters` are automatically detected
277 and deserialized. A dictionary is considered a serialized ComponentDevice if it has a
278 "type" key with value "single" or "multiple".
280 Objects in `init_parameters` that are dictionaries with a "type" key containing a fully
281 qualified class name are automatically detected and deserialized if the class has a
282 `from_dict()` method.
284 :param cls:
285 The class to be used for deserialization.
286 :param data:
287 The serialized data.
288 :returns:
289 The deserialized object.
291 :raises DeserializationError:
292 If the `type` field in `data` is missing or it doesn't match the type of `cls`.
293 """
294 # Copy so that replacing serialized sub-objects (Secret/ComponentDevice/nested components) with their
295 # deserialized instances below does not mutate the caller's ``data`` dict in place. Without this, a second
296 # deserialization of the same dict would receive already-parsed objects instead of their serialized form.
297 init_params = dict(data.get("init_parameters", {}))
298 if "type" not in data:
299 raise DeserializationError("Missing 'type' in serialization data")
300 if data["type"] != generate_qualified_class_name(cls):
301 raise DeserializationError(f"Class '{data['type']}' can't be deserialized as '{cls.__name__}'")
303 valid_init_param_names = _init_parameter_names(cls)
305 # Automatically detect and deserialize objects with from_dict methods
306 for key, value in init_params.items():
307 if isinstance(value, dict) and "type" in value:
308 type_value = value.get("type")
309 # Special handling for Secret (type == "env_var")
310 if type_value == "env_var":
311 init_params[key] = Secret.from_dict(value)
312 # Special handling for ComponentDevice (type == "single" or "multiple")
313 elif _is_serialized_component_device(value):
314 init_params[key] = ComponentDevice.from_dict(value)
315 # If type looks like a fully qualified class name, try to import it and deserialize
316 elif isinstance(type_value, str) and "." in type_value:
317 # Reject before importing if the parent class does not accept this parameter.
318 # This blocks YAML that smuggles untrusted classes into unused parameter slots.
319 if valid_init_param_names is not None and key not in valid_init_param_names:
320 known_params = (
321 f"Valid parameters are: {', '.join(repr(n) for n in sorted(valid_init_param_names))}."
322 if valid_init_param_names
323 else f"'{cls.__name__}' accepts no init parameters."
324 )
325 raise DeserializationError(
326 f"Refusing to deserialize unknown parameter '{key}' for '{cls.__name__}'. {known_params} "
327 f"Correct the parameter name or remove it from the serialized data."
328 )
329 try:
330 imported_class = import_class_by_name(type_value)
331 if hasattr(imported_class, "from_dict") and callable(imported_class.from_dict):
332 init_params[key] = imported_class.from_dict(value)
333 else:
334 init_params[key] = default_from_dict(imported_class, value)
335 except (ImportError, DeserializationError) as e:
336 raise type(e)(f"Failed to deserialize '{key}': {e}") from e
338 return cls(**init_params)
341def _init_parameter_names(cls: type[object]) -> set[str] | None:
342 """
343 Return the set of init parameter names accepted by `cls`.
345 Returns `None` if the constructor accepts arbitrary keyword arguments (`**kwargs`) — in
346 which case we cannot validate keys.
347 """
348 try:
349 signature = inspect.signature(cls.__init__)
350 except (TypeError, ValueError):
351 return None
352 names: set[str] = set()
353 for name, param in signature.parameters.items():
354 if name == "self":
355 continue
356 if param.kind is inspect.Parameter.VAR_KEYWORD:
357 # Constructor accepts **kwargs; we cannot tell whether `key` is a real parameter.
358 return None
359 if param.kind is inspect.Parameter.VAR_POSITIONAL:
360 continue
361 names.add(name)
362 return names
365@mark_deserialization_internal
366def import_class_by_name(fully_qualified_name: str) -> type[object]:
367 """
368 Utility function to import (load) a class object based on its fully qualified class name.
370 This function dynamically imports a class based on its string name.
371 It splits the name into module path and class name, imports the module,
372 and returns the class object.
374 For security, the module path is checked against the deserialization allowlist
375 (see :mod:`haystack.core.serialization_security`). Modules outside the allowlist
376 are rejected with a :class:`DeserializationError`.
378 :param fully_qualified_name: the fully qualified class name as a string
379 :returns: the class object.
380 :raises ImportError: If the class cannot be imported or found.
381 :raises DeserializationError: If the module is not on the deserialization allowlist.
382 """
383 return _import_class_by_name(fully_qualified_name)