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

1"""XML serializer.""" 

2 

3from typing import Optional 

4 

5import re 

6 

7import gws 

8 

9from . import error, namespace, util 

10 

11 

12class Serializer: 

13 """Serializer of an element tree to a string, used by ``XmlElement.to_string``. 

14 

15 A serializer object is used for one call of ``to_string``. 

16 """ 

17 

18 def __init__(self, el: gws.XmlElement, opts: Optional[gws.XmlOptions]): 

19 """Create a serializer. 

20 

21 Args: 

22 el: The root element. 

23 opts: Serialization options, defaults are used if ``None``. 

24 """ 

25 

26 self.root = el 

27 self.buf = [] 

28 

29 self.opts = opts or gws.XmlOptions() 

30 self.defaultNamespace = self.opts.defaultNamespace 

31 

32 self.ns_renames = self.opts.customNamespacePrefixes or {} 

33 self.ns_stack = [] 

34 

35 def to_string(self) -> str: 

36 """Serialize the tree. 

37 

38 Returns: 

39 The XML string. 

40 

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 """ 

45 

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}>') 

50 

51 root_ns = list(self.root.namespaces) 

52 if self.opts.withNamespaceDeclarations: 

53 root_ns = self._collect_namespaces(root_ns) 

54 

55 self._el_to_string(self.root, root_ns, is_root=True) 

56 

57 return ''.join(self.buf) 

58 

59 ## 

60 

61 def _el_to_string(self, el, namespaces, is_root=False): 

62 """Write an element, its children and its tail to the buffer.""" 

63 

64 self.ns_stack.append(namespaces) 

65 

66 tag = self._element_name(el.tag) 

67 atts = self._process_atts(el.attrib) 

68 atts.update(self._namespace_declarations(namespaces, is_root)) 

69 

70 open_tag = tag 

71 if atts: 

72 open_tag += ' ' + ' '.join(f'{k}="{v}"' for k, v in atts.items()) 

73 

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}/>') 

83 

84 txt = self._text_to_string(el.tail) 

85 if txt: 

86 self.buf.append(txt) 

87 

88 self.ns_stack.pop() 

89 

90 def _process_atts(self, attrib): 

91 """Convert attribute names and values, skipping ``None`` values.""" 

92 

93 atts = {} 

94 

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) 

99 

100 return atts 

101 

102 def _namespace_declarations(self, namespaces, is_root): 

103 """Create ``xmlns`` attributes for namespaces declared on an element.""" 

104 

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 ) 

112 

113 def _element_name(self, name): 

114 """Get the output name of an element, unprefixed in the default namespace.""" 

115 

116 uri, pname = self._parse_name(name) 

117 

118 if not uri: 

119 return pname 

120 

121 if uri == self._default_uri(): 

122 return pname 

123 

124 return self._prefix(uri, name) + ':' + pname 

125 

126 def _attribute_name(self, name): 

127 """Get the output name of an attribute.""" 

128 

129 uri, pname = self._parse_name(name) 

130 

131 if not uri: 

132 return pname 

133 

134 return self._prefix(uri, name) + ':' + pname 

135 

136 def _parse_name(self, name): 

137 """Split a name into URI and local name, rejecting prefixed and invalid names.""" 

138 

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 

145 

146 def _default_uri(self): 

147 """Get the URI of the default namespace in the current scope.""" 

148 

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 '' 

156 

157 def _prefix(self, uri, name): 

158 """Get the output prefix for a namespace URI.""" 

159 

160 if uri == namespace.XML_URI: 

161 return namespace.XML 

162 

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) 

167 

168 ns = namespace.find_by_uri(uri) 

169 if ns: 

170 return self._final_prefix(ns) 

171 

172 if uri.startswith(namespace.ADHOC): 

173 return uri.removeprefix(namespace.ADHOC) 

174 

175 raise error.NamespaceError(f'unknown namespace in {name!r}') 

176 

177 def _final_prefix(self, ns) -> str: 

178 """Apply ``customNamespacePrefixes`` to a namespace prefix and validate it.""" 

179 

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 

184 

185 def _collect_namespaces(self, root_ns): 

186 """Collect the namespaces to be declared on the root element.""" 

187 

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 

190 

191 nss = list(root_ns) 

192 

193 if self.defaultNamespace and all(ns.prefix != '' for ns in nss): 

194 nss.append(namespace.new('', self.defaultNamespace.uri, self.defaultNamespace.schemaLocation)) 

195 

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 

202 

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) 

212 

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) 

221 

222 walk(self.root, [nss]) 

223 

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 

230 

231 return nss 

232 

233 def _text_to_string(self, arg): 

234 """Convert and escape a text value.""" 

235 

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) 

242 

243 def _value_to_string(self, arg): 

244 """Convert and escape an attribute value.""" 

245 

246 s, ok = util.atom_to_string(arg) 

247 if not ok: 

248 s = str(arg) 

249 return util.escape_attribute(s) 

250 

251 

252_XML_DECL = '<?xml version="1.0" encoding="UTF-8"?>' 

253 

254# XML 1.0 (5th ed.) NCName, https://www.w3.org/TR/xml/#NT-Name 

255 

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}]*'