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
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
1"""XML values from :rfc:`6321`."""
3from __future__ import annotations
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
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
18from .text import vText
20if TYPE_CHECKING:
21 from collections.abc import Iterator
23 from icalendar.compatibility import Self
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)
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
45class vXml(vText):
46 """The value of the XML property from :rfc:`6321#section-4.2`.
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.
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.
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.
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.
71 Examples:
72 Add XML to an event and get the XML element.
74 .. code-block:: pycon
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'
86 In xCal, the element is part of the properties of the event.
88 .. code-block:: pycon
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 """
101 _element: ElementAdapter | None
102 _normal_form: tuple | str | None
103 __slots__ = ("_element", "_normal_form")
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
131 def _parse(self) -> Element:
132 """Parse the text of this value as XML.
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
145 @property
146 def element(self) -> Element:
147 """The XML element of this value.
149 Returns:
150 A new :class:`~xml.etree.ElementTree.Element` each time this is accessed.
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
161 @property
162 def ical_value(self) -> Element:
163 """The XML element of this value.
165 See also:
166 :attr:`element`.
168 """
169 return self.element
171 @classmethod
172 def from_xcal(cls, xcal: XCalParser | ElementAdapter | Element) -> Self:
173 """Parse xCal from :rfc:`6321`.
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.
179 Parameters:
180 xcal: The XML to parse or a parser.
182 Returns:
183 The value of the XML property.
185 Raises:
186 ~icalendar.error.XCalParsingError: If the provided xCal is invalid.
188 Example:
189 .. code-block:: pycon
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)
203 def to_xcal(self, element: Element) -> None:
204 """Copy the attribute :attr:`element` into the parameter ``element``.
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.
212 Parameters:
213 element: The ``xml`` property element in the xCal properties.
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.
221 Example:
222 The ``xml`` property element becomes the XML element.
224 .. code-block:: pycon
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)
269 def _get_normal_form(self) -> tuple | str:
270 """What equality compares: the XML elements, or the text.
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
283 def _get_elements(self) -> tuple:
284 """The elements of the XML in document order, without recursion.
286 Each element has its tag with the namespace, its sorted attributes,
287 its text, its tail, and its number of children.
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)
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 )
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
317 def __hash__(self) -> int:
318 """Hash what two vXml values compare."""
319 return hash(self._get_normal_form())
321 @classmethod
322 def examples(cls) -> list[Self]:
323 """Examples of vXml."""
324 return [cls('<a xmlns="http://example.com/ns">Hello World!</a>')]
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.
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
352__all__ = ["vXml"]