Coverage for src/somesy/pom_xml/xmlproxy.py: 93%

205 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-03 13:13 +0000

1"""Wrapper to provide dict-like access to XML via ElementTree.""" 

2 

3from __future__ import annotations 

4 

5import xml.etree.ElementTree as ET 

6from pathlib import Path 

7from typing import Any, Literal, cast, overload 

8 

9import defusedxml.ElementTree as DET 

10 

11# shallow type hint mostly for documentation purpose 

12JSONLike = Any 

13 

14 

15def load_xml(path: Path) -> ET.ElementTree: 

16 """Parse an XML file into an ElementTree, preserving comments.""" 

17 path = path if isinstance(path, Path) else Path(path) 

18 parser = DET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) 

19 return DET.parse(path, parser=parser) 

20 

21 

22def indent(elem, level=0): 

23 """Indent the elements of this XML node (i.e. pretty print).""" 

24 i = "\n" + level * " " 

25 if len(elem): 

26 if not elem.text or not elem.text.strip(): 

27 elem.text = i + " " 

28 if not elem.tail or not elem.tail.strip(): 

29 elem.tail = i 

30 children = list(elem) 

31 for child in children: 

32 indent(child, level + 1) 

33 if not children[-1].tail or not children[-1].tail.strip(): 

34 children[-1].tail = i 

35 else: 

36 if level and (not elem.tail or not elem.tail.strip()): 

37 elem.tail = i 

38 

39 

40class XMLProxy: 

41 """Class providing dict-like access to edit XML via ElementTree. 

42 

43 Note that this wrapper facade is limited to a restricted (but useful) subset of XML: 

44 * XML attributes are not supported 

45 * DTDs are ignored (arbitrary keys can be queried and added) 

46 * each tag is assumed to EITHER contain text OR more nested tags 

47 * lists are treated atomically (no way to add/remove element from a collection) 

48 

49 The semantics is implemented as follows: 

50 

51 * If there are multiple tags with the same name, a list of XMLProxy nodes is returned 

52 * If a unique tag does have no nested tags, its `text` string value is returned 

53 * Otherwise, the node is returned 

54 """ 

55 

56 def _wrap(self, el: ET.Element) -> XMLProxy: 

57 """Wrap a different element, inheriting the same namespace.""" 

58 return XMLProxy(el, default_namespace=self._def_ns) 

59 

60 def _dump(self): 

61 """Dump XML to stdout (for debugging).""" 

62 ET.dump(self._node) 

63 

64 def _qualified_key(self, key: str): 

65 """If passed key is not qualified, prepends the default namespace (if set).""" 

66 if key[0] == "{" or not self._def_ns: 

67 return key 

68 return "{" + self._def_ns + "}" + key 

69 

70 def _shortened_key(self, key: str): 

71 """Inverse of `_qualified_key` (strips default namespace from element name).""" 

72 if key[0] != "{" or not self._def_ns or key.find(self._def_ns) < 0: 

73 return key 

74 return key[key.find("}") + 1 :] 

75 

76 # ---- 

77 

78 def __init__(self, el: ET.Element, *, default_namespace: str | None = None): 

79 """Wrap an existing XML ElementTree Element.""" 

80 self._node: ET.Element = el 

81 self._def_ns = default_namespace 

82 

83 @classmethod 

84 def parse(cls, path: str | Path, **kwargs) -> XMLProxy: 

85 """Parse an XML file into a wrapped ElementTree, preserving comments.""" 

86 path = path if isinstance(path, Path) else Path(path) 

87 root = load_xml(path).getroot() 

88 if root is None: 

89 raise ValueError(f"XML file has no root element: {path}") 

90 return cls(root, **kwargs) 

91 

92 def write(self, path: str | Path, *, header: bool = True, **kwargs): 

93 """Write the XML DOM to an UTF-8 encoded file.""" 

94 path = path if isinstance(path, Path) else Path(path) 

95 et = ET.ElementTree(self._node) 

96 if self._def_ns and "default_namespace" not in kwargs: 

97 kwargs["default_namespace"] = self._def_ns 

98 indent(et.getroot()) 

99 et.write(path, encoding="UTF-8", xml_declaration=header, **kwargs) 

100 

101 def __repr__(self): 

102 """See `object.__repr__`.""" 

103 return str(self._node) 

104 

105 def __len__(self): 

106 """Return number of inner tags inside current XML element. 

107 

108 Note that bool(node) thus checks whether an XML node is a leaf in the element tree. 

109 """ 

110 return len(self._node) 

111 

112 def __iter__(self): 

113 """Iterate the nested elements in-order.""" 

114 return map(self._wrap, iter(self._node)) 

115 

116 @property 

117 def namespace(self) -> str | None: 

118 """Default namespace of this node.""" 

119 return self._def_ns 

120 

121 @property 

122 def is_comment(self): 

123 """Return whether the current element node is an XML comment.""" 

124 return not isinstance(self._node.tag, str) 

125 

126 @property 

127 def tag(self) -> str | None: 

128 """Return tag name of this element (unless it is a comment).""" 

129 if self.is_comment: 

130 return None 

131 return self._shortened_key(self._node.tag) 

132 

133 @tag.setter 

134 def tag(self, val: str): 

135 """Set the tag of this element.""" 

136 if self.is_comment: 

137 raise ValueError("Cannot set tag name for comment element!") 

138 self._node.tag = self._qualified_key(val) 

139 

140 # ---- helpers ---- 

141 

142 def to_jsonlike( 

143 self, 

144 *, 

145 strip_default_ns: bool = True, 

146 keep_root: bool = False, 

147 ) -> JSONLike: 

148 """Convert XML node to a JSON-like primitive, array or dict (ignoring attributes). 

149 

150 Note that all leaf values are strings (i.e. not parsed to bool/int/float etc.). 

151 

152 Args: 

153 strip_default_ns: Do not qualify keys from the default namespace 

154 keep_root: If true, the root tag name will be preserved (`{"root_tag": {...}}`) 

155 

156 """ 

157 if not len(self): # leaf -> assume it's a primitive value 

158 return self._node.text or "" 

159 

160 dct = {} 

161 ccnt = 0 

162 for elem in iter(self): 

163 raw = elem._node 

164 if not isinstance(raw.tag, str): 

165 ccnt += 1 

166 key = f"__comment_{ccnt}__" 

167 else: 

168 key = raw.tag if not strip_default_ns else self._shortened_key(raw.tag) 

169 

170 curr_val = elem.to_jsonlike(strip_default_ns=strip_default_ns) 

171 if key not in dct: 

172 dct[key] = curr_val 

173 continue 

174 val = dct[key] 

175 if not isinstance(val, list): 

176 dct[key] = [dct[key]] 

177 dct[key].append(curr_val) 

178 

179 return dct if not keep_root else {self._shortened_key(self._node.tag): dct} 

180 

181 @staticmethod 

182 def _new_element(tag: str, *, default_namespace: str | None = None) -> ET.Element: 

183 """Create a new element, qualifying the tag with the default namespace.""" 

184 if default_namespace and tag[0] != "{": 

185 tag = "{" + default_namespace + "}" + tag 

186 return ET.Element(tag) 

187 

188 @classmethod 

189 def _from_jsonlike_primitive( 

190 cls, val, *, elem_name: str | None = None, **kwargs 

191 ) -> str | XMLProxy: 

192 """Convert a leaf node into a string value (i.e. return inner text). 

193 

194 Returns a string (or an XML element, if elem_name is passed). 

195 """ 

196 if val is None: 

197 ret = "" # turn None into empty string 

198 elif isinstance(val, str): 

199 ret = val 

200 elif isinstance(val, bool): 

201 ret = str(val).lower() # True -> true / False -> false 

202 elif isinstance(val, (int, float)): 

203 ret = str(val) 

204 else: 

205 raise TypeError( 

206 f"Value of type {type(val)} is not JSON-like primitive: {val}" 

207 ) 

208 

209 if not elem_name: 

210 return ret 

211 else: # return the value wrapped as an element (needed in from_jsonlike) 

212 elem = cls._new_element(elem_name, **kwargs) 

213 elem.text = ret 

214 return cls(elem, **kwargs) 

215 

216 @classmethod 

217 def from_jsonlike( 

218 cls, val: JSONLike, *, root_name: str | None = None, **kwargs: Any 

219 ) -> Any: 

220 """Convert a JSON-like primitive, array or dict into an XML element. 

221 

222 Note that booleans are serialized as `true`/`false` and None as `null`. 

223 

224 Args: 

225 val: Value to convert into an XML element. 

226 root_name: If `val` is a dict, defines the tag name for the root element. 

227 kwargs: Additional arguments for XML element instantiation. 

228 

229 """ 

230 if isinstance(val, list): 

231 return [cls.from_jsonlike(x, root_name=root_name, **kwargs) for x in val] 

232 if not isinstance(val, dict): # primitive val 

233 return cls._from_jsonlike_primitive(val, elem_name=root_name, **kwargs) 

234 

235 # now the dict case remains 

236 elem = cls._new_element(root_name or "root", **kwargs) 

237 for k, v in val.items(): 

238 if k.startswith( 

239 "__comment_" 

240 ): # special key names are mapped to XML comments 

241 elem.append(ET.Comment(v if isinstance(v, str) else str(v))) 

242 

243 elif isinstance(v, list): 

244 for vv in cast( 

245 list[XMLProxy], XMLProxy.from_jsonlike(v, root_name=k, **kwargs) 

246 ): 

247 elem.append(vv._node) 

248 elif not isinstance(v, dict): # primitive val 

249 # FIXME: use better case-splitting for type of function to avoid cast 

250 tmp = cast( 

251 XMLProxy, 

252 XMLProxy._from_jsonlike_primitive(v, elem_name=k, **kwargs), 

253 ) 

254 elem.append(tmp._node) 

255 else: # dict 

256 elem.append( 

257 cast( 

258 XMLProxy, XMLProxy.from_jsonlike(v, root_name=k, **kwargs) 

259 )._node 

260 ) 

261 

262 return cls(elem, **kwargs) 

263 

264 # ---- dict-like access ---- 

265 

266 @overload 

267 def get( 

268 self, key: str, *, as_nodes: Literal[True], deep: Literal[False] = False 

269 ) -> list[XMLProxy]: ... 

270 

271 @overload 

272 def get( 

273 self, key: str, *, as_nodes: Literal[False] = False, deep: bool = False 

274 ) -> Any: ... 

275 

276 def get(self, key: str, *, as_nodes: bool = False, deep: bool = False) -> Any: 

277 """Get sub-structure(s) of value(s) matching desired XML tag name. 

278 

279 * If there are multiple matching elements, will return them all as a list. 

280 * If there is a single matching element, will return that element without a list. 

281 

282 Args: 

283 key: tag name to retrieve 

284 as_nodes: If true, will *always* return a list of (zero or more) XML nodes 

285 deep: Expand nested XML elements instead of returning them as XML nodes 

286 

287 """ 

288 # NOTE: could allow to retrieve comments when using empty string/none as key? 

289 

290 if as_nodes and deep: 

291 raise ValueError("as_nodes and deep are mutually exclusive!") 

292 if not key: 

293 raise ValueError("Key must not be an empty string!") 

294 key = self._qualified_key(key) 

295 

296 # if not fully qualified + default NS is given, use it for query 

297 lst = self._node.findall(key) 

298 ns: list[XMLProxy] = list(map(self._wrap, lst)) 

299 if as_nodes: # return it as a list of xml nodes 

300 return ns 

301 if not ns: # no element 

302 return None 

303 

304 ret = ns if not deep else [x.to_jsonlike() for x in ns] 

305 if len(ret) == 1: 

306 return ret[0] # single element 

307 else: 

308 return ret 

309 

310 def __getitem__(self, key: str): 

311 """Acts like `dict.__getitem__`, implemented with `get`.""" 

312 val = self.get(key) 

313 if val is not None: 

314 return val 

315 else: 

316 raise KeyError(key) 

317 

318 def __contains__(self, key: str) -> bool: 

319 """Acts like `dict.__contains__`, implemented with `get`.""" 

320 return self.get(key) is not None 

321 

322 def __delitem__(self, key: str | XMLProxy): 

323 """Delete a nested XML element with matching key name. 

324 

325 Note that **all** XML elements with the given tag name are removed! 

326 

327 To prevent this behavior, instead of a string tag name you can provide the 

328 exact element to be removed, i.e. if a node `node_a` represents the following XML: 

329 

330 ``` 

331 <a> 

332 <b>1</b> 

333 <c>2</c> 

334 <b>3</b> 

335 </a> 

336 ``` 

337 

338 Then we have that: 

339 

340 * `del node_a["b"]` removes **both** tags, leaving just the `c` tag. 

341 * `del node_a[node_a["a"][1]]` removes just the second tag with the `3`. 

342 """ 

343 if isinstance(key, str): 

344 nodes = self.get(key, as_nodes=True) 

345 else: 

346 nodes = [key] if key._node in self._node else [] 

347 

348 if not nodes: 

349 raise KeyError(key) 

350 

351 if self._node.text is not None: 

352 self._node.text = "" 

353 for child in nodes: 

354 self._node.remove(child._node) 

355 

356 def _clear(self): 

357 """Remove contents of this XML element (e.g. for overwriting in-place).""" 

358 self._node.text = "" 

359 children = list(iter(self._node)) # need to store, removal invalidates iterator 

360 for child in children: 

361 self._node.remove(child) 

362 

363 def __setitem__(self, key: str | XMLProxy, val: JSONLike | XMLProxy): 

364 """Add or overwrite an inner XML tag. 

365 

366 If there is exactly one matching tag, the value is substituted in-place. 

367 If the passed value is a list, all list entries are added in their own element. 

368 

369 If there are multiple existing matches or target values, then 

370 **all** existing elements are removed and the new value(s) are added in 

371 new element(s) (i.e. coming after other unrelated existing elements)! 

372 

373 To prevent this behavior, instead of a string tag name you can provide the 

374 exact element to be overwritten, i.e. if a node `node_a` represents the following XML: 

375 

376 ``` 

377 <a> 

378 <b>1</b> 

379 <c>2</c> 

380 <b>3</b> 

381 </a> 

382 ``` 

383 

384 Then we have that: 

385 

386 * `node_a["b"] = 5` removes both existing tags and creates a new tag with the passed value(s). 

387 * `node_a[node_a["b"][1]] = 5` replaces the `3` in the second tag with the `5`. 

388 

389 Note that the passed value must be either an XML element already, or be a pure JSON-like object. 

390 """ 

391 if isinstance(key, str): 

392 nodes = self.get(key, as_nodes=True) 

393 # delete all existing elements if multiple exist or are passed 

394 if len(nodes) > 1 or (len(nodes) and isinstance(val, list)): 

395 del self[key] 

396 nodes = [] 

397 # now we can assume there's zero or one suitable target elements 

398 if nodes: # if it is one, clear it out 

399 nodes[0]._clear() 

400 else: # an XMLProxy object was passed as key -> try to use that 

401 if isinstance(val, list): 

402 raise TypeError( 

403 "Cannot overwrite a single element with a list of values!" 

404 ) 

405 # ensure the target node is cleared out and use it as target 

406 key._clear() 

407 nodes = [key] 

408 if key.tag is None: 

409 raise ValueError("Cannot overwrite an XML element without a tag") 

410 key = key.tag 

411 

412 # ensure key string is qualified with a namespace 

413 key_name: str = self._qualified_key(key) 

414 

415 # normalize passed value(s) to be list (general case) 

416 vals = val if isinstance(val, list) else [val] 

417 

418 # ensure there is the required number of target element nodes 

419 for _ in range(len(vals) - len(nodes)): 

420 nodes.append(self._wrap(ET.SubElement(self._node, key_name))) 

421 

422 # normalize values no XML element nodes 

423 nvals: list[XMLProxy] = [] 

424 for item in vals: 

425 # ensure value is represented as an XML node 

426 if isinstance(item, XMLProxy): 

427 obj = self._wrap(ET.Element("dummy")) 

428 obj._node.append(item._node) 

429 else: 

430 obj = cast( 

431 XMLProxy, 

432 self.from_jsonlike( 

433 item, root_name=key_name, default_namespace=self._def_ns 

434 ), 

435 ) 

436 

437 nvals.append(obj) 

438 

439 for node, item in zip(nodes, nvals, strict=False): 

440 # transplant node contents into existing element (so it is inserted in-place) 

441 node._node.text = item._node.text 

442 for child in iter(item): 

443 node._node.append(child._node)