1from __future__ import annotations
2
3import re
4from collections import defaultdict
5from xml.etree.ElementTree import Element
6
7from icalendar.error import XCalParsingError
8from icalendar.parser.xcal.string import string_to_xsd_float
9
10REGEX_WHITESPACE = re.compile(r"\s+", re.MULTILINE)
11
12
13class ElementAdapter:
14 """An adapter class with convenience methods for XML elements.
15
16 This is a read-only access adapter.
17 All operations on ElementAdapter are reproducible and
18 do not change the underlying element.
19 """
20
21 @classmethod
22 def with_element(cls, element: Element | ElementAdapter) -> ElementAdapter:
23 """Create a new element adapter or return the present one."""
24 if isinstance(element, ElementAdapter):
25 return element
26 return cls(element)
27
28 def __init__(self, element: Element) -> None:
29 self._element = element
30 self._tag = self._element.tag.split("}")[-1].lower()
31
32 @property
33 def element(self) -> Element:
34 """The underlying element."""
35 return self._element
36
37 @property
38 def tag(self) -> str:
39 """A sanitized element tag."""
40 return self._tag
41
42 @property
43 def is_extension(self) -> bool:
44 """Whether the element is in a namespace other than the iCalendar namespace.
45
46 Elements without a namespace belong to iCalendar.
47 Extension elements directly in ``properties`` are XML properties.
48
49 See also:
50 :rfc:`6321#section-4.1`.
51
52 Returns:
53 ``True`` for an element outside the iCalendar namespace,
54 or ``False`` for an element either in the iCalendar namespace
55 or without a namespace.
56 """
57 tag = self._element.tag
58 return tag.startswith("{") and not tag.startswith(
59 "{urn:ietf:params:xml:ns:icalendar-2.0}"
60 )
61
62 @property
63 def children(self) -> list[ChildElementAdapter]:
64 """A list of child elements."""
65 tags = defaultdict(int)
66 children = []
67 for child_element in self._element:
68 tag = child_element.tag
69 tags[tag] += 1
70 children.append(ChildElementAdapter(child_element, self, tags[tag]))
71 return children
72
73 def get_child_with_tag(self, tag: str) -> ChildElementAdapter | None:
74 """Get the first child element with the given tag."""
75 tag = tag.lower()
76 for child in self.children:
77 if child.tag == tag:
78 return child
79 return None
80
81 def parse_tag(self, tag: str):
82 """Get the first child element with the given tag.
83
84 Returns:
85 The first child element with the given tag.
86
87 Raises:
88 XCalParsingError: If the tag is not found.
89 """
90 child = self.get_child_with_tag(tag)
91 if child is None:
92 raise XCalParsingError(f"Tag {tag} not found", None, self)
93 return child
94
95 def get_xsd_string(self) -> str:
96 """Return the element's text as ``xsd:string``.
97
98 This is the only datatype that leaves all the whitespace.
99
100 .. seealso::
101 `xmlschemata.org <https://books.xmlschemata.org/relaxng/ch19-77303.html>`__
102 """
103 return self._element.text or ""
104
105 def get_xsd_token(self) -> str:
106 """Return the element's text as ``xsd:token``.
107
108 ``xsd:token`` is the most appropriate datatype to use for strings
109 that don't care about whitespace.
110
111 .. seealso::
112 `xmlschemata.org <https://books.xmlschemata.org/relaxng/ch19-77319.html>`__
113 """
114 return REGEX_WHITESPACE.sub(" ", self.get_xsd_string()).strip()
115
116 def get_text_without_whitespace(self) -> str:
117 """Return the element's text with all whitespace removed."""
118 return REGEX_WHITESPACE.sub("", self.get_xsd_string())
119
120 def get_xpath(self) -> str:
121 """Return the path in the XML file where this element occurs."""
122 return f"/{self.tag}"
123
124 def get_xsd_float(self) -> float:
125 """Return the element's text parsed as ``xsd:float``.
126
127 Returns:
128 The parsed float value.
129
130 Raises:
131 XCalParsingError: If the value is not a float.
132 """
133 try:
134 return string_to_xsd_float(self.get_xsd_token())
135 except (TypeError, ValueError) as e:
136 raise XCalParsingError(
137 "Expected xsd:float",
138 self.get_xsd_token(),
139 self,
140 ) from e
141
142 def __repr__(self) -> str:
143 """Return the text representation of this element."""
144 return f"Element@{self.get_xpath()}"
145
146 def make_parent(self, tag: str) -> ElementAdapter:
147 """Return an element adapter for a parent."""
148 parent = Element(tag)
149 parent.append(self._element)
150 return ElementAdapter(parent)
151
152 def get_inner_text(self) -> str:
153 """Return the inner text.
154
155 This is useful when there is a broken property.
156 """
157 return self.get_xsd_string()
158
159
160class ChildElementAdapter(ElementAdapter):
161 """An adapter class with convenience methods for child elements.
162
163 Parameters:
164 child: The child element.
165 index: The index for building the XPath in the parent.
166 parent: The parent element adapter.
167 """
168
169 def __init__(self, child: Element, parent: ElementAdapter, index: int = 1) -> None:
170 super().__init__(child)
171 self._parent = parent
172 self._index = index
173
174 @property
175 def parent(self) -> ElementAdapter:
176 """The parent element."""
177 return self._parent
178
179 def get_xpath(self) -> str:
180 """Return the path in the XML file where this element occurs."""
181 return f"{self.parent.get_xpath()}/{self.tag}[{self._index}]"