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

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import inspect 

6from collections.abc import Callable, Iterable 

7from dataclasses import dataclass 

8from typing import Any, TypeVar 

9 

10from haystack.core.component.component import _hook_component_init 

11from haystack.core.errors import DeserializationError, SerializationError 

12 

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 

21 

22T = TypeVar("T") 

23 

24 

25@dataclass(frozen=True) 

26class DeserializationCallbacks: 

27 """ 

28 Callback functions that are invoked in specific stages of the pipeline deserialization process. 

29 

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]`). 

34 

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 """ 

39 

40 component_pre_init: Callable | None = None 

41 

42 

43def component_to_dict(obj: Any, name: str) -> dict[str, Any]: 

44 """ 

45 Converts a component instance into a dictionary. 

46 

47 If a `to_dict` method is present in the component instance, that will be used instead of the default method. 

48 

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. 

55 

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 

85 

86 data = default_to_dict(obj, **init_parameters) 

87 

88 _validate_component_to_dict_output(obj, name, data) 

89 return data 

90 

91 

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))) 

96 

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) 

108 

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 ) 

114 

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) 

125 

126 check_dict(data) 

127 

128 

129def generate_qualified_class_name(cls: type[object]) -> str: 

130 """ 

131 Generates a qualified class name for a class. 

132 

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__}" 

139 

140 

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. 

146 

147 If a `from_dict` method is present in the component class, that will be used instead of the default method. 

148 

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 """ 

160 

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) 

165 

166 def do_from_dict() -> Any: 

167 if hasattr(cls, "from_dict"): 

168 return cls.from_dict(data) 

169 

170 return default_from_dict(cls, data) 

171 

172 if callbacks is None or callbacks.component_pre_init is None: 

173 return do_from_dict() 

174 

175 with _hook_component_init(component_pre_init_callback): 

176 return do_from_dict() 

177 

178 

179def default_to_dict(obj: Any, **init_parameters: Any) -> dict[str, Any]: 

180 """ 

181 Utility function to serialize an object to a dictionary. 

182 

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`. 

188 

189 Objects in `init_parameters` that have a `to_dict()` method are automatically 

190 serialized by calling that method. 

191 

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. 

196 

197 An example usage: 

198 

199 ```python 

200 class MyClass: 

201 def __init__(self, my_param: int = 10) -> None: 

202 self.my_param = my_param 

203 

204 def to_dict(self): 

205 return default_to_dict(self, my_param=self.my_param) 

206 

207 

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 ``` 

217 

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 

232 

233 return {"type": generate_qualified_class_name(type(obj)), "init_parameters": serialized_params} 

234 

235 

236def _is_serialized_component_device(value: Any) -> bool: 

237 """ 

238 Check if a value is a serialized ComponentDevice dictionary. 

239 

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 

243 

244 This matches the structure produced by ComponentDevice.to_dict(). 

245 """ 

246 if not isinstance(value, dict): 

247 return False 

248 

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 

255 

256 

257def default_from_dict(cls: type[T], data: dict[str, Any]) -> T: 

258 """ 

259 Utility function to deserialize a dictionary to an object. 

260 

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. 

265 

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`. 

268 

269 If `data` contains an `init_parameters` field it will be used as parameters to create 

270 a new instance of `cls`. 

271 

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". 

275 

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". 

279 

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. 

283 

284 :param cls: 

285 The class to be used for deserialization. 

286 :param data: 

287 The serialized data. 

288 :returns: 

289 The deserialized object. 

290 

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__}'") 

302 

303 valid_init_param_names = _init_parameter_names(cls) 

304 

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 

337 

338 return cls(**init_params) 

339 

340 

341def _init_parameter_names(cls: type[object]) -> set[str] | None: 

342 """ 

343 Return the set of init parameter names accepted by `cls`. 

344 

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 

363 

364 

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. 

369 

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. 

373 

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`. 

377 

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)