Coverage for haystack/utils/type_serialization.py: 96%

144 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 ast 

6import builtins 

7import importlib 

8import inspect 

9import typing 

10from threading import Lock 

11from types import GenericAlias, ModuleType, NoneType, UnionType 

12from typing import Any, Union, get_args 

13 

14from haystack import logging 

15from haystack.core.errors import DeserializationError 

16from haystack.core.serialization_security import ( 

17 _check_builtin_is_type, 

18 _check_module_allowed, 

19 _check_not_deserialization_internal, 

20 _check_resolved_module_allowed, 

21 _check_traversable_attribute, 

22 mark_deserialization_internal, 

23) 

24 

25logger = logging.getLogger(__name__) 

26 

27_import_lock = Lock() 

28 

29# Literal singletons that are not types and must be resolved before the generic/module-path handling in 

30# deserialize_type: the None singleton ("None"), NoneType, and Ellipsis ("..."). Ellipsis appears in 

31# variadic tuples (tuple[int, ...]) and Callable[..., X]. "Ellipsis" is also accepted so that types 

32# serialized by older Haystack versions (which emitted the literal "Ellipsis") can still be read back. 

33_SPECIAL_LITERALS: dict[str, Any] = {"None": None, "NoneType": NoneType, "...": ..., "Ellipsis": ...} 

34 

35 

36def _is_union_type(target: Any) -> bool: 

37 """ 

38 Check if target is a Union type. 

39 

40 This handles both `typing.Union[X, Y]` and `X | Y` syntax from PEP 604, 

41 including parameterized types like `Optional[str]`. 

42 """ 

43 if target is Union or target is UnionType: 

44 return True 

45 origin = typing.get_origin(target) 

46 return origin is Union or origin is UnionType 

47 

48 

49def _build_pep604_union_type(types: list[type | UnionType]) -> type | UnionType: 

50 """Build a union type from a list of types using PEP 604 syntax (X | Y).""" 

51 result = types[0] 

52 for t in types[1:]: 

53 result = result | t 

54 return result 

55 

56 

57def _serialize_type_arg(arg: Any) -> str: 

58 """ 

59 Serialize a single generic argument. 

60 

61 Almost all arguments are plain types and are handed off to ``serialize_type``. The exception is the 

62 parameter list of a ``Callable[[X, Y], R]``, which ``typing.get_args`` returns as a Python ``list`` 

63 (e.g. ``[X, Y]``) rather than a type. Such a list is rendered as ``[X, Y]`` so it round-trips back to the 

64 same ``Callable`` on deserialization. Without this, the list would fall through to ``serialize_type`` and 

65 be dropped (its ``str()`` starts with ``[``, which the name handling truncates to an empty string). 

66 """ 

67 if isinstance(arg, list): 

68 return f"[{', '.join(serialize_type(a) for a in arg)}]" 

69 return serialize_type(arg) 

70 

71 

72def serialize_type(target: Any) -> str: 

73 """ 

74 Serializes a type or an instance to its string representation, including the module name. 

75 

76 This function handles types, instances of types, and special typing objects. 

77 It assumes that non-typing objects will have a '__name__' attribute. 

78 

79 :param target: 

80 The object to serialize, can be an instance or a type. 

81 :return: 

82 The string representation of the type. 

83 """ 

84 if target is NoneType: 

85 return "None" 

86 

87 # `...` (Ellipsis) shows up as an argument in variadic tuples (tuple[int, ...]) and in 

88 # Callable[..., X]. It is a singleton constant rather than a type, so serialize it to the same 

89 # literal Python uses when rendering these types. Without this it would fall through to 

90 # str(Ellipsis) == "Ellipsis", which deserialize_type then rejects as a non-type builtin. 

91 if target is Ellipsis: 

92 return "..." 

93 

94 # Literal holds values (e.g. Literal["yes", "no"]), not types. Serialize each value with repr() so 

95 # strings keep their quotes. 

96 if typing.get_origin(target) is typing.Literal: 

97 return f"typing.Literal[{', '.join(repr(a) for a in get_args(target))}]" 

98 

99 args = get_args(target) 

100 

101 if isinstance(target, UnionType): 

102 return " | ".join([serialize_type(a) for a in args]) 

103 

104 name = getattr(target, "__name__", str(target)) 

105 if name.startswith("typing."): 

106 name = name[7:] 

107 if "[" in name: 

108 name = name.split("[")[0] 

109 

110 # Get module name 

111 module = inspect.getmodule(target) 

112 module_name = "" 

113 # We omit the module name for builtins to not clutter the output 

114 if module and hasattr(module, "__name__") and module.__name__ != "builtins": 

115 module_name = f"{module.__name__}" 

116 

117 if args: 

118 # For typing generics, convert PEP 604 union types (X | Y) to typing.Union when serializing. 

119 # This avoids issues with Python's internal cache, where List[Union[str, int]] and List[str | int] are treated 

120 # as the same key. GenericAlias (builtins like list[...]) can keep the PEP 604 syntax. 

121 is_typing_generic = not isinstance(target, GenericAlias) 

122 # Optional[X] is normalized by Python to Union[X, None]; the trailing None is already implied by the 

123 # "Optional" name, so we drop it. For any other generic (e.g. Dict[str, None], Tuple[int, None] or a 

124 # Union with more than two members) NoneType is a regular argument and must be kept. 

125 skip_nonetype = name == "Optional" 

126 args_str = ", ".join( 

127 _serialize_type_arg(Union[tuple(get_args(a))] if is_typing_generic and isinstance(a, UnionType) else a) # noqa: UP007 

128 for a in args 

129 if not (skip_nonetype and a is NoneType) 

130 ) 

131 return f"{module_name}.{name}[{args_str}]" if module_name else f"{name}[{args_str}]" 

132 

133 return f"{module_name}.{name}" if module_name else f"{name}" 

134 

135 

136def _parse_generic_args(args_str: str) -> list[str]: 

137 args = [] 

138 bracket_count = 0 

139 current_arg = "" 

140 

141 for char in args_str: 

142 if char == "[": 

143 bracket_count += 1 

144 elif char == "]": 

145 bracket_count -= 1 

146 

147 if char == "," and bracket_count == 0: 

148 args.append(current_arg.strip()) 

149 current_arg = "" 

150 else: 

151 current_arg += char 

152 

153 if current_arg: 

154 args.append(current_arg.strip()) 

155 

156 return args 

157 

158 

159def _parse_pep604_union_args(union_str: str) -> list[str]: 

160 """ 

161 Parse a PEP 604 union string (e.g., "str | int | None") into individual type strings. 

162 

163 Handles nested generics properly, e.g., "list[str] | dict[str, int] | None". 

164 

165 :param union_str: The union string to parse 

166 :returns: A list of individual type strings 

167 """ 

168 args = [] 

169 bracket_count = 0 

170 current_arg = "" 

171 

172 for char in union_str: 

173 if char == "[": 

174 bracket_count += 1 

175 elif char == "]": 

176 bracket_count -= 1 

177 

178 if char == "|" and bracket_count == 0: 

179 args.append(current_arg.strip()) 

180 current_arg = "" 

181 else: 

182 current_arg += char 

183 

184 if current_arg.strip(): 

185 args.append(current_arg.strip()) 

186 

187 return args 

188 

189 

190def _deserialize_type_arg(arg_str: str) -> Any: 

191 """ 

192 Deserialize a single generic argument produced by ``_parse_generic_args``. 

193 

194 Mirrors ``_serialize_type_arg``: a ``Callable`` parameter list is emitted as ``[X, Y]`` and must be read 

195 back as a Python ``list`` of types (so ``Callable`` can be re-subscripted as ``Callable[[X, Y], R]``), 

196 not as a single type. An empty parameter list ``[]`` becomes ``[]``. Every other argument is a normal 

197 type and is delegated to ``deserialize_type``. 

198 """ 

199 stripped = arg_str.strip() 

200 if stripped.startswith("[") and stripped.endswith("]"): 

201 inner = stripped[1:-1].strip() 

202 if not inner: 

203 return [] 

204 return [deserialize_type(a) for a in _parse_generic_args(inner)] 

205 return deserialize_type(arg_str) 

206 

207 

208@mark_deserialization_internal 

209def deserialize_type(type_str: str) -> Any: 

210 """ 

211 Deserializes a type given its full import path as a string, including nested generic types. 

212 

213 This function will dynamically import the module if it's not already imported 

214 and then retrieve the type object from it. It also handles nested generic types like 

215 `list[dict[int, str]]`. 

216 

217 Every module path with a `.` prefix is checked against the deserialization 

218 allowlist (see `haystack.core.serialization_security`) before being imported. Modules outside 

219 the allowlist are rejected with a `DeserializationError`. Builtin and `typing`/`collections` 

220 names without a module prefix bypass this check. 

221 

222 :param type_str: 

223 The string representation of the type's full import path. 

224 :returns: 

225 The deserialized type object. 

226 :raises DeserializationError: 

227 If the module is not on the deserialization allowlist, or if the type cannot be 

228 deserialized due to a missing module or type. 

229 """ 

230 # Resolve the non-type literal singletons (see _SPECIAL_LITERALS) up front, before the generic and 

231 # module-path handling below: the dots in "..." would otherwise be read as a module path, and the 

232 # None/Ellipsis singletons would be refused by the builtin type gate. 

233 if type_str in _SPECIAL_LITERALS: 

234 return _SPECIAL_LITERALS[type_str] 

235 

236 # Handle PEP 604 union syntax at the top level (e.g., "str | int", "str | None") 

237 pep604_union_args = _parse_pep604_union_args(type_str) 

238 if len(pep604_union_args) > 1: 

239 deserialized_args = [deserialize_type(arg) for arg in pep604_union_args] 

240 return _build_pep604_union_type(deserialized_args) 

241 

242 # Handle generics (including Union[X, Y]) 

243 if "[" in type_str and type_str.endswith("]"): 

244 main_type_str, generics_str = type_str.split("[", 1) 

245 generics_str = generics_str[:-1] 

246 

247 main_type = deserialize_type(main_type_str) 

248 

249 # Parse literal args with ast.literal_eval, which safely handles 

250 # str/int/bool/None/bytes and is quote-aware, so a comma inside a string value does not split the 

251 # arguments. 

252 if main_type is typing.Literal: 

253 return typing.Literal[ast.literal_eval(f"({generics_str},)")] 

254 

255 generic_args = [_deserialize_type_arg(arg) for arg in _parse_generic_args(generics_str)] 

256 

257 # Reconstruct 

258 try: 

259 return main_type[tuple(generic_args) if len(generic_args) > 1 else generic_args[0]] 

260 except (TypeError, AttributeError) as e: 

261 raise DeserializationError(f"Could not apply arguments {generic_args} to type {main_type}") from e 

262 

263 # Handle non-generic types 

264 # First, check if there's a module prefix 

265 if "." in type_str: 

266 try: 

267 return _import_class_by_name(type_str) 

268 except ImportError as e: 

269 raise DeserializationError(str(e)) from e 

270 

271 # No module prefix, check builtins and typing. 

272 # (None / NoneType / Ellipsis are handled at the top of this function, before they can reach the 

273 # builtin type gate below which would refuse them for not being types.) 

274 if hasattr(builtins, type_str): 

275 resolved = getattr(builtins, type_str) 

276 # This bare-name path never consults the allowlist. A type annotation must resolve to an 

277 # actual type, so builtin functions like `eval`/`exec` are rejected while types pass. 

278 _check_builtin_is_type(resolved, type_str) 

279 return resolved 

280 

281 # Then check typing 

282 if hasattr(typing, type_str): 

283 return getattr(typing, type_str) 

284 

285 raise DeserializationError(f"Could not deserialize type: {type_str}") 

286 

287 

288def thread_safe_import(module_name: str) -> ModuleType: 

289 """ 

290 Import a module in a thread-safe manner. 

291 

292 Importing modules in a multi-threaded environment can lead to race conditions. 

293 This function ensures that the module is imported in a thread-safe manner without having impact 

294 on the performance of the import for single-threaded environments. 

295 

296 :param module_name: the module to import 

297 """ 

298 with _import_lock: 

299 return importlib.import_module(module_name) 

300 

301 

302@mark_deserialization_internal 

303def _import_class_by_name(fully_qualified_name: str) -> Any: 

304 """ 

305 Imports an attribute (typically a class) given its fully qualified name. 

306 

307 Checks the module against the deserialization allowlist (see 

308 `haystack.core.serialization_security`) and, for `builtins`, that the resolved attribute is 

309 an actual type — rejecting dangerous builtins like `eval`/`compile`. 

310 

311 :param fully_qualified_name: the fully qualified name, e.g. "my_package.MyClass" 

312 :returns: the imported attribute. 

313 :raises ImportError: If the module cannot be imported or the attribute doesn't exist on it. 

314 :raises DeserializationError: If the module is not on the deserialization allowlist, or the 

315 resolved builtin is not a type. 

316 """ 

317 module_path, attr_name = fully_qualified_name.rsplit(".", 1) 

318 _check_module_allowed(module_path) 

319 # A class reference names a public attribute of a module, never an object-internals attribute 

320 # (`__dict__`, `__globals__`, ...) that would expose a module namespace or escape gadget. 

321 _check_traversable_attribute(attr_name, fully_qualified_name) 

322 try: 

323 logger.debug( 

324 "Attempting to import '{attr_name}' from module '{module_path}'", 

325 attr_name=attr_name, 

326 module_path=module_path, 

327 ) 

328 module = thread_safe_import(module_path) 

329 resolved = getattr(module, attr_name) 

330 # `_check_module_allowed` above gates the declared module path, which the caller controls. 

331 # A class/module re-exported as an attribute of an allowlisted module (e.g. 

332 # `haystack.utils.auth.os` -> the `os` module, or a re-exported `subprocess.Popen`) would 

333 # otherwise slip through. Re-check the module the object actually belongs to; `module_path` 

334 # is the allowlisted module it was resolved from, so a private C accelerator backing it 

335 # (e.g. `io.StringIO` -> `_io`) is still accepted. 

336 _check_resolved_module_allowed(resolved, declared_module=module_path) 

337 # Refuse the deserializer's own machinery (the allowlist-administration function and the 

338 # resolution helpers) on the class-resolution path too, so it cannot be reached as a 

339 # component `type` or nested class reference. See `mark_deserialization_internal`. 

340 _check_not_deserialization_internal(resolved, fully_qualified_name) 

341 if module_path == "builtins": 

342 _check_builtin_is_type(resolved, fully_qualified_name) 

343 return resolved 

344 except (ImportError, AttributeError) as error: 

345 logger.exception("Failed to import '{full_name}'", full_name=fully_qualified_name) 

346 raise ImportError(f"Could not import '{fully_qualified_name}'") from error