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:31 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +0000
1"""Wrapper to provide dict-like access to XML via ElementTree."""
3from __future__ import annotations
5import xml.etree.ElementTree as ET
6from pathlib import Path
7from typing import Any, Literal, cast, overload
9import defusedxml.ElementTree as DET
11# shallow type hint mostly for documentation purpose
12JSONLike = Any
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)
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
40class XMLProxy:
41 """Class providing dict-like access to edit XML via ElementTree.
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)
49 The semantics is implemented as follows:
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 """
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)
60 def _dump(self):
61 """Dump XML to stdout (for debugging)."""
62 ET.dump(self._node)
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
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 :]
76 # ----
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
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)
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)
101 def __repr__(self):
102 """See `object.__repr__`."""
103 return str(self._node)
105 def __len__(self):
106 """Return number of inner tags inside current XML element.
108 Note that bool(node) thus checks whether an XML node is a leaf in the element tree.
109 """
110 return len(self._node)
112 def __iter__(self):
113 """Iterate the nested elements in-order."""
114 return map(self._wrap, iter(self._node))
116 @property
117 def namespace(self) -> str | None:
118 """Default namespace of this node."""
119 return self._def_ns
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)
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)
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)
140 # ---- helpers ----
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).
150 Note that all leaf values are strings (i.e. not parsed to bool/int/float etc.).
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": {...}}`)
156 """
157 if not len(self): # leaf -> assume it's a primitive value
158 return self._node.text or ""
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)
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)
179 return dct if not keep_root else {self._shortened_key(self._node.tag): dct}
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)
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).
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 )
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)
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.
222 Note that booleans are serialized as `true`/`false` and None as `null`.
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.
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)
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)))
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 )
262 return cls(elem, **kwargs)
264 # ---- dict-like access ----
266 @overload
267 def get(
268 self, key: str, *, as_nodes: Literal[True], deep: Literal[False] = False
269 ) -> list[XMLProxy]: ...
271 @overload
272 def get(
273 self, key: str, *, as_nodes: Literal[False] = False, deep: bool = False
274 ) -> Any: ...
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.
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.
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
287 """
288 # NOTE: could allow to retrieve comments when using empty string/none as key?
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)
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
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
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)
318 def __contains__(self, key: str) -> bool:
319 """Acts like `dict.__contains__`, implemented with `get`."""
320 return self.get(key) is not None
322 def __delitem__(self, key: str | XMLProxy):
323 """Delete a nested XML element with matching key name.
325 Note that **all** XML elements with the given tag name are removed!
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:
330 ```
331 <a>
332 <b>1</b>
333 <c>2</c>
334 <b>3</b>
335 </a>
336 ```
338 Then we have that:
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 []
348 if not nodes:
349 raise KeyError(key)
351 if self._node.text is not None:
352 self._node.text = ""
353 for child in nodes:
354 self._node.remove(child._node)
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)
363 def __setitem__(self, key: str | XMLProxy, val: JSONLike | XMLProxy):
364 """Add or overwrite an inner XML tag.
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.
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)!
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:
376 ```
377 <a>
378 <b>1</b>
379 <c>2</c>
380 <b>3</b>
381 </a>
382 ```
384 Then we have that:
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`.
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
412 # ensure key string is qualified with a namespace
413 key_name: str = self._qualified_key(key)
415 # normalize passed value(s) to be list (general case)
416 vals = val if isinstance(val, list) else [val]
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)))
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 )
437 nvals.append(obj)
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)