Coverage for tdom / processor.py: 98%

205 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-17 23:32 +0000

1import sys 

2import typing as t 

3from collections.abc import Iterable 

4from functools import lru_cache 

5from string.templatelib import Interpolation, Template 

6 

7from markupsafe import Markup 

8 

9from .callables import get_callable_info 

10from .classnames import classnames 

11from .format import format_interpolation as base_format_interpolation 

12from .format import format_template 

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

14from .parser import ( 

15 HTMLAttribute, 

16 HTMLAttributesDict, 

17 TAttribute, 

18 TComment, 

19 TComponent, 

20 TDocumentType, 

21 TElement, 

22 TemplateParser, 

23 TFragment, 

24 TInterpolatedAttribute, 

25 TLiteralAttribute, 

26 TNode, 

27 TSpreadAttribute, 

28 TTemplatedAttribute, 

29 TText, 

30) 

31from .placeholders import TemplateRef 

32from .template_utils import template_from_parts 

33from .utils import CachableTemplate, LastUpdatedOrderedDict 

34 

35 

36@t.runtime_checkable 

37class HasHTMLDunder(t.Protocol): 

38 def __html__(self) -> str: ... # pragma: no cover 

39 

40 

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

42def _parse_and_cache(cachable: CachableTemplate) -> TNode: 

43 return TemplateParser.parse(cachable.template) 

44 

45 

46type Attribute = tuple[str, object] 

47type AttributesDict = dict[str, object] 

48 

49 

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

51# Custom formatting for the processor 

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

53 

54 

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

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

57 assert format_spec == "safe" 

58 return Markup(value) 

59 

60 

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

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

63 assert format_spec == "unsafe" 

64 return str(value) 

65 

66 

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

68 

69 

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

71 return base_format_interpolation( 

72 interpolation, 

73 formatters=CUSTOM_FORMATTERS, 

74 ) 

75 

76 

77# -------------------------------------------------------------------------- 

78# Placeholder Substitution 

79# -------------------------------------------------------------------------- 

80 

81 

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

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

84 try: 

85 return dict(value) 

86 except (TypeError, ValueError): 

87 raise TypeError( 

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

89 ) from None 

90 

91 

92def _process_aria_attr(value: object) -> t.Iterable[HTMLAttribute]: 

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

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

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

96 if sub_v is True: 

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

98 elif sub_v is False: 

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

100 elif sub_v is None: 

101 pass 

102 else: 

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

104 

105 

106def _process_data_attr(value: object) -> t.Iterable[Attribute]: 

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

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

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

110 if sub_v is True: 

111 yield f"data-{sub_k}", True 

112 elif sub_v is not False and sub_v is not None: 

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

114 

115 

116def _process_class_attr(value: object) -> t.Iterable[HTMLAttribute]: 

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

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

119 

120 

121def _process_style_attr(value: object) -> t.Iterable[HTMLAttribute]: 

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

123 if isinstance(value, str): 

124 yield ("style", value) 

125 return 

126 try: 

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

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

129 yield ("style", style_str) 

130 except TypeError: 

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

132 

133 

134def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]: 

135 """ 

136 Substitute a spread attribute based on the interpolated value. 

137 

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

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

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

141 """ 

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

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

144 yield from _process_attr(sub_k, sub_v) 

145 

146 

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

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

149# _substitute_attr() itself. 

150CUSTOM_ATTR_PROCESSORS = { 

151 "class": _process_class_attr, 

152 "data": _process_data_attr, 

153 "style": _process_style_attr, 

154 "aria": _process_aria_attr, 

155} 

156 

157 

158def _process_attr(key: str, value: object) -> t.Iterable[Attribute]: 

159 """ 

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

161 

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

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

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

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

166 """ 

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

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

169 yield from custom_processor(value) 

170 return 

171 yield (key, value) 

172 

173 

174def _resolve_t_attrs( 

175 attrs: t.Sequence[TAttribute], interpolations: tuple[Interpolation, ...] 

176) -> AttributesDict: 

177 """ 

178 Replace placeholder values in attributes with their interpolated values. 

179 

180 The values returned are not yet processed for HTML output; that is handled 

181 in a later step. 

182 """ 

183 new_attrs: AttributesDict = LastUpdatedOrderedDict() 

184 for attr in attrs: 

185 match attr: 

186 case TLiteralAttribute(name=name, value=value): 

187 new_attrs[name] = True if value is None else value 

188 case TInterpolatedAttribute(name=name, value_i_index=i_index): 

189 interpolation = interpolations[i_index] 

190 attr_value = format_interpolation(interpolation) 

191 for sub_k, sub_v in _process_attr(name, attr_value): 

192 new_attrs[sub_k] = sub_v 

193 case TTemplatedAttribute(name=name, value_ref=ref): 

194 attr_t = _resolve_ref(ref, interpolations) 

195 attr_value = format_template(attr_t) 

196 new_attrs[name] = attr_value 

197 case TSpreadAttribute(i_index=i_index): 

198 interpolation = interpolations[i_index] 

199 spread_value = format_interpolation(interpolation) 

200 for sub_k, sub_v in _substitute_spread_attrs(spread_value): 

201 new_attrs[sub_k] = sub_v 

202 case _: 

203 raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}") 

204 return new_attrs 

205 

206 

207def _resolve_html_attrs(attrs: AttributesDict) -> HTMLAttributesDict: 

208 """Resolve attribute values for HTML output.""" 

209 html_attrs: HTMLAttributesDict = {} 

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

211 match value: 

212 case True: 

213 html_attrs[key] = None 

214 case False | None: 

215 pass 

216 case _: 

217 html_attrs[key] = str(value) 

218 return html_attrs 

219 

220 

221def _resolve_attrs( 

222 attrs: t.Sequence[TAttribute], interpolations: tuple[Interpolation, ...] 

223) -> HTMLAttributesDict: 

224 """ 

225 Substitute placeholders in attributes for HTML elements. 

226 

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

228 """ 

229 interpolated_attrs = _resolve_t_attrs(attrs, interpolations) 

230 return _resolve_html_attrs(interpolated_attrs) 

231 

232 

233def _flatten_nodes(nodes: t.Iterable[Node]) -> list[Node]: 

234 """Flatten a list of Nodes, expanding any Fragments.""" 

235 flat: list[Node] = [] 

236 for node in nodes: 

237 if isinstance(node, Fragment): 

238 flat.extend(node.children) 

239 else: 

240 flat.append(node) 

241 return flat 

242 

243 

244def _substitute_and_flatten_children( 

245 children: t.Iterable[TNode], interpolations: tuple[Interpolation, ...] 

246) -> list[Node]: 

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

248 resolved = [_resolve_t_node(child, interpolations) for child in children] 

249 flat = _flatten_nodes(resolved) 

250 return flat 

251 

252 

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

254 """ 

255 Convert an arbitrary value to a Node. 

256 

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

258 content positions. 

259 """ 

260 match value: 

261 case str(): 

262 return Text(value) 

263 case Node(): 

264 return value 

265 case Template(): 

266 return html(value) 

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

268 case False | None: 

269 return Fragment(children=[]) 

270 case Iterable(): 

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

272 return Fragment(children=children) 

273 case HasHTMLDunder(): 

274 # CONSIDER: should we do this lazily? 

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

276 case c if callable(c): 

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

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

279 return _node_from_value(c()) 

280 case _: 

281 # CONSIDER: should we do this lazily? 

282 return Text(str(value)) 

283 

284 

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

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

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

288 

289 

290def _invoke_component( 

291 attrs: AttributesDict, 

292 children: list[Node], # TODO: why not TNode, though? 

293 interpolation: Interpolation, 

294) -> Node: 

295 """ 

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

297 

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

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

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

301 invocation style. 

302 

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

304 arguments, then convert the result to a Node. 

305 

306 Component invocation rules: 

307 

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

309 positional arguments. 

310 

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

312 

313 - Child content exists in the template AND 

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

315 

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

317 empty tuple. 

318 

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

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

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

322 """ 

323 value = format_interpolation(interpolation) 

324 if not callable(value): 

325 raise TypeError( 

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

327 ) 

328 callable_info = get_callable_info(value) 

329 

330 if callable_info.requires_positional: 

331 raise TypeError( 

332 "Component callables cannot have required positional arguments." 

333 ) 

334 

335 kwargs: AttributesDict = {} 

336 

337 # Add all supported attributes 

338 for attr_name, attr_value in attrs.items(): 

339 snake_name = _kebab_to_snake(attr_name) 

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

341 kwargs[snake_name] = attr_value 

342 

343 # Add children if appropriate 

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

345 kwargs["children"] = tuple(children) 

346 

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

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

349 if missing: 

350 raise TypeError( 

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

352 ) 

353 

354 result = value(**kwargs) 

355 return _node_from_value(result) 

356 

357 

358def _resolve_ref( 

359 ref: TemplateRef, interpolations: tuple[Interpolation, ...] 

360) -> Template: 

361 resolved = [interpolations[i_index] for i_index in ref.i_indexes] 

362 return template_from_parts(ref.strings, resolved) 

363 

364 

365def _resolve_t_text_ref( 

366 ref: TemplateRef, interpolations: tuple[Interpolation, ...] 

367) -> Text | Fragment: 

368 """Resolve a TText ref into Text or Fragment by processing interpolations.""" 

369 if ref.is_literal: 

370 return Text(ref.strings[0]) 

371 

372 parts = [ 

373 Text(part) 

374 if isinstance(part, str) 

375 else _node_from_value(format_interpolation(part)) 

376 for part in _resolve_ref(ref, interpolations) 

377 ] 

378 flat = _flatten_nodes(parts) 

379 

380 if len(flat) == 1 and isinstance(flat[0], Text): 

381 return flat[0] 

382 

383 return Fragment(children=flat) 

384 

385 

386def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> Node: 

387 """Resolve a TNode tree into a Node tree by processing interpolations.""" 

388 match t_node: 

389 case TText(ref=ref): 

390 return _resolve_t_text_ref(ref, interpolations) 

391 case TComment(ref=ref): 

392 comment_t = _resolve_ref(ref, interpolations) 

393 comment = format_template(comment_t) 

394 return Comment(comment) 

395 case TDocumentType(text=text): 

396 return DocumentType(text) 

397 case TFragment(children=children): 

398 resolved_children = _substitute_and_flatten_children( 

399 children, interpolations 

400 ) 

401 return Fragment(children=resolved_children) 

402 case TElement(tag=tag, attrs=attrs, children=children): 

403 resolved_attrs = _resolve_attrs(attrs, interpolations) 

404 resolved_children = _substitute_and_flatten_children( 

405 children, interpolations 

406 ) 

407 return Element(tag=tag, attrs=resolved_attrs, children=resolved_children) 

408 case TComponent( 

409 start_i_index=start_i_index, 

410 end_i_index=end_i_index, 

411 attrs=t_attrs, 

412 children=children, 

413 ): 

414 start_interpolation = interpolations[start_i_index] 

415 end_interpolation = ( 

416 None if end_i_index is None else interpolations[end_i_index] 

417 ) 

418 resolved_attrs = _resolve_t_attrs(t_attrs, interpolations) 

419 resolved_children = _substitute_and_flatten_children( 

420 children, interpolations 

421 ) 

422 # HERE ALSO BE DRAGONS: validate matching start/end callables, since 

423 # the underlying TemplateParser cannot do that for us. 

424 if ( 

425 end_interpolation is not None 

426 and end_interpolation.value != start_interpolation.value 

427 ): 

428 raise TypeError("Mismatched component start and end callables.") 

429 return _invoke_component( 

430 attrs=resolved_attrs, 

431 children=resolved_children, 

432 interpolation=start_interpolation, 

433 ) 

434 case _: 

435 raise ValueError(f"Unknown TNode type: {type(t_node).__name__}") 

436 

437 

438# -------------------------------------------------------------------------- 

439# Public API 

440# -------------------------------------------------------------------------- 

441 

442 

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

444 """Parse an HTML t-string, substitue values, and return a tree of Nodes.""" 

445 cachable = CachableTemplate(template) 

446 t_node = _parse_and_cache(cachable) 

447 return _resolve_t_node(t_node, template.interpolations)