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 children(self) -> list[ChildElementAdapter]:
44 """A list of child elements."""
45 tags = defaultdict(int)
46 children = []
47 for child_element in self._element:
48 tag = child_element.tag
49 tags[tag] += 1
50 children.append(ChildElementAdapter(child_element, self, tags[tag]))
51 return children
52
53 def get_child_with_tag(self, tag: str) -> ChildElementAdapter | None:
54 """Get the first child element with the given tag."""
55 tag = tag.lower()
56 for child in self.children:
57 if child.tag == tag:
58 return child
59 return None
60
61 def parse_tag(self, tag: str):
62 """Get the first child element with the given tag.
63
64 Returns:
65 The first child element with the given tag.
66
67 Raises:
68 XCalParsingError: If the tag is not found.
69 """
70 child = self.get_child_with_tag(tag)
71 if child is None:
72 raise XCalParsingError(f"Tag {tag} not found", None, self)
73 return child
74
75 def get_xsd_string(self) -> str:
76 """Return the element's text as ``xsd:string``.
77
78 This is the only datatype that leaves all the whitespace.
79
80 .. seealso::
81 `xmlschemata.org <https://books.xmlschemata.org/relaxng/ch19-77303.html>`__
82 """
83 return self._element.text or ""
84
85 def get_xsd_token(self) -> str:
86 """Return the element's text as ``xsd:token``.
87
88 ``xsd:token`` is the most appropriate datatype to use for strings
89 that don't care about whitespace.
90
91 .. seealso::
92 `xmlschemata.org <https://books.xmlschemata.org/relaxng/ch19-77319.html>`__
93 """
94 return REGEX_WHITESPACE.sub(" ", self.get_xsd_string()).strip()
95
96 def get_text_without_whitespace(self) -> str:
97 """Return the element's text with all whitespace removed."""
98 return REGEX_WHITESPACE.sub("", self.get_xsd_string())
99
100 def get_xpath(self) -> str:
101 """Return the path in the XML file where this element occurs."""
102 return f"/{self.tag}"
103
104 def get_xsd_float(self) -> float:
105 """Return the element's text parsed as ``xsd:float``.
106
107 Returns:
108 The parsed float value.
109
110 Raises:
111 XCalParsingError: If the value is not a float.
112 """
113 try:
114 return string_to_xsd_float(self.get_xsd_token())
115 except (TypeError, ValueError) as e:
116 raise XCalParsingError(
117 "Expected xsd:float",
118 self.get_xsd_token(),
119 self,
120 ) from e
121
122 def __repr__(self) -> str:
123 """Return the text representation of this element."""
124 return f"Element@{self.get_xpath()}"
125
126 def make_parent(self, tag: str) -> ElementAdapter:
127 """Return an element adapter for a parent."""
128 parent = Element(tag)
129 parent.append(self._element)
130 return ElementAdapter(parent)
131
132 def get_inner_text(self) -> str:
133 """Return the inner text.
134
135 This is useful when there is a broken property.
136 """
137 return self.get_xsd_string()
138
139
140class ChildElementAdapter(ElementAdapter):
141 """An adapter class with convenience methods for child elements.
142
143 Parameters:
144 child: The child element.
145 index: The index for building the XPath in the parent.
146 parent: The parent element adapter.
147 """
148
149 def __init__(self, child: Element, parent: ElementAdapter, index: int = 1) -> None:
150 super().__init__(child)
151 self._parent = parent
152 self._index = index
153
154 @property
155 def parent(self) -> ElementAdapter:
156 """The parent element."""
157 return self._parent
158
159 def get_xpath(self) -> str:
160 """Return the path in the XML file where this element occurs."""
161 return f"{self.parent.get_xpath()}/{self.tag}[{self._index}]"