Coverage for haystack/core/pipeline/utils.py: 99%

71 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 heapq 

6from collections.abc import Callable 

7from copy import deepcopy 

8from functools import wraps 

9from itertools import count 

10from typing import Any 

11 

12from haystack import logging 

13from haystack.core.component import Component 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18def _deepcopy_with_exceptions(obj: Any) -> Any: 

19 """ 

20 Attempts to perform a deep copy of the given object. 

21 

22 This function recursively handles common container types (lists, tuples, sets, and dicts) to ensure deep copies 

23 of nested structures. For specific object types that are known to be problematic for deepcopying-such as 

24 instances of `Component`, `Tool`, or `Toolset` - the original object is returned as-is. 

25 If `deepcopy` fails for any other reason, the original object is returned and a log message is recorded. 

26 

27 :param obj: The object to be deep-copied. 

28 

29 :returns: 

30 A deep-copied version of the object, or the original object if deepcopying fails. 

31 """ 

32 # Import here to avoid circular imports 

33 from haystack.tools.tool import Tool 

34 from haystack.tools.toolset import Toolset 

35 

36 # namedtuples are tuple subclasses whose __new__ takes the fields positionally, 

37 # so they must be rebuilt by unpacking rather than from a single iterable. 

38 if isinstance(obj, tuple) and hasattr(obj, "_fields"): 

39 return type(obj)(*(_deepcopy_with_exceptions(v) for v in obj)) 

40 

41 if isinstance(obj, (list, tuple, set)): 

42 return type(obj)(_deepcopy_with_exceptions(v) for v in obj) 

43 

44 if isinstance(obj, dict): 

45 return {k: _deepcopy_with_exceptions(v) for k, v in obj.items()} 

46 

47 # Components and Tools often contain objects that we do not want to deepcopy or are not deepcopyable 

48 # (e.g. models, clients, etc.). In this case we return the object as-is. 

49 if isinstance(obj, (Component, Tool, Toolset)): 

50 return obj 

51 

52 try: 

53 return deepcopy(obj) 

54 except Exception as e: 

55 logger.info( 

56 "Deepcopy failed for object of type '{obj_type}'. Error: {error}. Returning original object instead.", 

57 obj_type=type(obj).__name__, 

58 error=e, 

59 ) 

60 return obj 

61 

62 

63def parse_connect_string(connection: str) -> tuple[str, str | None]: 

64 """ 

65 Returns component-connection pairs from a connect_to/from string. 

66 

67 :param connection: 

68 The connection string. 

69 :returns: 

70 A tuple containing the component name and the connection name. 

71 """ 

72 if "." in connection: 

73 split_str = connection.split(".", maxsplit=1) 

74 return (split_str[0], split_str[1]) 

75 return connection, None 

76 

77 

78class FIFOPriorityQueue: 

79 """ 

80 A priority queue that maintains FIFO order for items of equal priority. 

81 

82 Items with the same priority are processed in the order they were added. 

83 This queue ensures that when multiple items share the same priority level, 

84 they are dequeued in the same order they were enqueued (First-In-First-Out). 

85 """ 

86 

87 def __init__(self) -> None: 

88 """ 

89 Initialize a new FIFO priority queue. 

90 """ 

91 # List of tuples (priority, count, item) where count ensures FIFO order 

92 self._queue: list[tuple[int, int, Any]] = [] 

93 # Counter to maintain insertion order for equal priorities 

94 self._counter = count() 

95 

96 def push(self, item: Any, priority: int) -> None: 

97 """ 

98 Push an item into the queue with a given priority. 

99 

100 Items with equal priority maintain FIFO ordering based on insertion time. 

101 Lower priority numbers are dequeued first. 

102 

103 :param item: 

104 The item to insert into the queue. 

105 :param priority: 

106 Priority level for the item. Lower numbers indicate higher priority. 

107 """ 

108 next_count = next(self._counter) 

109 entry = (priority, next_count, item) 

110 heapq.heappush(self._queue, entry) 

111 

112 def pop(self) -> tuple[int, Any]: 

113 """ 

114 Remove and return the highest priority item from the queue. 

115 

116 For items with equal priority, returns the one that was inserted first. 

117 

118 :returns: 

119 A tuple containing (priority, item) with the lowest priority number. 

120 :raises IndexError: 

121 If the queue is empty. 

122 """ 

123 if not self._queue: 

124 raise IndexError("pop from empty queue") 

125 priority, _, item = heapq.heappop(self._queue) 

126 return priority, item 

127 

128 def peek(self) -> tuple[int, Any]: 

129 """ 

130 Return but don't remove the highest priority item from the queue. 

131 

132 For items with equal priority, returns the one that was inserted first. 

133 

134 :returns: 

135 A tuple containing (priority, item) with the lowest priority number. 

136 :raises IndexError: 

137 If the queue is empty. 

138 """ 

139 if not self._queue: 

140 raise IndexError("peek at empty queue") 

141 priority, _, item = self._queue[0] 

142 return priority, item 

143 

144 def get(self) -> tuple[int, Any] | None: 

145 """ 

146 Remove and return the highest priority item from the queue. 

147 

148 For items with equal priority, returns the one that was inserted first. 

149 Unlike pop(), returns None if the queue is empty instead of raising an exception. 

150 

151 :returns: 

152 A tuple containing (priority, item), or None if the queue is empty. 

153 """ 

154 if not self._queue: 

155 return None 

156 priority, _, item = heapq.heappop(self._queue) 

157 return priority, item 

158 

159 def __len__(self) -> int: 

160 """ 

161 Return the number of items in the queue. 

162 

163 :returns: 

164 The number of items currently in the queue. 

165 """ 

166 return len(self._queue) 

167 

168 def __bool__(self) -> bool: 

169 """ 

170 Return True if the queue has items, False if empty. 

171 

172 :returns: 

173 True if the queue contains items, False otherwise. 

174 """ 

175 return bool(self._queue) 

176 

177 

178def args_deprecated(func: Callable[..., Any]) -> Callable[..., Any]: 

179 """ 

180 Decorator to warn about the use of positional arguments in a function. 

181 

182 Adapted from https://stackoverflow.com/questions/68432070/ 

183 :param func: 

184 """ 

185 

186 def _positional_arg_warning() -> None: 

187 """ 

188 Triggers a warning message if positional arguments are used in a function 

189 """ 

190 import warnings 

191 

192 msg = ( 

193 "Warning: In an upcoming release, this method will require keyword arguments for all parameters. " 

194 "Please update your code to use keyword arguments to ensure future compatibility. " 

195 ) 

196 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

197 

198 @wraps(func) 

199 def wrapper(*args: Any, **kwargs: Any) -> Any: 

200 # call the function first, to make sure the signature matches 

201 ret_value = func(*args, **kwargs) 

202 

203 # A Pipeline instance is always the first argument - remove it from the args to check for positional arguments 

204 # We check the class name as strings to avoid circular imports 

205 if args and isinstance(args, tuple) and args[0].__class__.__name__ in ["Pipeline", "PipelineBase"]: 

206 args = args[1:] 

207 

208 if args: 

209 _positional_arg_warning() 

210 return ret_value 

211 

212 return wrapper