Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/icalendar/prop/xml.py: 31%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

134 statements  

1"""XML values from :rfc:`6321`.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from contextlib import contextmanager, suppress 

7from copy import copy, deepcopy 

8from io import BytesIO 

9from typing import TYPE_CHECKING, Any 

10from xml.etree.ElementTree import Element, ParseError, tostring 

11 

12from icalendar import config 

13from icalendar.error import InvalidCalendar 

14from icalendar.parser.xcal.adapter import ElementAdapter 

15from icalendar.parser.xcal.base import XCalParser 

16from icalendar.parser_tools import DEFAULT_ENCODING, ICAL_TYPE 

17 

18from .text import vText 

19 

20if TYPE_CHECKING: 

21 from collections.abc import Iterator 

22 

23 from icalendar.compatibility import Self 

24 

25# The text is already decoded, so an encoding in the XML declaration does not apply. 

26_DECLARED_ENCODING = re.compile( 

27 r"""^(\ufeff?<\?xml\s[^>]*?)\s+encoding\s*=\s*("[^"]*"|'[^']*')""", 

28 re.ASCII, 

29) 

30# Bytes are decoded with the encoding of their XML declaration. 

31_DECLARED_ENCODING_OF_BYTES = re.compile( 

32 rb"""^<\?xml\s[^>]*?\sencoding\s*=\s*["']([A-Za-z][A-Za-z0-9._-]*)["']""" 

33) 

34 

35 

36def _decode(data: bytes) -> str | bytes: 

37 """Decode XML bytes with the encoding of their XML declaration, if they have one.""" 

38 match = _DECLARED_ENCODING_OF_BYTES.match(data) 

39 if match: 

40 with suppress(LookupError, UnicodeDecodeError): 

41 return data.decode(match.group(1).decode("ascii")) 

42 return data 

43 

44 

45class vXml(vText): 

46 """The value of the XML property from :rfc:`6321#section-4.2`. 

47 

48 The XML property embeds an XML element into any component. 

49 The element is in a namespace other than the iCalendar namespace. 

50 The default value type is TEXT, so the value is the XML as text. 

51 XML with the value type BINARY is a :class:`~icalendar.prop.binary.vBinary`. 

52 Use :attr:`element` to get the XML element. 

53 

54 In xCal, the XML property does not exist. 

55 Instead the XML elements are directly copied into the ``<properties>`` element, 

56 see :rfc:`6321#section-4.1`. 

57 :mod:`~xml.etree.ElementTree` chooses the namespace prefixes there. 

58 

59 Two values are equal if their elements have the same tags, attributes 

60 and text, whatever the namespace prefixes and the order of the attributes. 

61 Values with a document type, and other strings, are compared as text. 

62 

63 Parameters: 

64 value: Required. The XML as a string, bytes, an :class:`~xml.etree.ElementTree.Element`, 

65 or an :class:`~icalendar.parser.xcal.adapter.ElementAdapter`. 

66 Bytes are decoded with the encoding of their XML declaration. 

67 An element adapter keeps its path in the xCal document. 

68 encoding: The encoding of bytes without an XML declaration. 

69 params: Optional parameter dictionary for the property. 

70 

71 Examples: 

72 Add XML to an event and get the XML element. 

73 

74 .. code-block:: pycon 

75 

76 >>> from icalendar import Event 

77 >>> event = Event() 

78 >>> event.add("XML", '<a xmlns="http://example.com/ns">text</a>') 

79 >>> print(event.to_ical().decode()) 

80 BEGIN:VEVENT 

81 XML:<a xmlns="http://example.com/ns">text</a> 

82 END:VEVENT 

83 >>> event["XML"].element.tag 

84 '{http://example.com/ns}a' 

85 

86 In xCal, the element is part of the properties of the event. 

87 

88 .. code-block:: pycon 

89 

90 >>> print(event.to_xcal(indent=2).decode()) 

91 <?xml version="1.0" encoding="UTF-8"?> 

92 <icalendar xmlns:ns0="http://example.com/ns" xmlns="urn:ietf:params:xml:ns:icalendar-2.0"> 

93 <vevent> 

94 <properties> 

95 <ns0:a>text</ns0:a> 

96 </properties> 

97 </vevent> 

98 </icalendar> 

99 """ 

100 

101 _element: ElementAdapter | None 

102 _normal_form: tuple | str | None 

103 __slots__ = ("_element", "_normal_form") 

104 

105 def __new__( 

106 cls, 

107 value: ICAL_TYPE | Element | ElementAdapter, 

108 encoding: str = DEFAULT_ENCODING, 

109 /, 

110 params: dict[str, Any] | None = None, 

111 ) -> Self: 

112 element = None 

113 if isinstance(value, bytes): 

114 value = _decode(value) 

115 elif isinstance(value, Element): 

116 element = ElementAdapter(deepcopy(value)) 

117 elif isinstance(value, ElementAdapter): 

118 element = value 

119 if element is not None: 

120 xml = copy(element.element) 

121 xml.tail = None 

122 value = tostring(xml, encoding="unicode") 

123 self = super().__new__(cls, value, encoding, params=params) 

124 self._normal_form = None 

125 # TEXT removes control characters. Then the element is not the value. 

126 self._element = ( 

127 element if element is not None and str.__eq__(self, value) else None 

128 ) 

129 return self 

130 

131 def _parse(self) -> Element: 

132 """Parse the text of this value as XML. 

133 

134 Raises: 

135 xml.etree.ElementTree.ParseError: If the value is not XML. 

136 """ 

137 text = _DECLARED_ENCODING.sub(r"\1", self, count=1) 

138 data = text.encode("utf-8", "surrogatepass") 

139 try: 

140 return config.parse_xml(BytesIO(data)).getroot() 

141 except LookupError as error: 

142 # The XML declaration names an unknown encoding. 

143 raise ParseError(error) from error 

144 

145 @property 

146 def element(self) -> Element: 

147 """The XML element of this value. 

148 

149 Returns: 

150 A new :class:`~xml.etree.ElementTree.Element` each time this is accessed. 

151 

152 Raises: 

153 xml.etree.ElementTree.ParseError: If the value is not XML. 

154 """ 

155 if self._element is None: 

156 self._element = ElementAdapter(self._parse()) 

157 element = deepcopy(self._element.element) 

158 element.tail = None 

159 return element 

160 

161 @property 

162 def ical_value(self) -> Element: 

163 """The XML element of this value. 

164 

165 See also: 

166 :attr:`element`. 

167 

168 """ 

169 return self.element 

170 

171 @classmethod 

172 def from_xcal(cls, xcal: XCalParser | ElementAdapter | Element) -> Self: 

173 """Parse xCal from :rfc:`6321`. 

174 

175 An element in a namespace other than the iCalendar namespace 

176 is the XML element of an XML property per :rfc:`6321#section-4.1`. 

177 An ``xml`` property element is parsed like other TEXT properties. 

178 

179 Parameters: 

180 xcal: The XML to parse or a parser. 

181 

182 Returns: 

183 The value of the XML property. 

184 

185 Raises: 

186 ~icalendar.error.XCalParsingError: If the provided xCal is invalid. 

187 

188 Example: 

189 .. code-block:: pycon 

190 

191 >>> from xml.etree.ElementTree import fromstring 

192 >>> from icalendar import vXml 

193 >>> vXml.from_xcal(fromstring('<a xmlns="http://example.com/ns">text</a>')) 

194 vXml(b'<ns0:a xmlns:ns0="http://example.com/ns">text</ns0:a>') 

195 """ 

196 element = xcal.element if isinstance(xcal, XCalParser) else xcal 

197 if isinstance(element, Element): 

198 element = ElementAdapter(element) 

199 if element.is_extension: 

200 return cls(element) 

201 return super().from_xcal(xcal) 

202 

203 def to_xcal(self, element: Element) -> None: 

204 """Copy the attribute :attr:`element` into the parameter ``element``. 

205 

206 All other property classes in :mod:`icalendar.prop` add children to the ``element`` parameter. 

207 :class:`vXml` is special because it holds a single XML 1.0 element per :rfc:`6321#section-4.1`. 

208 Such an element resides directly inside the ``<properties>`` tag. 

209 Because of this, the tag, attributes, text, and children of ``element`` 

210 are replaced with the XML 1.0 element contained in this object. 

211 

212 Parameters: 

213 element: The ``xml`` property element in the xCal properties. 

214 

215 Raises: 

216 ~icalendar.error.InvalidCalendar: If the element is not the ``xml`` 

217 property element, the XML property has parameters other than 

218 ``VALUE``, its value is not XML, or its element either has no namespace 

219 or has the iCalendar namespace. 

220 

221 Example: 

222 The ``xml`` property element becomes the XML element. 

223 

224 .. code-block:: pycon 

225 

226 >>> from xml.etree.ElementTree import Element, tostring 

227 >>> from icalendar import vXml 

228 >>> element = Element("xml") 

229 >>> vXml('<a xmlns="http://example.com/ns">text</a>').to_xcal(element) 

230 >>> tostring(element) 

231 b'<ns0:a xmlns:ns0="http://example.com/ns">text</ns0:a>' 

232 """ 

233 if element.tag != "xml": 

234 raise InvalidCalendar( 

235 f"{self.__class__.__name__} can only be written to xCal" 

236 " as the value of the XML property." 

237 ) 

238 parameters = set(self.params) - {"VALUE"} 

239 if parameters: 

240 raise InvalidCalendar( 

241 "Cannot write the XML property to xCal with the parameters " 

242 + ", ".join(sorted(parameters)) 

243 ) 

244 try: 

245 xml = self.element 

246 except ParseError as error: 

247 raise InvalidCalendar( 

248 f"Cannot write the XML property to xCal: {error}" 

249 ) from error 

250 if not ElementAdapter(xml).is_extension: 

251 name = ElementAdapter(xml).tag 

252 raise InvalidCalendar( 

253 f"Cannot write the XML property to xCal: the element {xml.tag!r}" 

254 " needs a namespace other than the iCalendar namespace." 

255 f' Add one with the xmlns attribute, for example <{name} xmlns="' 

256 'http://example.com/ns">.' 

257 ) 

258 if any( 

259 isinstance(child.tag, str) and not child.tag.startswith("{") 

260 for child in xml.iter() 

261 ): 

262 # Elements without a namespace do not get the iCalendar namespace. 

263 xml.set("xmlns", "") 

264 element.tag = xml.tag 

265 element.attrib = xml.attrib 

266 element.text = xml.text 

267 element[:] = list(xml) 

268 

269 def _get_normal_form(self) -> tuple | str: 

270 """What equality compares: the XML elements, or the text. 

271 

272 The text is compared if the value is not XML, or if it has a document 

273 type, which could make the XML much larger than the text. 

274 The normal form is computed once. 

275 """ 

276 if self._normal_form is None: 

277 self._normal_form = str(self) 

278 if "<!DOCTYPE" not in self: 

279 with suppress(ParseError, ValueError): 

280 self._normal_form = self._get_elements() 

281 return self._normal_form 

282 

283 def _get_elements(self) -> tuple: 

284 """The elements of the XML in document order, without recursion. 

285 

286 Each element has its tag with the namespace, its sorted attributes, 

287 its text, its tail, and its number of children. 

288 

289 Raises: 

290 xml.etree.ElementTree.ParseError: If the value is not XML. 

291 """ 

292 root = self._parse() if self._element is None else self._element.element 

293 elements = [] 

294 stack = [root] 

295 while stack: 

296 e = stack.pop() 

297 attributes = tuple(sorted(e.attrib.items())) 

298 tail = None if e is root else e.tail 

299 elements.append((e.tag, attributes, e.text, tail, len(e))) 

300 stack.extend(reversed(e)) 

301 return tuple(elements) 

302 

303 def __eq__(self, other: object) -> bool: 

304 """Compare the XML of two vXml values.""" 

305 if not isinstance(other, vXml): 

306 return NotImplemented 

307 return ( 

308 str.__eq__(self, other) 

309 or self._get_normal_form() == other._get_normal_form() 

310 ) 

311 

312 def __ne__(self, other: object) -> bool: 

313 """The opposite of ``==``.""" 

314 result = self.__eq__(other) 

315 return result if result is NotImplemented else not result 

316 

317 def __hash__(self) -> int: 

318 """Hash what two vXml values compare.""" 

319 return hash(self._get_normal_form()) 

320 

321 @classmethod 

322 def examples(cls) -> list[Self]: 

323 """Examples of vXml.""" 

324 return [cls('<a xmlns="http://example.com/ns">Hello World!</a>')] 

325 

326 

327@contextmanager 

328def _preserve_indentation_of_xcal_extension_properties( 

329 xcal: Element, 

330) -> Iterator[None]: 

331 """Keep the whitespace in the elements of XML properties in xCal. 

332 

333 :func:`xml.etree.ElementTree.indent` changes the whitespace in elements. 

334 In this context, the elements of XML properties are replaced 

335 by empty elements. 

336 """ 

337 hidden = [] 

338 for properties in xcal.iter("properties"): 

339 for index, element in enumerate(properties): 

340 if ElementAdapter(element).is_extension: 

341 placeholder = Element(element.tag) 

342 properties[index] = placeholder 

343 hidden.append((properties, index, element, placeholder)) 

344 try: 

345 yield 

346 finally: 

347 for properties, index, element, placeholder in hidden: 

348 element.tail = placeholder.tail 

349 properties[index] = element 

350 

351 

352__all__ = ["vXml"]