Coverage for src/epublib/reference.py: 91%
160 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 16:07 -0300
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 16:07 -0300
1from __future__ import annotations
3from abc import ABC, abstractmethod
4from collections.abc import Generator, Sequence
5from typing import Any, Self, cast, override
7import bs4
9from epublib.exceptions import EPUBError
10from epublib.util import (
11 attr_to_str,
12 get_relative_href,
13 split_fragment,
14 strip_fragment,
15)
18class NavigationReference[
19 C: NavigationReference,
20 S: bs4.BeautifulSoup = bs4.BeautifulSoup,
21](ABC):
22 """
23 Abstract base class for nested references containing text and href.
24 This simplest form assumes there is a text tag, and an href tag,
25 show href_attr stores the href.
26 """
28 tag_name: str
29 text_selector: str
30 text_tag_name: str
31 href_selector: str
32 href_tag_name: str
33 href_attr: str
34 child_class: type[C] | None = None
36 def __init__(
37 self,
38 soup: S,
39 tag: bs4.Tag | None = None,
40 parent: NavigationReference[Any] | None = None, # type: ignore[reportAny]
41 text: str | None = None,
42 href: str | None = None,
43 depth: int = 0,
44 ):
45 # Child is responsible for creating new tag if tag is None, but
46 # will not insert itself in its parent
47 self.soup: S = soup
48 self.tag: bs4.Tag = tag if tag else self._create_own_tag()
49 self.depth: int = depth
50 self.parent: NavigationReference[Any] | None = parent # type: ignore[reportAny]
52 self._items: list[C] = self._init_items()
54 if text is not None:
55 self.text = text
57 if href is not None:
58 self.href = href
60 def _create_own_tag(self) -> bs4.Tag:
61 tag = self.soup.new_tag(self.tag_name)
63 return tag
65 def _get_text_tag(self) -> bs4.Tag | None:
66 return self.tag.select_one(self.text_selector)
68 def _get_href_tag(self) -> bs4.Tag | None:
69 return self.tag.select_one(self.href_selector)
71 def _create_text(self, value: str) -> bs4.Tag:
72 text_tag = self.soup.new_tag(self.text_tag_name)
73 text_tag.string = value
74 __ = self.tag.insert(0, text_tag)
76 return text_tag
78 def _create_href(self, value: str) -> bs4.Tag | None:
79 href_tag = self.soup.new_tag(self.href_tag_name)
80 href_tag[self.href_attr] = value
81 __ = self.tag.append(href_tag)
83 return href_tag
85 @property
86 def text(self) -> str:
87 text_tag = self._get_text_tag()
88 if not text_tag:
89 return ""
90 return text_tag.get_text()
92 @text.setter
93 def text(self, value: str) -> None:
94 text_tag = self._get_text_tag()
96 if text_tag:
97 text_tag.string = value
99 else:
100 __ = self._create_text(value)
102 def _set_href_tag(self, value: str) -> None:
103 href_tag = self._get_href_tag()
105 if href_tag:
106 href_tag[self.href_attr] = value
108 else:
109 __ = self._create_href(value)
111 @property
112 def href(self) -> str:
113 href_tag = self._get_href_tag()
114 if not href_tag:
115 return ""
116 return attr_to_str(href_tag[self.href_attr])
118 @href.setter
119 def href(self, value: str) -> None:
120 self._set_href_tag(value)
122 @property
123 def items(self) -> Sequence[C]:
124 return tuple(self._items)
126 def _get_child_class(self) -> type[C]:
127 return (
128 cast(type[C], self.__class__)
129 if self.child_class is None
130 else self.child_class
131 )
133 @abstractmethod
134 def _get_children_tags(self) -> list[bs4.Tag]:
135 pass
137 def _init_items(self) -> list[C]:
138 items: list[C] = []
139 cls = self._get_child_class()
141 for child_tag in self._get_children_tags():
142 items.append(
143 cls(
144 self.soup,
145 child_tag,
146 parent=self, # type: ignore[reportArgumentType]
147 depth=self.depth + 1,
148 )
149 )
151 return items
153 @abstractmethod
154 def _insert_tag(self, position: int, tag: bs4.Tag):
155 pass
157 def add_item(
158 self,
159 text: str | None,
160 href: str | None,
161 position: int | None = None,
162 ):
163 position = len(self.items) if position is None else position
165 item = self._get_child_class()(
166 self.soup,
167 parent=self,
168 text=text,
169 href=href,
170 depth=self.depth + 1,
171 )
172 self._insert_tag(position, item.tag)
173 self._items.insert(position, item)
175 return item
177 @override
178 def __repr__(self) -> str:
179 repr = f"{self.__class__.__name__}({self.tag.name}"
180 if self.items:
181 repr += f", {len(self.items)} items"
183 repr += ")"
185 return repr
187 def remove_item(self, item: C):
188 if item not in self.items:
189 raise EPUBError(
190 f"Can't remove item '{item}' as it is not present in '{self}'"
191 )
193 __ = item.tag.extract()
194 self._items.remove(item)
196 def remove(
197 self,
198 base_filename: str,
199 filename: str,
200 ignore_fragment: bool = False,
201 ):
202 relative_href = get_relative_href(base_filename, filename) if filename else None
203 for item in self.items:
204 cmp = strip_fragment(item.href) if ignore_fragment else item.href
205 if cmp == relative_href:
206 self.remove_item(item)
207 else:
208 item.remove(base_filename, filename, ignore_fragment)
210 def items_referencing(
211 self,
212 filename: str,
213 ignore_fragment: bool = False,
214 ) -> Generator[Self | C]:
215 if self.href:
216 cmp = strip_fragment(self.href) if ignore_fragment else self.href
217 if cmp == filename:
218 yield self
220 for item in self.items:
221 yield from (
222 cast(C, it)
223 for it in item.items_referencing(
224 filename,
225 ignore_fragment,
226 )
227 )
229 def add_after(self, text: str | None, href: str | None) -> C:
230 """Add item with text and href after this item in the parent's list."""
231 if not self.parent:
232 raise EPUBError("Can't add after root item")
234 position = self.parent.items.index(self)
235 return cast(C, self.parent.add_item(text, href, position + 1))
237 @property
238 def max_depth(self) -> int:
239 if not self.items:
240 return self.depth
242 return max(self.depth, *(item.max_depth for item in self.items))
245class NavigationRoot[
246 C: NavigationReference[Any],
247 D,
248 S: bs4.BeautifulSoup = bs4.BeautifulSoup,
249](
250 NavigationReference[C, S],
251 ABC,
252):
253 """
254 Abstract base class for root of list of references containing only text
255 and href.
256 """
258 def __init__(
259 self,
260 soup: S,
261 tag: bs4.Tag | None,
262 base_filename: str,
263 text: str | None = None,
264 ):
265 self.base_filename: str = base_filename
266 super().__init__(soup, tag, parent=None, text=text)
267 if tag is None:
268 self._insert_self_in_soup()
270 @abstractmethod
271 def _insert_self_in_soup(self):
272 pass
274 @abstractmethod
275 def reset(self, entries: Sequence[D]) -> None:
276 pass
278 @override
279 def remove( # type: ignore[reportIncompatibleMethodOverride]
280 self,
281 filename: str,
282 ignore_fragment: bool = True,
283 ):
284 return super().remove(self.base_filename, filename, ignore_fragment)
286 @override
287 def items_referencing(
288 self,
289 filename: str,
290 ignore_fragment: bool = True,
291 ) -> Generator[C]:
292 relative_filenames: list[str] = []
294 base, fragment = split_fragment(filename)
295 if base == self.base_filename:
296 relative_filenames.append("" if fragment is None else f"#{fragment}")
298 relative_filenames.append(get_relative_href(self.base_filename, filename))
300 for item in self.items:
301 for relative_filename in relative_filenames:
302 yield from (
303 cast(C, it)
304 for it in item.items_referencing(
305 relative_filename,
306 ignore_fragment,
307 )
308 )
310 @property
311 @override
312 def href(self) -> str:
313 raise EPUBError(f"Root navigation list ({self.__class__.__name__}) has no href")
315 @href.setter
316 @override
317 def href(self, value: str) -> None:
318 raise EPUBError(
319 f"Can't set href on root of navigation list ({self.__class__.__name__})"
320 )