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

228 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-22 21:44 +0100

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 pass 

22 

23 

24class Node: 

25 _inline: bool 

26 

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

28 raise NotImplementedError 

29 

30 def __str__(self) -> str: 

31 buffer = io.StringIO() 

32 self.write(buffer) 

33 return buffer.getvalue() 

34 

35 @staticmethod 

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

37 node_list = list(nodes) 

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

39 node.write(f) 

40 if next_ is not None: 

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

42 f.write("\n") 

43 

44 

45class HasNodes(Protocol): 

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

47 

48 

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

50 for obj in objects: 

51 match obj: 

52 case str(s): 

53 yield Text(s) 

54 case Node(): 

55 yield obj 

56 case _: 

57 for node in obj.get_nodes(): 

58 yield node 

59 

60 

61class Text(Node): 

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

63 self._text = s 

64 self._inline = True 

65 self._escape = escape 

66 

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

68 if self._escape: 

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

70 else: 

71 f.write(self._text) 

72 

73 

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

75 node = Text(s) 

76 register_with_context(node) 

77 return node 

78 

79 

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

81 node = Text(s, escape=False) 

82 register_with_context(node) 

83 return node 

84 

85 

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

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

88 

89 

90class Element(Node): 

91 _tag: str 

92 _attrs: dict[str, str] 

93 

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

95 class_names: list[str] = [] 

96 for name in key.split(): 

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

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

99 else: 

100 class_names.append(name) 

101 if class_names: 

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

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

104 return self 

105 

106 def __repr__(self) -> str: 

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

108 

109 

110class ElementEmpty(Element): 

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

112 self._tag = tag 

113 self._inline = inline 

114 self._omit_end_tag = omit_end_tag 

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

116 

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

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

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

120 if not ATTRIBUTE_NAME_RE.fullmatch(name): 

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

122 if value is True: 

123 self._attrs[name] = name 

124 elif value is not False: 

125 self._attrs[name] = value 

126 

127 register_with_context(self) 

128 return self 

129 

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

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

132 if self._omit_end_tag: 

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

134 else: 

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

136 

137 

138class ElementNonEmpty(Element): 

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

140 self._tag = tag 

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

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

143 self._inline = inline 

144 

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

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

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

148 if not ATTRIBUTE_NAME_RE.fullmatch(name): 

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

150 if value is True: 

151 self._attrs[name] = name 

152 elif value is not False: 

153 self._attrs[name] = value 

154 

155 child_nodes = list(iter_nodes(children)) 

156 for child in child_nodes: 

157 deregister_from_context(child) 

158 self._children.extend(child_nodes) 

159 

160 register_with_context(self) 

161 return self 

162 

163 def __enter__(self) -> Self: 

164 push_element_context(self) 

165 return self 

166 

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

168 parent, children = pop_element_context() 

169 assert parent is self 

170 parent(*children) 

171 

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

173 ids_seen = _rendering_context.get(None) 

174 if ids_seen is not None: 

175 if id(self) in ids_seen: 

176 raise CircularReferenceError 

177 ids_seen.add(id(self)) 

178 else: 

179 ids_seen = { 

180 id(self), 

181 } 

182 _rendering_context.set(ids_seen) 

183 

184 try: 

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

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

187 indent_next_child = not inline_mode or first_child_is_block 

188 

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

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

191 for node in self._children: 

192 if indent_next_child or not node._inline: 

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

194 node.write(f, indent + 1) 

195 indent_next_child = not node._inline 

196 

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

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

199 

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

201 finally: 

202 ids_seen.remove(id(self)) 

203 

204 

205@dataclass(slots=True) 

206class ElementContext: 

207 parent: ElementNonEmpty 

208 collected_nodes: list[Node | HasNodes] 

209 registered_nodes: set[Node | HasNodes] 

210 

211 

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

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

214 

215 

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

217 ctx = ElementContext(parent=parent, collected_nodes=[], registered_nodes=set()) 

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

219 stack.append(ctx) 

220 else: 

221 _context_stack.set([ctx]) 

222 

223 

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

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

226 return ctx.parent, [ 

227 node for node in ctx.collected_nodes if node in ctx.registered_nodes 

228 ] 

229 

230 

231def register_with_context(node: Node | HasNodes) -> None: 

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

233 ctx = stack[-1] 

234 if node not in ctx.registered_nodes: 

235 ctx.registered_nodes.add(node) 

236 ctx.collected_nodes.append(node) 

237 

238 

239def deregister_from_context(node: Node | HasNodes) -> None: 

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

241 ctx = stack[-1] 

242 ctx.registered_nodes.discard(node) 

243 

244 

245class Fragment: 

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

247 self._content = list(content) 

248 

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

250 return iter_nodes(self._content) 

251 

252 def __enter__(self) -> Self: 

253 self._capture = ElementNonEmpty("__capture__") 

254 push_element_context(self._capture) 

255 return self 

256 

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

258 parent, children = pop_element_context() 

259 assert parent is self._capture 

260 self._content.extend(children) 

261 

262 def __str__(self) -> str: 

263 buf = io.StringIO() 

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

265 return buf.getvalue() 

266 

267 

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

269 f = Fragment(*content) 

270 register_with_context(f) 

271 return f 

272 

273 

274class Prototype: 

275 _tag: str 

276 

277 def __repr__(self) -> str: 

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

279 

280 

281class PrototypeEmpty(Prototype): 

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

283 self._tag = tag 

284 self._inline = inline 

285 self._omit_end_tag = omit_end_tag 

286 

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

288 return ElementEmpty( 

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

290 )(**attrs) 

291 

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

293 return ElementEmpty( 

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

295 )[key] 

296 

297 

298class PrototypeNonEmpty(Prototype): 

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

300 self._tag = tag 

301 self._inline = inline 

302 

303 def __call__( 

304 self, *children: Node | HasNodes | str, **attrs: str | bool 

305 ) -> ElementNonEmpty: 

306 elem = ElementNonEmpty(self._tag, inline=self._inline)(*children, **attrs) 

307 register_with_context(elem) 

308 return elem 

309 

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

311 return ElementNonEmpty(self._tag, inline=self._inline)[key] 

312 

313 def __enter__(self) -> ElementNonEmpty: 

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

315 push_element_context(elem) 

316 return elem 

317 

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

319 parent, children = pop_element_context() 

320 parent(*children) 

321 

322 

323@overload 

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

325 

326 

327@overload 

328def make_prototype( 

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

330) -> PrototypeNonEmpty: ... 

331 

332 

333@overload 

334def make_prototype( 

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

336) -> PrototypeEmpty: ... 

337 

338 

339def make_prototype( 

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

341) -> PrototypeNonEmpty | PrototypeEmpty: 

342 if empty: 

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

344 return PrototypeNonEmpty(tag, inline=inline)