Coverage for gws-app/gws/lib/xmlx/serializer.py: 97%
149 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""XML serializer."""
3from typing import Optional
5import re
7import gws
9from . import error, namespace, util
12class Serializer:
13 """Serializer of an element tree to a string, used by ``XmlElement.to_string``.
15 A serializer object is used for one call of ``to_string``.
16 """
18 def __init__(self, el: gws.XmlElement, opts: Optional[gws.XmlOptions]):
19 """Create a serializer.
21 Args:
22 el: The root element.
23 opts: Serialization options, defaults are used if ``None``.
24 """
26 self.root = el
27 self.buf = []
29 self.opts = opts or gws.XmlOptions()
30 self.defaultNamespace = self.opts.defaultNamespace
32 self.ns_renames = self.opts.customNamespacePrefixes or {}
33 self.ns_stack = []
35 def to_string(self) -> str:
36 """Serialize the tree.
38 Returns:
39 The XML string.
41 Raises:
42 WriteError: If the tree contains an invalid or prefixed name, or an invalid prefix.
43 NamespaceError: If a namespace is unknown, or one prefix is used for different URIs.
44 """
46 if self.opts.withXmlDeclaration or self.opts.doctype:
47 self.buf.append(_XML_DECL)
48 if self.opts.doctype:
49 self.buf.append(f'<!DOCTYPE {self.opts.doctype}>')
51 root_ns = list(self.root.namespaces)
52 if self.opts.withNamespaceDeclarations:
53 root_ns = self._collect_namespaces(root_ns)
55 self._el_to_string(self.root, root_ns, is_root=True)
57 return ''.join(self.buf)
59 ##
61 def _el_to_string(self, el, namespaces, is_root=False):
62 """Write an element, its children and its tail to the buffer."""
64 self.ns_stack.append(namespaces)
66 tag = self._element_name(el.tag)
67 atts = self._process_atts(el.attrib)
68 atts.update(self._namespace_declarations(namespaces, is_root))
70 open_tag = tag
71 if atts:
72 open_tag += ' ' + ' '.join(f'{k}="{v}"' for k, v in atts.items())
74 txt = self._text_to_string(el.text)
75 if txt or len(el) > 0:
76 self.buf.append(f'<{open_tag}>')
77 self.buf.append(txt)
78 for child in el:
79 self._el_to_string(child, child.namespaces)
80 self.buf.append(f'</{tag}>')
81 else:
82 self.buf.append(f'<{open_tag}/>')
84 txt = self._text_to_string(el.tail)
85 if txt:
86 self.buf.append(txt)
88 self.ns_stack.pop()
90 def _process_atts(self, attrib):
91 """Convert attribute names and values, skipping ``None`` values."""
93 atts = {}
95 for key, val in attrib.items():
96 if val is None:
97 continue
98 atts[self._attribute_name(key)] = self._value_to_string(val)
100 return atts
102 def _namespace_declarations(self, namespaces, is_root):
103 """Create ``xmlns`` attributes for namespaces declared on an element."""
105 if not namespaces:
106 return {}
107 return namespace.declarations(
108 namespaces,
109 self.ns_renames,
110 with_schema_locations=is_root and self.opts.withSchemaLocations,
111 )
113 def _element_name(self, name):
114 """Get the output name of an element, unprefixed in the default namespace."""
116 uri, pname = self._parse_name(name)
118 if not uri:
119 return pname
121 if uri == self._default_uri():
122 return pname
124 return self._prefix(uri, name) + ':' + pname
126 def _attribute_name(self, name):
127 """Get the output name of an attribute."""
129 uri, pname = self._parse_name(name)
131 if not uri:
132 return pname
134 return self._prefix(uri, name) + ':' + pname
136 def _parse_name(self, name):
137 """Split a name into URI and local name, rejecting prefixed and invalid names."""
139 uri, prefix, pname = namespace.parse_name(name)
140 if prefix:
141 raise error.WriteError(f'prefixed name {name!r}')
142 if not re.fullmatch(_NAME_RE, pname):
143 raise error.WriteError(f'invalid XML name {name!r}')
144 return uri, pname
146 def _default_uri(self):
147 """Get the URI of the default namespace in the current scope."""
149 for nss in reversed(self.ns_stack):
150 for ns in nss:
151 if ns.prefix == '':
152 return ns.uri
153 if self.defaultNamespace:
154 return self.defaultNamespace.uri
155 return ''
157 def _prefix(self, uri, name):
158 """Get the output prefix for a namespace URI."""
160 if uri == namespace.XML_URI:
161 return namespace.XML
163 for nss in reversed(self.ns_stack):
164 for ns in nss:
165 if ns.uri == uri and ns.prefix:
166 return self._final_prefix(ns)
168 ns = namespace.find_by_uri(uri)
169 if ns:
170 return self._final_prefix(ns)
172 if uri.startswith(namespace.ADHOC):
173 return uri.removeprefix(namespace.ADHOC)
175 raise error.NamespaceError(f'unknown namespace in {name!r}')
177 def _final_prefix(self, ns) -> str:
178 """Apply ``customNamespacePrefixes`` to a namespace prefix and validate it."""
180 pfx = self.ns_renames.get(ns.uri) or ns.prefix
181 if not re.fullmatch(_NAME_RE, pfx):
182 raise error.WriteError(f'invalid XML prefix {pfx!r}')
183 return pfx
185 def _collect_namespaces(self, root_ns):
186 """Collect the namespaces to be declared on the root element."""
188 # namespaces to declare on the root: its own, the default one and those used in the tree
189 # and not declared on an enclosing element
191 nss = list(root_ns)
193 if self.defaultNamespace and all(ns.prefix != '' for ns in nss):
194 nss.append(namespace.new('', self.defaultNamespace.uri, self.defaultNamespace.schemaLocation))
196 def declared(uri, scopes, for_attribute):
197 for lst in scopes:
198 for ns in lst:
199 if ns.uri == uri and (ns.prefix or not for_attribute):
200 return True
201 return False
203 def resolve(uri, name, scopes, for_attribute):
204 if not uri or uri == namespace.XML_URI or uri.startswith(namespace.ADHOC):
205 return
206 if declared(uri, scopes, for_attribute):
207 return
208 ns = namespace.find_by_uri(uri)
209 if not ns:
210 raise error.NamespaceError(f'unknown namespace in {name!r}')
211 nss.append(ns)
213 def walk(el, scopes):
214 uri, _, _ = namespace.parse_name(el.tag)
215 resolve(uri, el.tag, scopes, False)
216 for key in el.attrib:
217 uri, _, _ = namespace.parse_name(key)
218 resolve(uri, key, scopes, True)
219 for c in el:
220 walk(c, scopes + [c.namespaces] if c.namespaces else scopes)
222 walk(self.root, [nss])
224 seen = {}
225 for ns in nss:
226 pfx = self.ns_renames.get(ns.uri) or ns.prefix
227 if pfx in seen and seen[pfx] != ns.uri:
228 raise error.NamespaceError(f'namespace prefix {pfx!r} is used for {seen[pfx]!r} and {ns.uri!r}')
229 seen[pfx] = ns.uri
231 return nss
233 def _text_to_string(self, arg):
234 """Convert and escape a text value."""
236 s, ok = util.atom_to_string(arg)
237 if not ok:
238 s = str(arg)
239 if self.opts.compactWhitespace:
240 s = ' '.join(s.strip().split())
241 return util.escape_text(s)
243 def _value_to_string(self, arg):
244 """Convert and escape an attribute value."""
246 s, ok = util.atom_to_string(arg)
247 if not ok:
248 s = str(arg)
249 return util.escape_attribute(s)
252_XML_DECL = '<?xml version="1.0" encoding="UTF-8"?>'
254# XML 1.0 (5th ed.) NCName, https://www.w3.org/TR/xml/#NT-Name
256_NAME_START = (
257 'A-Za-z_'
258 '\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02ff\u0370-\u037d\u037f-\u1fff\u200c-\u200d'
259 '\u2070-\u218f\u2c00-\u2fef\u3001-\ud7ff\uf900-\ufdcf\ufdf0-\ufffd\U00010000-\U000effff'
260)
261_NAME_CHAR = _NAME_START + '\\-.0-9\u00b7\u0300-\u036f\u203f-\u2040'
262_NAME_RE = f'[{_NAME_START}][{_NAME_CHAR}]*'