Coverage for src/minihtml/_core.py: 99%

242 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-06 18:07 +0200

1import io 

2import re 

3import sys 

4from collections.abc import Iterable, Iterator 

5from contextvars import ContextVar 

6from dataclasses import dataclass 

7from html import escape 

8from itertools import zip_longest 

9from typing import Literal, Protocol, TextIO, overload 

10 

11if sys.version_info >= (3, 11): 11 ↛ 14line 11 didn't jump to line 14 because the condition on line 11 was always true

12 from typing import Self 

13else: 

14 from typing_extensions import Self 

15 

16# We also disallow '&', '<', ';' 

17ATTRIBUTE_NAME_RE = re.compile(r"^[a-zA-Z0-9!#$%()*+,.:?@\[\]^_`{|}~-]+$") 

18 

19 

20class CircularReferenceError(Exception): 

21 """ 

22 Raised when a circular reference between elements is detected. 

23 """ 

24 

25 pass 

26 

27 

28class Node: 

29 """ 

30 Base class for text nodes and elements. 

31 """ 

32 

33 _inline: bool 

34 

35 def write(self, f: TextIO, indent: int = 0) -> None: 

36 raise NotImplementedError 

37 

38 def __str__(self) -> str: 

39 buffer = io.StringIO() 

40 self.write(buffer) 

41 return buffer.getvalue() 

42 

43 @staticmethod 

44 def render_list(f: TextIO, nodes: Iterable["Node"]) -> None: 

45 node_list = list(nodes) 

46 for node, next_ in zip_longest(node_list, node_list[1:]): 

47 node.write(f) 

48 if next_ is not None: 

49 if node._inline != next_._inline or not (node._inline or next_._inline): 

50 f.write("\n") 

51 

52 

53class HasNodes(Protocol): 

54 def get_nodes(self) -> Iterable[Node]: ... # pragma: no cover 

55 

56 

57def iter_nodes(objects: Iterable[Node | HasNodes | str]) -> Iterator[Node]: 

58 for obj in objects: 

59 match obj: 

60 case str(s): 

61 yield Text(s) 

62 case Node(): 

63 yield obj 

64 case _: 

65 for node in obj.get_nodes(): 

66 yield node 

67 

68 

69class Text(Node): 

70 """ 

71 A text node. 

72 

73 Use the :func:`text` and :func:`safe` functions to create text nodes. 

74 """ 

75 

76 def __init__(self, s: str, escape: bool = True): 

77 self._text = s 

78 self._inline = True 

79 self._escape = escape 

80 

81 def write(self, f: TextIO, indent: int = 0) -> None: 

82 if self._escape: 

83 f.write(escape(self._text, quote=False)) 

84 else: 

85 f.write(self._text) 

86 

87 

88def text(s: str) -> Text: 

89 """ 

90 Create a text node. 

91 

92 When called inside an element context, adds text content to the parent 

93 element. 

94 """ 

95 node = Text(s) 

96 register_with_context(node) 

97 return node 

98 

99 

100def safe(s: str) -> Text: 

101 """ 

102 Create a text node with HTML escaping disabled. 

103 

104 When called inside an element context, adds text content to the parent 

105 element. 

106 """ 

107 node = Text(s, escape=False) 

108 register_with_context(node) 

109 return node 

110 

111 

112def _format_attrs(attrs: dict[str, str]) -> str: 

113 return " ".join(f'{k}="{escape(v, quote=True)}"' for k, v in attrs.items()) 

114 

115 

116class Element(Node): 

117 """ 

118 Base class for elements. 

119 """ 

120 

121 _tag: str 

122 _attrs: dict[str, str] 

123 

124 def __getitem__(self, key: str) -> Self: 

125 class_names: list[str] = [] 

126 for name in key.split(): 

127 if name[0] == "#": 

128 self._attrs["id"] = name[1:] 

129 else: 

130 class_names.append(name) 

131 if class_names: 

132 old_names = self._attrs.get("class", "").split() 

133 self._attrs["class"] = " ".join(old_names + class_names) 

134 return self 

135 

136 def __repr__(self) -> str: 

137 return f"<{type(self).__name__} {self._tag}>" 

138 

139 

140class ElementEmpty(Element): 

141 """ 

142 An empty element. 

143 """ 

144 

145 def __init__(self, tag: str, *, inline: bool = False, omit_end_tag: bool): 

146 self._tag = tag 

147 self._inline = inline 

148 self._omit_end_tag = omit_end_tag 

149 self._attrs: dict[str, str] = {} 

150 

151 def __call__(self, **attrs: str | bool) -> Self: 

152 for name, value in attrs.items(): 

153 name = name if name == "_" else name.rstrip("_").replace("_", "-") 

154 if not ATTRIBUTE_NAME_RE.fullmatch(name): 

155 raise ValueError(f"Invalid attribute name: {name!r}") 

156 if value is True: 

157 self._attrs[name] = name 

158 elif value is not False: 

159 self._attrs[name] = value 

160 

161 return self 

162 

163 def write(self, f: TextIO, indent: int = 0) -> None: 

164 attrs = f" {_format_attrs(self._attrs)}" if self._attrs else "" 

165 if self._omit_end_tag: 

166 f.write(f"<{self._tag}{attrs}>") 

167 else: 

168 f.write(f"<{self._tag}{attrs}></{self._tag}>") 

169 

170 

171class ElementNonEmpty(Element): 

172 """ 

173 An element that can have content. 

174 """ 

175 

176 def __init__(self, tag: str, *, inline: bool = False): 

177 self._tag = tag 

178 self._attrs: dict[str, str] = {} 

179 self._children: list[Node] = [] 

180 self._inline = inline 

181 

182 def __call__(self, *content: Node | HasNodes | str, **attrs: str | bool) -> Self: 

183 for name, value in attrs.items(): 

184 name = name if name == "_" else name.rstrip("_").replace("_", "-") 

185 if not ATTRIBUTE_NAME_RE.fullmatch(name): 

186 raise ValueError(f"Invalid attribute name: {name!r}") 

187 if value is True: 

188 self._attrs[name] = name 

189 elif value is not False: 

190 self._attrs[name] = value 

191 

192 for obj in content: 

193 if not isinstance(obj, str): 

194 deregister_from_context(obj) 

195 self._children.extend(iter_nodes(content)) 

196 

197 return self 

198 

199 def __enter__(self) -> Self: 

200 push_element_context(self) 

201 return self 

202 

203 def __exit__(self, *exc_info: object) -> None: 

204 parent, content = pop_element_context() 

205 assert parent is self 

206 parent(*content) 

207 

208 def write(self, f: TextIO, indent: int = 0) -> None: 

209 ids_seen = _rendering_context.get(None) 

210 if ids_seen is not None: 

211 if id(self) in ids_seen: 

212 raise CircularReferenceError 

213 ids_seen.add(id(self)) 

214 else: 

215 ids_seen = {id(self)} 

216 _rendering_context.set(ids_seen) 

217 

218 try: 

219 inline_mode = self._inline or all([c._inline for c in self._children]) 

220 first_child_is_block = self._children and not self._children[0]._inline 

221 indent_next_child = not inline_mode or first_child_is_block 

222 

223 attrs = f" {_format_attrs(self._attrs)}" if self._attrs else "" 

224 f.write(f"<{self._tag}{attrs}>") 

225 for node in self._children: 

226 if indent_next_child or not node._inline: 

227 f.write(f"\n{' ' * (indent + 1)}") 

228 node.write(f, indent + 1) 

229 indent_next_child = not node._inline 

230 

231 if self._children and (indent_next_child or not inline_mode): 

232 f.write(f"\n{' ' * indent}") 

233 

234 f.write(f"</{self._tag}>") 

235 finally: 

236 ids_seen.remove(id(self)) 

237 

238 

239@dataclass(slots=True) 

240class ElementContext: 

241 parent: ElementNonEmpty 

242 collected_content: list[Node | HasNodes] 

243 registered_content: set[Node | HasNodes] 

244 

245 

246_context_stack = ContextVar[list[ElementContext]]("context_stack") 

247_rendering_context = ContextVar[set[int]]("rendering_context") 

248 

249 

250def push_element_context(parent: ElementNonEmpty) -> None: 

251 ctx = ElementContext(parent=parent, collected_content=[], registered_content=set()) 

252 if stack := _context_stack.get(None): 

253 stack.append(ctx) 

254 else: 

255 _context_stack.set([ctx]) 

256 

257 

258def pop_element_context() -> tuple[ElementNonEmpty, list[Node | HasNodes]]: 

259 ctx = _context_stack.get().pop() 

260 return ctx.parent, [ 

261 obj for obj in ctx.collected_content if obj in ctx.registered_content 

262 ] 

263 

264 

265def register_with_context(obj: Node | HasNodes) -> None: 

266 if stack := _context_stack.get(None): 

267 ctx = stack[-1] 

268 if obj not in ctx.registered_content: 268 ↛ exitline 268 didn't return from function 'register_with_context' because the condition on line 268 was always true

269 ctx.registered_content.add(obj) 

270 ctx.collected_content.append(obj) 

271 

272 

273def deregister_from_context(obj: Node | HasNodes) -> None: 

274 if stack := _context_stack.get(None): 

275 ctx = stack[-1] 

276 ctx.registered_content.discard(obj) 

277 

278 

279class Fragment: 

280 """ 

281 A collection of nodes without a parent element. 

282 

283 Use the :func:`fragment` function to create fragments. 

284 """ 

285 

286 def __init__(self, *content: Node | HasNodes | str): 

287 self._content = list(content) 

288 for obj in content: 

289 if not isinstance(obj, str): 

290 deregister_from_context(obj) 

291 

292 def get_nodes(self) -> Iterable[Node]: 

293 return iter_nodes(self._content) 

294 

295 def __enter__(self) -> Self: 

296 self._capture = ElementNonEmpty("__capture__") 

297 push_element_context(self._capture) 

298 return self 

299 

300 def __exit__(self, *exc_info: object) -> None: 

301 parent, content = pop_element_context() 

302 assert parent is self._capture 

303 self._content.extend(content) 

304 

305 def __str__(self) -> str: 

306 buf = io.StringIO() 

307 Node.render_list(buf, self.get_nodes()) 

308 return buf.getvalue() 

309 

310 

311def fragment(*content: Node | HasNodes | str) -> Fragment: 

312 """ 

313 Create a fragment. 

314 

315 A fragment is a collection of nodes without a parent element. 

316 

317 When called inside an element context, adds the fragment contents to the 

318 parent element. 

319 """ 

320 f = Fragment(*content) 

321 register_with_context(f) 

322 return f 

323 

324 

325class Prototype: 

326 """ 

327 Base class for element prototypes. 

328 """ 

329 

330 _tag: str 

331 

332 def _get_repr(self, **flags: bool) -> str: 

333 flag_names = [k for k, v in flags.items() if v] 

334 flag_str = f" ({', '.join(flag_names)})" if flag_names else "" 

335 return f"<{type(self).__name__} {self._tag}{flag_str}>" 

336 

337 

338class PrototypeEmpty(Prototype): 

339 """ 

340 A prototype for emtpy elements. 

341 

342 Use the :func:`make_prototype` function to create new prototypes. 

343 """ 

344 

345 def __init__(self, tag: str, *, inline: bool, omit_end_tag: bool): 

346 self._tag = tag 

347 self._inline = inline 

348 self._omit_end_tag = omit_end_tag 

349 

350 def __call__(self, **attrs: str | bool) -> ElementEmpty: 

351 elem = ElementEmpty( 

352 self._tag, inline=self._inline, omit_end_tag=self._omit_end_tag 

353 )(**attrs) 

354 register_with_context(elem) 

355 return elem 

356 

357 def __getitem__(self, key: str) -> ElementEmpty: 

358 elem = ElementEmpty( 

359 self._tag, inline=self._inline, omit_end_tag=self._omit_end_tag 

360 )[key] 

361 register_with_context(elem) 

362 return elem 

363 

364 def __repr__(self) -> str: 

365 return self._get_repr(inline=self._inline, omit_end_tag=self._omit_end_tag) 

366 

367 

368class PrototypeNonEmpty(Prototype): 

369 """ 

370 A prototype for elements that can have content. 

371 

372 Use the :func:`make_prototype` function to create new prototypes. 

373 """ 

374 

375 def __init__(self, tag: str, *, inline: bool): 

376 self._tag = tag 

377 self._inline = inline 

378 

379 def __call__( 

380 self, *content: Node | HasNodes | str, **attrs: str | bool 

381 ) -> ElementNonEmpty: 

382 elem = ElementNonEmpty(self._tag, inline=self._inline)(*content, **attrs) 

383 register_with_context(elem) 

384 return elem 

385 

386 def __getitem__(self, key: str) -> ElementNonEmpty: 

387 elem = ElementNonEmpty(self._tag, inline=self._inline)[key] 

388 register_with_context(elem) 

389 return elem 

390 

391 def __enter__(self) -> ElementNonEmpty: 

392 elem = ElementNonEmpty(self._tag, inline=self._inline) 

393 register_with_context(elem) 

394 push_element_context(elem) 

395 return elem 

396 

397 def __exit__(self, *exc_info: object) -> None: 

398 parent, content = pop_element_context() 

399 parent(*content) 

400 

401 def __repr__(self) -> str: 

402 return self._get_repr(inline=self._inline) 

403 

404 

405@overload 

406def make_prototype(tag: str, *, inline: bool = ...) -> PrototypeNonEmpty: ... 

407 

408 

409@overload 

410def make_prototype( 

411 tag: str, *, inline: bool = ..., empty: Literal[False] 

412) -> PrototypeNonEmpty: ... 

413 

414 

415@overload 

416def make_prototype( 

417 tag: str, *, inline: bool = ..., empty: Literal[True], omit_end_tag: bool = ... 

418) -> PrototypeEmpty: ... 

419 

420 

421def make_prototype( 

422 tag: str, *, inline: bool = False, empty: bool = False, omit_end_tag: bool = False 

423) -> PrototypeNonEmpty | PrototypeEmpty: 

424 """ 

425 Factory function to create a new element prototype. 

426 

427 Args: 

428 tag: The tag name. 

429 inline: Whether or not the element is an inline (``True``) or block 

430 element (``False``). 

431 empty: Whether or not the element is allowed to have content. 

432 omit_end_tag: When `empty=True`, whether or not the end tag should be 

433 omitted when rendering. 

434 

435 Returns: 

436 An element prototype. 

437 """ 

438 if empty: 

439 return PrototypeEmpty(tag, inline=inline, omit_end_tag=omit_end_tag) 

440 return PrototypeNonEmpty(tag, inline=inline)