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

1from __future__ import annotations 

2 

3from abc import ABC, abstractmethod 

4from collections.abc import Generator, Sequence 

5from typing import Any, Self, cast, override 

6 

7import bs4 

8 

9from epublib.exceptions import EPUBError 

10from epublib.util import ( 

11 attr_to_str, 

12 get_relative_href, 

13 split_fragment, 

14 strip_fragment, 

15) 

16 

17 

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 """ 

27 

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 

35 

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] 

51 

52 self._items: list[C] = self._init_items() 

53 

54 if text is not None: 

55 self.text = text 

56 

57 if href is not None: 

58 self.href = href 

59 

60 def _create_own_tag(self) -> bs4.Tag: 

61 tag = self.soup.new_tag(self.tag_name) 

62 

63 return tag 

64 

65 def _get_text_tag(self) -> bs4.Tag | None: 

66 return self.tag.select_one(self.text_selector) 

67 

68 def _get_href_tag(self) -> bs4.Tag | None: 

69 return self.tag.select_one(self.href_selector) 

70 

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) 

75 

76 return text_tag 

77 

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) 

82 

83 return href_tag 

84 

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() 

91 

92 @text.setter 

93 def text(self, value: str) -> None: 

94 text_tag = self._get_text_tag() 

95 

96 if text_tag: 

97 text_tag.string = value 

98 

99 else: 

100 __ = self._create_text(value) 

101 

102 def _set_href_tag(self, value: str) -> None: 

103 href_tag = self._get_href_tag() 

104 

105 if href_tag: 

106 href_tag[self.href_attr] = value 

107 

108 else: 

109 __ = self._create_href(value) 

110 

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]) 

117 

118 @href.setter 

119 def href(self, value: str) -> None: 

120 self._set_href_tag(value) 

121 

122 @property 

123 def items(self) -> Sequence[C]: 

124 return tuple(self._items) 

125 

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 ) 

132 

133 @abstractmethod 

134 def _get_children_tags(self) -> list[bs4.Tag]: 

135 pass 

136 

137 def _init_items(self) -> list[C]: 

138 items: list[C] = [] 

139 cls = self._get_child_class() 

140 

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 ) 

150 

151 return items 

152 

153 @abstractmethod 

154 def _insert_tag(self, position: int, tag: bs4.Tag): 

155 pass 

156 

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 

164 

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) 

174 

175 return item 

176 

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" 

182 

183 repr += ")" 

184 

185 return repr 

186 

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 ) 

192 

193 __ = item.tag.extract() 

194 self._items.remove(item) 

195 

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) 

209 

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 

219 

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 ) 

228 

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") 

233 

234 position = self.parent.items.index(self) 

235 return cast(C, self.parent.add_item(text, href, position + 1)) 

236 

237 @property 

238 def max_depth(self) -> int: 

239 if not self.items: 

240 return self.depth 

241 

242 return max(self.depth, *(item.max_depth for item in self.items)) 

243 

244 

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 """ 

257 

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() 

269 

270 @abstractmethod 

271 def _insert_self_in_soup(self): 

272 pass 

273 

274 @abstractmethod 

275 def reset(self, entries: Sequence[D]) -> None: 

276 pass 

277 

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) 

285 

286 @override 

287 def items_referencing( 

288 self, 

289 filename: str, 

290 ignore_fragment: bool = True, 

291 ) -> Generator[C]: 

292 relative_filenames: list[str] = [] 

293 

294 base, fragment = split_fragment(filename) 

295 if base == self.base_filename: 

296 relative_filenames.append("" if fragment is None else f"#{fragment}") 

297 

298 relative_filenames.append(get_relative_href(self.base_filename, filename)) 

299 

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 ) 

309 

310 @property 

311 @override 

312 def href(self) -> str: 

313 raise EPUBError(f"Root navigation list ({self.__class__.__name__}) has no href") 

314 

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 )