Coverage for tdom/processor.py: 100%

197 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-17 19:54 +0000

1import random 

2import string 

3import sys 

4import typing as t 

5from collections.abc import Iterable 

6from functools import lru_cache 

7from string.templatelib import Interpolation, Template 

8 

9from markupsafe import Markup 

10 

11from .callables import CallableInfo, get_callable_info 

12from .classnames import classnames 

13from .nodes import Element, Fragment, Node, Text 

14from .parser import parse_html 

15from .utils import format_interpolation as base_format_interpolation 

16 

17 

18@t.runtime_checkable 

19class HasHTMLDunder(t.Protocol): 

20 def __html__(self) -> str: ... 

21 

22 

23# -------------------------------------------------------------------------- 

24# Value formatting 

25# -------------------------------------------------------------------------- 

26 

27 

28def _format_safe(value: object, format_spec: str) -> str: 

29 """Use Markup() to mark a value as safe HTML.""" 

30 assert format_spec == "safe" 

31 return Markup(value) 

32 

33 

34def _format_unsafe(value: object, format_spec: str) -> str: 

35 """Convert a value to a plain string, forcing it to be treated as unsafe.""" 

36 assert format_spec == "unsafe" 

37 return str(value) 

38 

39 

40CUSTOM_FORMATTERS = (("safe", _format_safe), ("unsafe", _format_unsafe)) 

41 

42 

43def format_interpolation(interpolation: Interpolation) -> object: 

44 return base_format_interpolation( 

45 interpolation, 

46 formatters=CUSTOM_FORMATTERS, 

47 ) 

48 

49 

50# -------------------------------------------------------------------------- 

51# Instrumentation, Parsing, and Caching 

52# -------------------------------------------------------------------------- 

53 

54_PLACEHOLDER_PREFIX = f"t🐍-{''.join(random.choices(string.ascii_lowercase, k=4))}-" 

55_PP_LEN = len(_PLACEHOLDER_PREFIX) 

56 

57 

58def _placeholder(i: int) -> str: 

59 """Generate a placeholder for the i-th interpolation.""" 

60 return f"{_PLACEHOLDER_PREFIX}{i}" 

61 

62 

63def _placholder_index(s: str) -> int: 

64 """Extract the index from a placeholder string.""" 

65 return int(s[_PP_LEN:]) 

66 

67 

68def _instrument( 

69 strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...] 

70) -> t.Iterable[str]: 

71 """ 

72 Join the strings with placeholders in between where interpolations go. 

73 

74 This is used to prepare the template string for parsing, so that we can 

75 later substitute the actual interpolated values into the parse tree. 

76 

77 The placeholders are chosen to be unlikely to collide with typical HTML 

78 content. 

79 """ 

80 count = len(strings) 

81 

82 callable_placeholders: dict[int, str] = {} 

83 

84 for i, s in enumerate(strings): 

85 yield s 

86 # There are always count-1 placeholders between count strings. 

87 if i < count - 1: 

88 placeholder = _placeholder(i) 

89 

90 # Special case for component callables: if the interpolation 

91 # is a callable, we need to make sure that any matching closing 

92 # tag uses the same placeholder. 

93 callable_info = callable_infos[i] 

94 if callable_info: 

95 placeholder = callable_placeholders.setdefault( 

96 callable_info.id, placeholder 

97 ) 

98 

99 yield placeholder 

100 

101 

102@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) 

103def _instrument_and_parse_internal( 

104 strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...] 

105) -> Node: 

106 """ 

107 Instrument the strings and parse the resulting HTML. 

108 

109 The result is cached to avoid re-parsing the same template multiple times. 

110 """ 

111 instrumented = _instrument(strings, callable_infos) 

112 return parse_html(instrumented) 

113 

114 

115def _callable_info(value: object) -> CallableInfo | None: 

116 """Return a unique identifier for a callable, or None if not callable.""" 

117 return get_callable_info(value) if callable(value) else None 

118 

119 

120def _instrument_and_parse(template: Template) -> Node: 

121 """Instrument and parse a template, returning a tree of Nodes.""" 

122 # This is a thin wrapper around the cached internal function that does the 

123 # actual work. This exists to handle the syntax we've settled on for 

124 # component invocation, namely that callables are directly included as 

125 # interpolations both in the open *and* the close tags. We need to make 

126 # sure that matching tags... match! 

127 # 

128 # If we used `tdom`'s approach of component closing tags of <//> then we 

129 # wouldn't have to do this. But I worry that tdom's syntax is harder to read 

130 # (it's easy to miss the closing tag) and may prove unfamiliar for 

131 # users coming from other templating systems. 

132 callable_infos = tuple( 

133 _callable_info(interpolation.value) for interpolation in template.interpolations 

134 ) 

135 return _instrument_and_parse_internal(template.strings, callable_infos) 

136 

137 

138# -------------------------------------------------------------------------- 

139# Placeholder Substitution 

140# -------------------------------------------------------------------------- 

141 

142 

143def _force_dict(value: t.Any, *, kind: str) -> dict: 

144 """Try to convert a value to a dict, raising TypeError if not possible.""" 

145 try: 

146 return dict(value) 

147 except (TypeError, ValueError): 

148 raise TypeError( 

149 f"Cannot use {type(value).__name__} as value for {kind} attributes" 

150 ) from None 

151 

152 

153def _process_aria_attr(value: object) -> t.Iterable[tuple[str, str | None]]: 

154 """Produce aria-* attributes based on the interpolated value for "aria".""" 

155 d = _force_dict(value, kind="aria") 

156 for sub_k, sub_v in d.items(): 

157 if sub_v is True: 

158 yield f"aria-{sub_k}", "true" 

159 elif sub_v is False: 

160 yield f"aria-{sub_k}", "false" 

161 elif sub_v is None: 

162 pass 

163 else: 

164 yield f"aria-{sub_k}", str(sub_v) 

165 

166 

167def _process_data_attr(value: object) -> t.Iterable[tuple[str, str | None]]: 

168 """Produce data-* attributes based on the interpolated value for "data".""" 

169 d = _force_dict(value, kind="data") 

170 for sub_k, sub_v in d.items(): 

171 if sub_v is True: 

172 yield f"data-{sub_k}", None 

173 elif sub_v not in (False, None): 

174 yield f"data-{sub_k}", str(sub_v) 

175 

176 

177def _process_class_attr(value: object) -> t.Iterable[tuple[str, str | None]]: 

178 """Substitute a class attribute based on the interpolated value.""" 

179 yield ("class", classnames(value)) 

180 

181 

182def _process_style_attr(value: object) -> t.Iterable[tuple[str, str | None]]: 

183 """Substitute a style attribute based on the interpolated value.""" 

184 if isinstance(value, str): 

185 yield ("style", value) 

186 return 

187 try: 

188 d = _force_dict(value, kind="style") 

189 style_str = "; ".join(f"{k}: {v}" for k, v in d.items()) 

190 yield ("style", style_str) 

191 except TypeError: 

192 raise TypeError("'style' attribute value must be a string or dict") from None 

193 

194 

195def _substitute_spread_attrs( 

196 value: object, 

197) -> t.Iterable[tuple[str, object | None]]: 

198 """ 

199 Substitute a spread attribute based on the interpolated value. 

200 

201 A spread attribute is one where the key is a placeholder, indicating that 

202 the entire attribute set should be replaced by the interpolated value. 

203 The value must be a dict or iterable of key-value pairs. 

204 """ 

205 d = _force_dict(value, kind="spread") 

206 for sub_k, sub_v in d.items(): 

207 yield from _process_attr(sub_k, sub_v) 

208 

209 

210# A collection of custom handlers for certain attribute names that have 

211# special semantics. This is in addition to the special-casing in 

212# _substitute_attr() itself. 

213CUSTOM_ATTR_PROCESSORS = { 

214 "class": _process_class_attr, 

215 "data": _process_data_attr, 

216 "style": _process_style_attr, 

217 "aria": _process_aria_attr, 

218} 

219 

220 

221def _process_attr( 

222 key: str, 

223 value: object, 

224) -> t.Iterable[tuple[str, object | None]]: 

225 """ 

226 Substitute a single attribute based on its key and the interpolated value. 

227 

228 A single parsed attribute with a placeholder may result in multiple 

229 attributes in the final output, for instance if the value is a dict or 

230 iterable of key-value pairs. Likewise, a value of False will result in 

231 the attribute being omitted entirely; nothing is yielded in that case. 

232 """ 

233 # Special handling for certain attribute names that have special semantics 

234 if custom_processor := CUSTOM_ATTR_PROCESSORS.get(key): 

235 yield from custom_processor(value) 

236 return 

237 

238 # General handling for all other attributes: 

239 match value: 

240 case True: 

241 yield (key, None) 

242 case False | None: 

243 pass 

244 case _: 

245 yield (key, value) 

246 

247 

248def _substitute_interpolated_attrs( 

249 attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...] 

250) -> dict[str, object]: 

251 """ 

252 Replace placeholder values in attributes with their interpolated values. 

253 

254 This only handles step (1): value substitution. No special processing 

255 of attribute names or value types is performed. 

256 """ 

257 new_attrs: dict[str, object | None] = {} 

258 for key, value in attrs.items(): 

259 if value and value.startswith(_PLACEHOLDER_PREFIX): 

260 # Interpolated attribute value 

261 index = _placholder_index(value) 

262 interpolation = interpolations[index] 

263 interpolated_value = format_interpolation(interpolation) 

264 new_attrs[key] = interpolated_value 

265 elif key.startswith(_PLACEHOLDER_PREFIX): 

266 # Spread attributes 

267 index = _placholder_index(key) 

268 interpolation = interpolations[index] 

269 spread_value = format_interpolation(interpolation) 

270 for sub_k, sub_v in _substitute_spread_attrs(spread_value): 

271 new_attrs[sub_k] = sub_v 

272 else: 

273 # Static attribute 

274 new_attrs[key] = value 

275 return new_attrs 

276 

277 

278def _process_html_attrs(attrs: dict[str, object]) -> dict[str, str | None]: 

279 """ 

280 Process attributes for HTML elements. 

281 

282 This handles steps (2) and (3): special attribute name handling and 

283 value type processing (True -> None, False -> omit, etc.) 

284 """ 

285 processed_attrs: dict[str, str | None] = {} 

286 for key, value in attrs.items(): 

287 for sub_k, sub_v in _process_attr(key, value): 

288 # Convert to string, preserving None 

289 processed_attrs[sub_k] = str(sub_v) if sub_v is not None else None 

290 return processed_attrs 

291 

292 

293def _substitute_attrs( 

294 attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...] 

295) -> dict[str, str | None]: 

296 """ 

297 Substitute placeholders in attributes for HTML elements. 

298 

299 This is the full pipeline: interpolation + HTML processing. 

300 """ 

301 interpolated_attrs = _substitute_interpolated_attrs(attrs, interpolations) 

302 return _process_html_attrs(interpolated_attrs) 

303 

304 

305def _substitute_and_flatten_children( 

306 children: t.Iterable[Node], interpolations: tuple[Interpolation, ...] 

307) -> list[Node]: 

308 """Substitute placeholders in a list of children and flatten any fragments.""" 

309 new_children: list[Node] = [] 

310 for child in children: 

311 substituted = _substitute_node(child, interpolations) 

312 if isinstance(substituted, Fragment): 

313 # This can happen if an interpolation results in a Fragment, for 

314 # instance if it is iterable. 

315 new_children.extend(substituted.children) 

316 else: 

317 new_children.append(substituted) 

318 return new_children 

319 

320 

321def _node_from_value(value: object) -> Node: 

322 """ 

323 Convert an arbitrary value to a Node. 

324 

325 This is the primary action performed when replacing interpolations in child 

326 content positions. 

327 """ 

328 match value: 

329 case str(): 

330 return Text(value) 

331 case Node(): 

332 return value 

333 case Template(): 

334 return html(value) 

335 # Consider: falsey values, not just False and None? 

336 case False | None: 

337 return Fragment(children=[]) 

338 case Iterable(): 

339 children = [_node_from_value(v) for v in value] 

340 return Fragment(children=children) 

341 case HasHTMLDunder(): 

342 # CONSIDER: should we do this lazily? 

343 return Text(Markup(value.__html__())) 

344 case c if callable(c): 

345 # Treat all callable values in child content positions as if 

346 # they are zero-arg functions that return a value to be rendered. 

347 return _node_from_value(c()) 

348 case _: 

349 # CONSIDER: should we do this lazily? 

350 return Text(str(value)) 

351 

352 

353def _kebab_to_snake(name: str) -> str: 

354 """Convert a kebab-case name to snake_case.""" 

355 return name.replace("-", "_").lower() 

356 

357 

358def _invoke_component( 

359 tag: str, 

360 new_attrs: dict[str, object | None], 

361 new_children: list[Node], 

362 interpolations: tuple[Interpolation, ...], 

363) -> Node: 

364 """ 

365 Invoke a component callable with the provided attributes and children. 

366 

367 Components are any callable that meets the required calling signature. 

368 Typically, that's a function, but it could also be the constructor or 

369 __call__() method for a class; dataclass constructors match our expected 

370 invocation style. 

371 

372 We validate the callable's signature and invoke it with keyword-only 

373 arguments, then convert the result to a Node. 

374 

375 Component invocation rules: 

376 

377 1. All arguments are passed as keywords only. Components cannot require 

378 positional arguments. 

379 

380 2. Children are passed via a "children" parameter when: 

381 

382 - Child content exists in the template AND 

383 - The callable accepts "children" OR has **kwargs 

384 

385 If no children exist but the callable accepts "children", we pass an 

386 empty tuple. 

387 

388 3. All other attributes are converted from kebab-case to snake_case 

389 and passed as keyword arguments if the callable accepts them (or has 

390 **kwargs). Attributes that don't match parameters are silently ignored. 

391 """ 

392 index = _placholder_index(tag) 

393 interpolation = interpolations[index] 

394 value = format_interpolation(interpolation) 

395 if not callable(value): 

396 raise TypeError( 

397 f"Expected a callable for component invocation, got {type(value).__name__}" 

398 ) 

399 callable_info = get_callable_info(value) 

400 

401 if callable_info.requires_positional: 

402 raise TypeError( 

403 "Component callables cannot have required positional arguments." 

404 ) 

405 

406 kwargs: dict[str, object] = {} 

407 

408 # Add all supported attributes 

409 for attr_name, attr_value in new_attrs.items(): 

410 snake_name = _kebab_to_snake(attr_name) 

411 if snake_name in callable_info.named_params or callable_info.kwargs: 

412 kwargs[snake_name] = attr_value 

413 

414 # Add children if appropriate 

415 if "children" in callable_info.named_params or callable_info.kwargs: 

416 kwargs["children"] = tuple(new_children) 

417 

418 # Check to make sure we've fully satisfied the callable's requirements 

419 missing = callable_info.required_named_params - kwargs.keys() 

420 if missing: 

421 raise TypeError( 

422 f"Missing required parameters for component: {', '.join(missing)}" 

423 ) 

424 

425 result = value(**kwargs) 

426 return _node_from_value(result) 

427 

428 

429def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node: 

430 """Substitute placeholders in a node based on the corresponding interpolations.""" 

431 match p_node: 

432 case Text(text) if str(text).startswith(_PLACEHOLDER_PREFIX): 

433 index = _placholder_index(str(text)) 

434 interpolation = interpolations[index] 

435 value = format_interpolation(interpolation) 

436 return _node_from_value(value) 

437 case Element(tag=tag, attrs=attrs, children=children): 

438 new_children = _substitute_and_flatten_children(children, interpolations) 

439 if tag.startswith(_PLACEHOLDER_PREFIX): 

440 component_attrs = _substitute_interpolated_attrs(attrs, interpolations) 

441 return _invoke_component( 

442 tag, component_attrs, new_children, interpolations 

443 ) 

444 else: 

445 html_attrs = _substitute_attrs(attrs, interpolations) 

446 return Element(tag=tag, attrs=html_attrs, children=new_children) 

447 case Fragment(children=children): 

448 new_children = _substitute_and_flatten_children(children, interpolations) 

449 return Fragment(children=new_children) 

450 case _: 

451 return p_node 

452 

453 

454# -------------------------------------------------------------------------- 

455# Public API 

456# -------------------------------------------------------------------------- 

457 

458 

459def html(template: Template) -> Node: 

460 """Parse a t-string and return a tree of Nodes.""" 

461 # Parse the HTML, returning a tree of nodes with placeholders 

462 # where interpolations go. 

463 p_node = _instrument_and_parse(template) 

464 return _substitute_node(p_node, template.interpolations)