Coverage for event_normalizer/reducers.py: 100%

84 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 20:04 +0000

1""" 

2Utilities to project/reduce raw event dictionaries. 

3 

4This module is intentionally independent from: 

5- Pydantic models 

6- FlatEvent / NormalizedEvent 

7- GraceDB-specific parsing 

8- VictoriaMetrics serialization 

9""" 

10 

11from __future__ import annotations 

12 

13from collections.abc import Iterable 

14from copy import deepcopy 

15from typing import Any 

16 

17_MISSING = object() 

18 

19 

20def append_required_fields( 

21 paths: Iterable[str], 

22 required_fields: Iterable[str], 

23) -> list[str]: 

24 """ 

25 Return a new list of paths including all required fields. 

26 

27 Duplicate paths are removed while preserving the original order. 

28 

29 Example: 

30 >>> append_required_fields( 

31 ... ["graceid", "far"], 

32 ... ["far", "gpstime"], 

33 ... ) 

34 ['graceid', 'far', 'gpstime'] 

35 """ 

36 result: list[str] = [] 

37 

38 for path in [*paths, *required_fields]: 

39 if path not in result: 

40 result.append(path) 

41 

42 return result 

43 

44 

45def reduce_event( 

46 event: dict[str, Any], 

47 paths: Iterable[str], 

48 *, 

49 required_fields: Iterable[str] = (), 

50 include_missing: bool = True, 

51 preserve_list_keys: Iterable[str] = ("ifo",), 

52) -> dict[str, Any]: 

53 """ 

54 Return a reduced copy of an event containing only requested paths. 

55 

56 Parameters 

57 ---------- 

58 event: 

59 Raw event dictionary. 

60 

61 paths: 

62 Dot-separated paths to keep. 

63 

64 Examples: 

65 - "graceid" 

66 - "far" 

67 - "extra_attributes.CoincInspiral.mass" 

68 - "extra_attributes.SingleInspiral.channel" 

69 

70 required_fields: 

71 Extra paths to append before reducing. This is optional because the 

72 reducer itself does not know which fields are mandatory for a specific 

73 downstream model. 

74 

75 Example: 

76 required_fields=("far", "gpstime") 

77 

78 include_missing: 

79 If True, missing leaf fields are included with value None. 

80 If False, missing paths are omitted from the result. 

81 

82 preserve_list_keys: 

83 When projecting a list of dictionaries, preserve these identifying 

84 fields in each item when available. 

85 

86 The default preserves "ifo", useful for SingleInspiral entries: 

87 

88 input: 

89 [{"ifo": "H1", "channel": "..."}, ...] 

90 

91 path: 

92 "extra_attributes.SingleInspiral.channel" 

93 

94 output: 

95 [{"ifo": "H1", "channel": "..."}, ...] 

96 

97 Set to an empty tuple if this behaviour is not desired. 

98 

99 Returns 

100 ------- 

101 dict[str, Any] 

102 A new reduced event dictionary. 

103 """ 

104 selected_paths = append_required_fields(paths, required_fields) 

105 preserve_keys = tuple(preserve_list_keys) 

106 

107 result: dict[str, Any] = {} 

108 

109 for path in selected_paths: 

110 if not path: 

111 continue 

112 

113 parts = path.split(".") 

114 projected = _project_path( 

115 source=event, 

116 parts=parts, 

117 include_missing=include_missing, 

118 preserve_list_keys=preserve_keys, 

119 ) 

120 

121 if projected is _MISSING: 

122 continue 

123 

124 _deep_merge(result, projected) 

125 

126 return result 

127 

128 

129def _project_path( 

130 source: Any, 

131 parts: list[str], 

132 *, 

133 include_missing: bool, 

134 preserve_list_keys: tuple[str, ...], 

135) -> Any: 

136 """Build a nested projection for one dot-separated path.""" 

137 if not parts: 

138 return deepcopy(source) 

139 

140 key = parts[0] 

141 remaining = parts[1:] 

142 

143 if isinstance(source, dict): 

144 value = source.get(key, _MISSING) 

145 

146 if value is _MISSING: 

147 if include_missing: 

148 return _build_missing_path(parts) 

149 return _MISSING 

150 

151 if not remaining: 

152 return {key: deepcopy(value)} 

153 

154 child = _project_path( 

155 source=value, 

156 parts=remaining, 

157 include_missing=include_missing, 

158 preserve_list_keys=preserve_list_keys, 

159 ) 

160 

161 if child is _MISSING: 

162 return _MISSING 

163 

164 return {key: child} 

165 

166 if isinstance(source, list): 

167 projected_items: list[Any] = [] 

168 

169 for item in source: 

170 child = _project_path( 

171 source=item, 

172 parts=parts, 

173 include_missing=include_missing, 

174 preserve_list_keys=preserve_list_keys, 

175 ) 

176 

177 if child is _MISSING: 

178 continue 

179 

180 if isinstance(item, dict) and isinstance(child, dict): 

181 child = { 

182 key: deepcopy(item[key]) 

183 for key in preserve_list_keys 

184 if key in item 

185 } | child 

186 

187 projected_items.append(child) 

188 

189 return projected_items 

190 

191 if include_missing: 

192 return _build_missing_path(parts) 

193 

194 return _MISSING 

195 

196 

197def _build_missing_path(parts: list[str]) -> dict[str, Any]: 

198 """Build a nested dictionary ending in None for a missing path.""" 

199 result: dict[str, Any] = {} 

200 current = result 

201 

202 for key in parts[:-1]: 

203 current[key] = {} 

204 current = current[key] 

205 

206 current[parts[-1]] = None 

207 

208 return result 

209 

210 

211def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None: 

212 """ 

213 Merge source into target recursively. 

214 

215 Lists are merged item-by-item by index. This allows calls such as: 

216 

217 reduce_event( 

218 event, 

219 [ 

220 "extra_attributes.SingleInspiral.channel", 

221 "extra_attributes.SingleInspiral.snr", 

222 ], 

223 ) 

224 

225 to produce one list containing both fields per detector. 

226 """ 

227 for key, value in source.items(): 

228 if key not in target: 

229 target[key] = deepcopy(value) 

230 continue 

231 

232 current = target[key] 

233 

234 if isinstance(current, dict) and isinstance(value, dict): 

235 _deep_merge(current, value) 

236 

237 elif isinstance(current, list) and isinstance(value, list): 

238 _merge_lists(current, value) 

239 

240 else: 

241 target[key] = deepcopy(value) 

242 

243 

244def _merge_lists(target: list[Any], source: list[Any]) -> None: 

245 """Merge two lists item-by-item, extending target if needed.""" 

246 for index, value in enumerate(source): 

247 if index >= len(target): 

248 target.append(deepcopy(value)) 

249 continue 

250 

251 current = target[index] 

252 

253 if isinstance(current, dict) and isinstance(value, dict): 

254 _deep_merge(current, value) 

255 

256 elif isinstance(current, list) and isinstance(value, list): 

257 _merge_lists(current, value) 

258 

259 else: 

260 target[index] = deepcopy(value)