Coverage for tdom/processor_test.py: 99%

1102 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-19 21:37 +0000

1import datetime 

2import math 

3import typing as t 

4from collections import UserDict 

5from collections.abc import Callable 

6from dataclasses import dataclass 

7from itertools import chain, product 

8from string.templatelib import Template 

9 

10import pytest 

11from markupsafe import Markup 

12from markupsafe import escape as markupsafe_escape 

13 

14from .callables import get_callable_info 

15from .escaping import escape_html_text 

16from .processor import ( 

17 CachedTemplateParserProxy, 

18 ProcessContext, 

19 TemplateParserProxy, 

20 TemplateProcessor, 

21 _make_default_template_processor, 

22) 

23from .processor import ( 

24 _prep_component_kwargs as prep_component_kwargs, 

25) 

26from .protocols import HasHTMLDunder 

27 

28processor_api = _make_default_template_processor( 

29 parser_api=TemplateParserProxy(), # do not use cache 

30) 

31 

32 

33def make_ctx(**kwargs): 

34 return ProcessContext(**kwargs) 

35 

36 

37def html(template: Template, assume_ctx: ProcessContext | None = None): 

38 if assume_ctx is None: 

39 assume_ctx = ProcessContext() 

40 return processor_api.process(template, assume_ctx=assume_ctx) 

41 

42 

43# -------------------------------------------------------------------------- 

44# Basic HTML parsing tests 

45# -------------------------------------------------------------------------- 

46 

47 

48# 

49# Text 

50# 

51class TestBareTemplate: 

52 def test_empty(self): 

53 assert html(t"") == "" 

54 

55 def test_text_literal(self): 

56 assert html(t"Hello, world!") == "Hello, world!" 

57 

58 def test_text_singleton(self): 

59 greeting = "Hello, Alice!" 

60 assert html(t"{greeting}", make_ctx(parent_tag="div")) == "Hello, Alice!" 

61 assert html(t"{greeting}", make_ctx(parent_tag="script")) == "Hello, Alice!" 

62 assert html(t"{greeting}", make_ctx(parent_tag="style")) == "Hello, Alice!" 

63 assert html(t"{greeting}", make_ctx(parent_tag="textarea")) == "Hello, Alice!" 

64 assert html(t"{greeting}", make_ctx(parent_tag="title")) == "Hello, Alice!" 

65 

66 def test_text_singleton_without_parent(self): 

67 greeting = "</script>" 

68 res = html(t"{greeting}") 

69 assert res == "&lt;/script&gt;" 

70 assert res != greeting 

71 

72 def test_text_singleton_explicit_parent_script(self): 

73 greeting = "</script>" 

74 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="script")) 

75 assert res == "\\x3c/script>" 

76 assert res != "</script>" 

77 

78 def test_text_singleton_explicit_parent_div(self): 

79 greeting = "</div>" 

80 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="div")) 

81 assert res == "&lt;/div&gt;" 

82 assert res != "</div>" 

83 

84 def test_text_template(self): 

85 name = "Alice" 

86 assert ( 

87 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) 

88 == "Hello, Alice!" 

89 ) 

90 

91 def test_text_template_escaping(self): 

92 name = "Alice & Bob" 

93 assert ( 

94 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) 

95 == "Hello, Alice &amp; Bob!" 

96 ) 

97 

98 def test_parse_entities_are_escaped_no_parent_tag(self): 

99 res = html(t"&lt;/p&gt;") 

100 assert res == "&lt;/p&gt;", "Default to standard escaping." 

101 

102 

103class LiteralHTML: 

104 """Text is returned as is by __html__.""" 

105 

106 def __init__(self, text): 

107 self.text = text 

108 

109 def __html__(self): 

110 # In a real app, this would come from a sanitizer or trusted source 

111 return self.text 

112 

113 

114def test_literal_html_has_html_dunder(): 

115 assert isinstance(LiteralHTML, HasHTMLDunder) 

116 

117 

118def test_markup_has_html_dunder(): 

119 assert isinstance(Markup, HasHTMLDunder) 

120 

121 

122class TestComment: 

123 def test_literal(self): 

124 assert html(t"<!--This is a comment-->") == "<!--This is a comment-->" 

125 

126 # 

127 # Singleton / Exact Match 

128 # 

129 def test_singleton_str(self): 

130 text = "This is a comment" 

131 assert html(t"<!--{text}-->") == "<!--This is a comment-->" 

132 

133 def test_singleton_object(self): 

134 assert html(t"<!--{0}-->") == "<!--0-->" 

135 

136 def test_singleton_none(self): 

137 assert html(t"<!--{None}-->") == "<!---->" 

138 

139 @pytest.mark.parametrize("bool_value", (True, False)) 

140 def test_singleton_bool(self, bool_value): 

141 assert html(t"<!--{bool_value}-->") == "<!---->" 

142 

143 @pytest.mark.parametrize( 

144 "html_dunder_cls", 

145 ( 

146 LiteralHTML, 

147 Markup, 

148 ), 

149 ) 

150 def test_singleton_has_html_dunder(self, html_dunder_cls): 

151 content = html_dunder_cls("-->") 

152 assert html(t"<!--{content}-->") == "<!---->-->", ( 

153 "DO NOT DO THIS! This is just an advanced escape hatch." 

154 ) 

155 

156 def test_singleton_escaping(self): 

157 text = "-->comment" 

158 assert html(t"<!--{text}-->") == "<!----&gt;comment-->" 

159 

160 # 

161 # Templated -- literal text mixed with interpolation(s) 

162 # 

163 def test_templated_str(self): 

164 text = "comment" 

165 assert html(t"<!--This is a {text}-->") == "<!--This is a comment-->" 

166 

167 def test_templated_object(self): 

168 assert html(t"<!--This is a {0}-->") == "<!--This is a 0-->" 

169 

170 def test_templated_none(self): 

171 assert html(t"<!--This is a {None}-->") == "<!--This is a -->" 

172 

173 @pytest.mark.parametrize("bool_value", (True, False)) 

174 def test_templated_bool(self, bool_value): 

175 assert html(t"<!--This is a {bool_value}-->") == "<!--This is a -->" 

176 

177 @pytest.mark.parametrize( 

178 "html_dunder_cls", 

179 ( 

180 LiteralHTML, 

181 Markup, 

182 ), 

183 ) 

184 def test_templated_has_html_dunder_error(self, html_dunder_cls): 

185 """Objects with __html__ are not processed with literal text or other interpolations.""" 

186 text = html_dunder_cls("in a comment") 

187 with pytest.raises(ValueError, match="not supported"): 

188 _ = html(t"<!--This is a {text}-->") 

189 with pytest.raises(ValueError, match="not supported"): 

190 _ = html(t"<!--{None}{text}-->") 

191 with pytest.raises(ValueError, match="not supported"): 

192 _ = html(t"<!--This is a {Markup('Also check specialized cls.')}-->") 

193 

194 def test_templated_multiple_interpolations(self): 

195 text = "comment" 

196 assert ( 

197 html(t"<!--This is a {text} with {0} and {None}-->") 

198 == "<!--This is a comment with 0 and -->" 

199 ) 

200 

201 def test_templated_escaping(self): 

202 # @TODO: There doesn't seem to be a way to properly escape this 

203 # so we just use an entity to break the special closing string 

204 # even though it won't be actually unescaped by anything. There 

205 # might be something better for this. 

206 text = "-->comment" 

207 assert html(t"<!--This is a {text}-->") == "<!--This is a --&gt;comment-->" 

208 

209 def test_not_supported__recursive_template_error(self): 

210 text_t = t"comment" 

211 with pytest.raises(ValueError, match="not supported"): 

212 _ = html(t"<!--{text_t}-->") 

213 

214 def test_not_supported_recursive_iterable_error(self): 

215 texts = ["This", "is", "a", "comment"] 

216 with pytest.raises(ValueError, match="not supported"): 

217 _ = html(t"<!--{texts}-->") 

218 

219 

220class TestDocumentType: 

221 def test_literal(self): 

222 assert html(t"<!doctype html>") == "<!DOCTYPE html>" 

223 

224 def test_literal_lowercase(self): 

225 tp = TemplateProcessor(uppercase_doctype=False) 

226 assert ( 

227 tp.process(t"<!doctype html>", assume_ctx=ProcessContext()) 

228 == "<!doctype html>" 

229 ) 

230 

231 

232class TestVoidElementLiteral: 

233 def test_void(self): 

234 assert html(t"<br>") == "<br />" 

235 

236 def test_void_self_closed(self): 

237 assert html(t"<br />") == "<br />" 

238 

239 def test_void_mixed_closing(self): 

240 assert html(t"<br>Is this content?<br />") == "<br />Is this content?<br />" 

241 

242 def test_chain_of_void_elements(self): 

243 # Make sure our handling of CPython issue #69445 is reasonable. 

244 assert ( 

245 html(t"<br><hr><img src='image.png' /><br /><hr>") 

246 == '<br /><hr /><img src="image.png" /><br /><hr />' 

247 ) 

248 

249 

250class TestNormalTextElementLiteral: 

251 def test_empty(self): 

252 assert html(t"<div></div>") == "<div></div>" 

253 

254 def test_with_text(self): 

255 assert html(t"<p>Hello, world!</p>") == "<p>Hello, world!</p>" 

256 

257 def test_nested_elements(self): 

258 assert ( 

259 html(t"<div><p>Hello</p><p>World</p></div>") 

260 == "<div><p>Hello</p><p>World</p></div>" 

261 ) 

262 

263 def test_entities_are_escaped(self): 

264 """Literal entities interpreted by parser but escaped in output.""" 

265 res = html(t"<p>&lt;/p&gt;</p>") 

266 assert res == "<p>&lt;/p&gt;</p>", res 

267 

268 

269class TestNormalTextElementDynamic: 

270 def test_singleton_None(self): 

271 assert html(t"<p>{None}</p>") == "<p></p>" 

272 

273 def test_singleton_str(self): 

274 name = "Alice" 

275 assert html(t"<p>{name}</p>") == "<p>Alice</p>" 

276 

277 @pytest.mark.parametrize("bool_value", (True, False)) 

278 def test_singleton_bool(self, bool_value): 

279 assert html(t"<p>{bool_value}</p>") == "<p></p>" 

280 

281 def test_singleton_object(self): 

282 assert html(t"<p>{0}</p>") == "<p>0</p>" 

283 

284 @pytest.mark.parametrize( 

285 "html_dunder_cls", 

286 ( 

287 LiteralHTML, 

288 Markup, 

289 ), 

290 ) 

291 def test_singleton_has_html_dunder(self, html_dunder_cls): 

292 content = html_dunder_cls("<em>Alright!</em>") 

293 assert html(t"<p>{content}</p>") == "<p><em>Alright!</em></p>" 

294 

295 def test_singleton_simple_template(self): 

296 name = "Alice" 

297 text_t = t"Hi {name}" 

298 assert html(t"<p>{text_t}</p>") == "<p>Hi Alice</p>" 

299 

300 def test_singleton_simple_iterable(self): 

301 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] 

302 assert html(t"<p>{strs}</p>") == "<p>Strings...Yeah!Rock...Yeah!</p>" 

303 

304 def test_singleton_escaping(self): 

305 text = '''<>&'"''' 

306 assert html(t"<p>{text}</p>") == "<p>&lt;&gt;&amp;&#39;&#34;</p>" 

307 

308 def test_templated_None(self): 

309 assert html(t"<p>Response: {None}.</p>") == "<p>Response: .</p>" 

310 

311 def test_templated_str(self): 

312 name = "Alice" 

313 assert html(t"<p>Response: {name}.</p>") == "<p>Response: Alice.</p>" 

314 

315 @pytest.mark.parametrize("bool_value", (True, False)) 

316 def test_templated_bool(self, bool_value): 

317 assert html(t"<p>Response: {bool_value}</p>") == "<p>Response: </p>" 

318 

319 def test_templated_object(self): 

320 assert html(t"<p>Response: {0}.</p>") == "<p>Response: 0.</p>" 

321 

322 @pytest.mark.parametrize( 

323 "html_dunder_cls", 

324 ( 

325 LiteralHTML, 

326 Markup, 

327 ), 

328 ) 

329 def test_templated_has_html_dunder(self, html_dunder_cls): 

330 text = html_dunder_cls("<em>Alright!</em>") 

331 assert ( 

332 html(t"<p>Response: {text}.</p>") == "<p>Response: <em>Alright!</em>.</p>" 

333 ) 

334 

335 def test_templated_simple_template(self): 

336 name = "Alice" 

337 text_t = t"Hi {name}" 

338 assert html(t"<p>Response: {text_t}.</p>") == "<p>Response: Hi Alice.</p>" 

339 

340 def test_templated_simple_iterable(self): 

341 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] 

342 assert ( 

343 html(t"<p>Response: {strs}.</p>") 

344 == "<p>Response: Strings...Yeah!Rock...Yeah!.</p>" 

345 ) 

346 

347 def test_templated_escaping(self): 

348 text = '''<>&'"''' 

349 assert ( 

350 html(t"<p>Response: {text}.</p>") 

351 == "<p>Response: &lt;&gt;&amp;&#39;&#34;.</p>" 

352 ) 

353 

354 def test_templated_escaping_in_literals(self): 

355 text = "This text is fine" 

356 assert ( 

357 html(t"<p>The literal has &lt; in it: {text}.</p>") 

358 == "<p>The literal has &lt; in it: This text is fine.</p>" 

359 ) 

360 

361 def test_iterable_of_templates(self): 

362 items = ["Apple", "Banana", "Cherry"] 

363 assert ( 

364 html(t"<ul>{[t'<li>{item}</li>' for item in items]}</ul>") 

365 == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>" 

366 ) 

367 

368 def test_iterable_of_templates_of_iterable_of_templates(self): 

369 outer = ["fruit", "more fruit"] 

370 inner = ["apple", "banana", "cherry"] 

371 inner_items = [t"<li>{item}</li>" for item in inner] 

372 outer_items = [ 

373 t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer 

374 ] 

375 assert ( 

376 html(t"<ul>{outer_items}</ul>") 

377 == "<ul><li>fruit<ul><li>apple</li><li>banana</li><li>cherry</li></ul></li><li>more fruit<ul><li>apple</li><li>banana</li><li>cherry</li></ul></li></ul>" 

378 ) 

379 

380 

381class TestRawTextElementLiteral: 

382 def test_script_empty(self): 

383 assert html(t"<script></script>") == "<script></script>" 

384 

385 def test_style_empty(self): 

386 assert html(t"<style></style>") == "<style></style>" 

387 

388 def test_script_with_content(self): 

389 assert html(t"<script>var x = 1;</script>") == "<script>var x = 1;</script>" 

390 

391 def test_style_with_content(self): 

392 # @NOTE: Double {{ and }} to avoid t-string interpolation. 

393 assert ( 

394 html(t"<style>.red { color: red; } </style>") 

395 == "<style>.red { color: red; }</style>" 

396 ) 

397 

398 def test_script_with_content_escaped_in_normal_text(self): 

399 # @NOTE: Double {{ and }} to avoid t-string interpolation. 

400 assert ( 

401 html(t"<script>function CompareNumbers(a, b) { return a < b; } </script>") 

402 == "<script>function CompareNumbers(a, b) { return a < b; }</script>" 

403 ), "The < should not be escaped." 

404 

405 def test_style_with_content_escaped_in_normal_text(self): 

406 # @NOTE: Double {{ and }} to avoid t-string interpolation. 

407 assert ( 

408 html(t"<style>section > h4 { background-color: red; } </style>") 

409 == "<style>section > h4 { background-color: red; }</style>" 

410 ), "The > should not be escaped." 

411 

412 def test_not_supported_recursive_template_error(self): 

413 text_t = t"comment" 

414 with pytest.raises(ValueError, match="not supported"): 

415 _ = html(t"<!--{text_t}-->") 

416 

417 def test_not_supported_recursive_iterable_error(self): 

418 texts = ["This", "is", "a", "comment"] 

419 with pytest.raises(ValueError, match="not supported"): 

420 _ = html(t"<!--{texts}-->") 

421 

422 

423class TestEscapableRawTextElementLiteral: 

424 def test_title_empty(self): 

425 assert html(t"<title></title>") == "<title></title>" 

426 

427 def test_textarea_empty(self): 

428 assert html(t"<textarea></textarea>") == "<textarea></textarea>" 

429 

430 def test_title_with_content(self): 

431 assert html(t"<title>Content</title>") == "<title>Content</title>" 

432 

433 def test_textarea_with_content(self): 

434 assert html(t"<textarea>Content</textarea>") == "<textarea>Content</textarea>" 

435 

436 def test_title_with_escapable_content(self): 

437 assert ( 

438 html(t"<title>Are t-strings > everything?</title>") 

439 == "<title>Are t-strings &gt; everything?</title>" 

440 ), "The > can be escaped in this content type." 

441 

442 def test_textarea_with_escapable_content(self): 

443 assert ( 

444 html(t"<textarea><p>Welcome To TDOM</p></textarea>") 

445 == "<textarea>&lt;p&gt;Welcome To TDOM&lt;/p&gt;</textarea>" 

446 ), "The p tags can be escaped in this content type." 

447 

448 

449class TestRawTextScriptDynamic: 

450 def test_singleton_none(self): 

451 assert html(t"<script>{None}</script>") == "<script></script>" 

452 

453 def test_singleton_str(self): 

454 content = "var x = 1;" 

455 assert html(t"<script>{content}</script>") == "<script>var x = 1;</script>" 

456 

457 @pytest.mark.parametrize("bool_value", (True, False)) 

458 def test_singleton_bool(self, bool_value): 

459 assert html(t"<script>{bool_value}</script>") == "<script></script>" 

460 

461 def test_singleton_object(self): 

462 content = 0 

463 assert html(t"<script>{content}</script>") == "<script>0</script>" 

464 

465 @pytest.mark.parametrize( 

466 "html_dunder_cls", 

467 ( 

468 LiteralHTML, 

469 Markup, 

470 ), 

471 ) 

472 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

473 # @TODO: We should probably put some double override to prevent this by accident. 

474 # Or just disable this and if people want to do this then put the 

475 # content in a SCRIPT and inject the whole thing with a __html__? 

476 content = html_dunder_cls("</script>") 

477 assert html(t"<script>{content}</script>") == "<script></script></script>", ( 

478 "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!" 

479 ) 

480 

481 def test_singleton_escaping(self): 

482 content = "</script>" 

483 script_t = t"<script>{content}</script>" 

484 bad_output = script_t.strings[0] + content + script_t.strings[1] 

485 assert html(script_t) == "<script>\\x3c/script></script>" 

486 assert html(script_t) != bad_output, "Sanity check." 

487 

488 def test_templated_none(self): 

489 assert ( 

490 html(t"<script>var x = 1;{None};</script>") 

491 == "<script>var x = 1;;</script>" 

492 ) 

493 

494 def test_templated_str(self): 

495 content = "var x = 1" 

496 assert ( 

497 html(t"<script>var x = 0;{content};</script>") 

498 == "<script>var x = 0;var x = 1;</script>" 

499 ) 

500 

501 @pytest.mark.parametrize("bool_value", (True, False)) 

502 def test_templated_bool(self, bool_value): 

503 assert ( 

504 html(t"<script>var x = 15; {bool_value}</script>") 

505 == "<script>var x = 15; </script>" 

506 ) 

507 

508 def test_templated_object(self): 

509 content = 0 

510 assert ( 

511 html(t"<script>var x = {content};</script>") 

512 == "<script>var x = 0;</script>" 

513 ) 

514 

515 @pytest.mark.parametrize( 

516 "html_dunder_cls", 

517 ( 

518 LiteralHTML, 

519 Markup, 

520 ), 

521 ) 

522 def test_templated_has_html_dunder(self, html_dunder_cls): 

523 content = html_dunder_cls("anything") 

524 with pytest.raises(ValueError, match="not supported"): 

525 _ = html(t"<script>var x = 1;{content}</script>") 

526 

527 def test_templated_escaping(self): 

528 content = "</script>" 

529 script_t = t"<script>var x = '{content}';</script>" 

530 bad_output = script_t.strings[0] + content + script_t.strings[1] 

531 assert html(script_t) == "<script>var x = '\\x3c/script>';</script>" 

532 assert html(script_t) != bad_output, "Sanity check." 

533 

534 def test_templated_multiple_interpolations(self): 

535 assert ( 

536 html(t"<script>var x = {1}; var y = {2};</script>") 

537 == "<script>var x = 1; var y = 2;</script>" 

538 ) 

539 

540 def test_not_supported_recursive_template_error(self): 

541 text_t = t"script" 

542 with pytest.raises(ValueError, match="not supported"): 

543 _ = html(t"<script>{text_t}</script>") 

544 

545 def test_not_supported_recursive_iterable_error(self): 

546 texts = ["This", "is", "a", "script"] 

547 with pytest.raises(ValueError, match="not supported"): 

548 _ = html(t"<script>{texts}</script>") 

549 

550 

551class TestRawTextStyleDynamic: 

552 def test_singleton_none(self): 

553 assert html(t"<style>{None}</style>") == "<style></style>" 

554 

555 def test_singleton_str(self): 

556 content = "div { background-color: red; }" 

557 assert ( 

558 html(t"<style>{content}</style>") 

559 == "<style>div { background-color: red; }</style>" 

560 ) 

561 

562 @pytest.mark.parametrize("bool_value", (True, False)) 

563 def test_singleton_bool(self, bool_value): 

564 assert html(t"<style>{bool_value}</style>") == "<style></style>" 

565 

566 def test_singleton_object(self): 

567 content = 0 

568 assert html(t"<style>{content}</style>") == "<style>0</style>" 

569 

570 @pytest.mark.parametrize( 

571 "html_dunder_cls", 

572 ( 

573 LiteralHTML, 

574 Markup, 

575 ), 

576 ) 

577 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

578 # @TODO: We should probably put some double override to prevent this by accident. 

579 # Or just disable this and if people want to do this then put the 

580 # content in a STYLE and inject the whole thing with a __html__? 

581 content = html_dunder_cls("</style>") 

582 assert html(t"<style>{content}</style>") == "<style></style></style>", ( 

583 "DO NOT DO THIS! This is just an advanced escape hatch!" 

584 ) 

585 

586 def test_singleton_escaping(self): 

587 content = "</style>" 

588 style_t = t"<style>{content}</style>" 

589 bad_output = style_t.strings[0] + content + style_t.strings[1] 

590 assert html(style_t) == "<style>&lt;/style></style>" 

591 assert html(style_t) != bad_output, "Sanity check." 

592 

593 def test_templated_none(self): 

594 assert ( 

595 html(t"<style>h1 { background-color: red; } {None}</style>") 

596 == "<style>h1 { background-color: red; }</style>" 

597 ) 

598 

599 def test_templated_str(self): 

600 content = " h2 { background-color: blue; }" 

601 assert ( 

602 html(t"<style>h1 { background-color: red; } {content}</style>") 

603 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>" 

604 ) 

605 

606 @pytest.mark.parametrize("bool_value", (True, False)) 

607 def test_templated_bool(self, bool_value): 

608 assert ( 

609 html(t"<style>h1 { background-color: red; } ;{bool_value}</style>") 

610 == "<style>h1 { background-color: red; };</style>" 

611 ) 

612 

613 def test_templated_object(self): 

614 padding_right = 0 

615 assert ( 

616 html(t"<style>h1 { padding-right: {padding_right}px; } </style>") 

617 == "<style>h1 { padding-right: 0px; }</style>" 

618 ) 

619 

620 @pytest.mark.parametrize( 

621 "html_dunder_cls", 

622 ( 

623 LiteralHTML, 

624 Markup, 

625 ), 

626 ) 

627 def test_templated_has_html_dunder(self, html_dunder_cls): 

628 content = html_dunder_cls("anything") 

629 with pytest.raises(ValueError, match="not supported"): 

630 _ = html(t"<style>h1 { color: red; } ;{content}</style>") 

631 

632 def test_templated_escaping(self): 

633 content = "</style>" 

634 style_t = t"<style>div { background-color: red; } {content}</style>" 

635 bad_output = style_t.strings[0] + content + style_t.strings[1] 

636 assert ( 

637 html(style_t) == "<style>div { background-color: red; } &lt;/style></style>" 

638 ) 

639 assert html(style_t) != bad_output, "Sanity check." 

640 

641 def test_templated_multiple_interpolations(self): 

642 assert ( 

643 html( 

644 t"<style>h1 { background-color: {'red'}; } h2 { background-color: {'blue'}; } </style>" 

645 ) 

646 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>" 

647 ) 

648 

649 def test_exact_not_supported_recursive_template_error(self): 

650 text_t = t"style" 

651 with pytest.raises(ValueError, match="not supported"): 

652 _ = html(t"<style>{text_t}</style>") 

653 

654 def test_inexact_not_supported_recursive_template_error(self): 

655 text_t = t"style" 

656 with pytest.raises(ValueError, match="not supported"): 

657 _ = html(t"<style>{text_t} and more</style>") 

658 

659 def test_exact_not_supported_recursive_iterable_error(self): 

660 texts = ["This", "is", "a", "style"] 

661 with pytest.raises(ValueError, match="not supported"): 

662 _ = html(t"<style>{texts}</style>") 

663 

664 def test_inexact_not_supported_recursive_iterable_error(self): 

665 texts = ["This", "is", "a", "style"] 

666 with pytest.raises(ValueError, match="not supported"): 

667 _ = html(t"<style>{texts} and more</style>") 

668 

669 

670class TestEscapableRawTextTitleDynamic: 

671 def test_singleton_none(self): 

672 assert html(t"<title>{None}</title>") == "<title></title>" 

673 

674 def test_singleton_str(self): 

675 content = "Welcome To TDOM" 

676 assert html(t"<title>{content}</title>") == "<title>Welcome To TDOM</title>" 

677 

678 @pytest.mark.parametrize("bool_value", (True, False)) 

679 def test_singleton_bool(self, bool_value): 

680 assert html(t"<title>{bool_value}</title>") == "<title></title>" 

681 

682 def test_singleton_object(self): 

683 content = 0 

684 assert html(t"<title>{content}</title>") == "<title>0</title>" 

685 

686 @pytest.mark.parametrize( 

687 "html_dunder_cls", 

688 ( 

689 LiteralHTML, 

690 Markup, 

691 ), 

692 ) 

693 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

694 # @TODO: We should probably put some double override to prevent this by accident. 

695 content = html_dunder_cls("</title>") 

696 assert html(t"<title>{content}</title>") == "<title></title></title>", ( 

697 "DO NOT DO THIS! This is just an advanced escape hatch!" 

698 ) 

699 

700 def test_singleton_escaping(self): 

701 content = "</title>" 

702 assert html(t"<title>{content}</title>") == "<title>&lt;/title&gt;</title>" 

703 

704 def test_templated_none(self): 

705 assert ( 

706 html(t"<title>A great story about: {None}</title>") 

707 == "<title>A great story about: </title>" 

708 ) 

709 

710 def test_templated_str(self): 

711 content = "TDOM" 

712 assert ( 

713 html(t"<title>A great story about: {content}</title>") 

714 == "<title>A great story about: TDOM</title>" 

715 ) 

716 

717 @pytest.mark.parametrize("bool_value", (True, False)) 

718 def test_templated_bool(self, bool_value): 

719 assert ( 

720 html(t"<title>A great story; {bool_value}</title>") 

721 == "<title>A great story; </title>" 

722 ) 

723 

724 def test_templated_object(self): 

725 content = 0 

726 assert ( 

727 html(t"<title>A great number: {content}</title>") 

728 == "<title>A great number: 0</title>" 

729 ) 

730 

731 @pytest.mark.parametrize( 

732 "html_dunder_cls", 

733 ( 

734 LiteralHTML, 

735 Markup, 

736 ), 

737 ) 

738 def test_templated_has_html_dunder(self, html_dunder_cls): 

739 content = html_dunder_cls("No") 

740 with pytest.raises(ValueError, match="not supported"): 

741 _ = html(t"<title>Literal html?: {content}</title>") 

742 

743 def test_templated_escaping(self): 

744 content = "</title>" 

745 assert ( 

746 html(t"<title>The end tag: {content}.</title>") 

747 == "<title>The end tag: &lt;/title&gt;.</title>" 

748 ) 

749 

750 def test_templated_multiple_interpolations(self): 

751 assert ( 

752 html(t"<title>The number {0} is less than {1}.</title>") 

753 == "<title>The number 0 is less than 1.</title>" 

754 ) 

755 

756 def test_exact_not_supported_recursive_template_error(self): 

757 text_t = t"title" 

758 with pytest.raises(ValueError, match="not supported"): 

759 _ = html(t"<title>{text_t}</title>") 

760 

761 def test_exact_not_supported_recursive_iterable_error(self): 

762 texts = ["This", "is", "a", "title"] 

763 with pytest.raises(ValueError, match="not supported"): 

764 _ = html(t"<title>{texts}</title>") 

765 

766 def test_inexact_not_supported_recursive_template_error(self): 

767 text_t = t"title" 

768 with pytest.raises(ValueError, match="not supported"): 

769 _ = html(t"<title>{text_t} and more</title>") 

770 

771 def test_inexact_not_supported_recursive_iterable_error(self): 

772 texts = ["This", "is", "a", "title"] 

773 with pytest.raises(ValueError, match="not supported"): 

774 _ = html(t"<title>{texts} and more</title>") 

775 

776 

777class TestEscapableRawTextTextareaDynamic: 

778 def test_singleton_none(self): 

779 assert html(t"<textarea>{None}</textarea>") == "<textarea></textarea>" 

780 

781 def test_singleton_str(self): 

782 content = "Welcome To TDOM" 

783 assert ( 

784 html(t"<textarea>{content}</textarea>") 

785 == "<textarea>Welcome To TDOM</textarea>" 

786 ) 

787 

788 @pytest.mark.parametrize("bool_value", (True, False)) 

789 def test_singleton_bool(self, bool_value): 

790 assert html(t"<textarea>{bool_value}</textarea>") == "<textarea></textarea>" 

791 

792 def test_singleton_object(self): 

793 content = 0 

794 assert html(t"<textarea>{content}</textarea>") == "<textarea>0</textarea>" 

795 

796 @pytest.mark.parametrize( 

797 "html_dunder_cls", 

798 ( 

799 LiteralHTML, 

800 Markup, 

801 ), 

802 ) 

803 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

804 # @TODO: We should probably put some double override to prevent this by accident. 

805 content = html_dunder_cls("</textarea>") 

806 assert ( 

807 html(t"<textarea>{content}</textarea>") 

808 == "<textarea></textarea></textarea>" 

809 ), "DO NOT DO THIS! This is just an advanced escape hatch!" 

810 

811 def test_singleton_escaping(self): 

812 content = "</textarea>" 

813 assert ( 

814 html(t"<textarea>{content}</textarea>") 

815 == "<textarea>&lt;/textarea&gt;</textarea>" 

816 ) 

817 

818 def test_templated_none(self): 

819 assert ( 

820 html(t"<textarea>A great story about: {None}</textarea>") 

821 == "<textarea>A great story about: </textarea>" 

822 ) 

823 

824 def test_templated_str(self): 

825 content = "TDOM" 

826 assert ( 

827 html(t"<textarea>A great story about: {content}</textarea>") 

828 == "<textarea>A great story about: TDOM</textarea>" 

829 ) 

830 

831 @pytest.mark.parametrize("bool_value", (True, False)) 

832 def test_templated_bool(self, bool_value): 

833 assert ( 

834 html(t"<textarea>This is great.{bool_value}</textarea>") 

835 == "<textarea>This is great.</textarea>" 

836 ) 

837 

838 def test_templated_object(self): 

839 content = 0 

840 assert ( 

841 html(t"<textarea>A great number: {content}</textarea>") 

842 == "<textarea>A great number: 0</textarea>" 

843 ) 

844 

845 @pytest.mark.parametrize( 

846 "html_dunder_cls", 

847 ( 

848 LiteralHTML, 

849 Markup, 

850 ), 

851 ) 

852 def test_templated_has_html_dunder(self, html_dunder_cls): 

853 content = html_dunder_cls("No") 

854 with pytest.raises(ValueError, match="not supported"): 

855 _ = html(t"<textarea>Literal html?: {content}</textarea>") 

856 

857 def test_templated_multiple_interpolations(self): 

858 assert ( 

859 html(t"<textarea>The number {0} is less than {1}.</textarea>") 

860 == "<textarea>The number 0 is less than 1.</textarea>" 

861 ) 

862 

863 def test_templated_escaping(self): 

864 content = "</textarea>" 

865 assert ( 

866 html(t"<textarea>The end tag: {content}.</textarea>") 

867 == "<textarea>The end tag: &lt;/textarea&gt;.</textarea>" 

868 ) 

869 

870 def test_not_supported_recursive_template_error(self): 

871 text_t = t"textarea" 

872 with pytest.raises(ValueError, match="not supported"): 

873 _ = html(t"<textarea>{text_t}</textarea>") 

874 

875 def test_not_supported_recursive_iterable_error(self): 

876 texts = ["This", "is", "a", "textarea"] 

877 with pytest.raises(ValueError, match="not supported"): 

878 _ = html(t"<textarea>{texts}</textarea>") 

879 

880 

881class Convertible: 

882 def __str__(self): 

883 return "string" 

884 

885 def __repr__(self): 

886 return "repr" 

887 

888 

889def test_convertible_fixture(): 

890 """Make sure test fixture is working correctly.""" 

891 c = Convertible() 

892 assert f"{c!s}" == "string" 

893 assert f"{c!r}" == "repr" 

894 

895 

896def wrap_template_in_tags( 

897 start_tag: str, template: Template, end_tag: str | None = None 

898): 

899 """Utility for testing templated text but with different containing tags.""" 

900 if end_tag is None: 

901 end_tag = start_tag 

902 return Template(f"<{start_tag}>") + template + Template(f"</{end_tag}>") 

903 

904 

905def wrap_text_in_tags(start_tag: str, content: str, end_tag: str | None = None): 

906 """Utility for testing expected text but with different containing tags.""" 

907 if end_tag is None: 

908 end_tag = start_tag 

909 # Stringify to flatten `Markup()` 

910 content = str(content) 

911 return f"<{start_tag}>" + content + f"</{end_tag}>" 

912 

913 

914class TestInterpolationConversion: 

915 def test_str(self): 

916 c = Convertible() 

917 for tag in ("p", "script", "title"): 

918 assert html(wrap_template_in_tags(tag, t"{c!s}")) == wrap_text_in_tags( 

919 tag, "string" 

920 ) 

921 

922 def test_repr(self): 

923 c = Convertible() 

924 for tag in ("p", "script", "title"): 

925 assert html(wrap_template_in_tags(tag, t"{c!r}")) == wrap_text_in_tags( 

926 tag, "repr" 

927 ) 

928 

929 def test_ascii_raw_text(self): 

930 # single quotes are not escaped in raw text 

931 assert html(wrap_template_in_tags("script", t"{'😊'!a}")) == wrap_text_in_tags( 

932 "script", ascii("😊") 

933 ) 

934 

935 def test_ascii_escapable_normal_and_raw(self): 

936 # single quotes are escaped 

937 for tag in ("p", "title"): 

938 assert html(wrap_template_in_tags(tag, t"{'😊'!a}")) == wrap_text_in_tags( 

939 tag, escape_html_text(ascii("😊")) 

940 ) 

941 

942 

943class TestInterpolationFormatSpec: 

944 def test_normal_text_safe(self): 

945 raw_content = "<u>underlined</u>" 

946 assert ( 

947 html(t"<p>This is {raw_content:safe} text.</p>") 

948 == "<p>This is <u>underlined</u> text.</p>" 

949 ) 

950 

951 def test_raw_text_safe(self): 

952 # @TODO: What should even happen here? 

953 raw_content = "</script>" 

954 assert ( 

955 html(t"<script>{raw_content:safe}</script>") == "<script></script></script>" 

956 ), "DO NOT DO THIS! This is an advanced escape hatch." 

957 

958 def test_escapable_raw_text_safe(self): 

959 raw_content = "<u>underlined</u>" 

960 assert ( 

961 html(t"<textarea>{raw_content:safe}</textarea>") 

962 == "<textarea><u>underlined</u></textarea>" 

963 ) 

964 

965 def test_normal_text_unsafe(self): 

966 supposedly_safe = Markup("<i>italic</i>") 

967 assert ( 

968 html(t"<p>This is {supposedly_safe:unsafe} text.</p>") 

969 == "<p>This is &lt;i&gt;italic&lt;/i&gt; text.</p>" 

970 ) 

971 

972 def test_raw_text_unsafe(self): 

973 # @TODO: What should even happen here? 

974 supposedly_safe = "</script>" 

975 assert ( 

976 html(t"<script>{supposedly_safe:unsafe}</script>") 

977 == "<script>\\x3c/script></script>" 

978 ) 

979 assert ( 

980 html(t"<script>{supposedly_safe:unsafe}</script>") 

981 != "<script></script></script>" 

982 ) # Sanity check 

983 

984 def test_escapable_raw_text_unsafe(self): 

985 supposedly_safe = Markup("<i>italic</i>") 

986 assert ( 

987 html(t"<textarea>{supposedly_safe:unsafe}</textarea>") 

988 == "<textarea>&lt;i&gt;italic&lt;/i&gt;</textarea>" 

989 ) 

990 

991 def test_all_text_callback(self): 

992 def get_value(): 

993 return "dynamic" 

994 

995 for tag in ("p", "script", "style"): 

996 assert ( 

997 html( 

998 Template(f"<{tag}>") 

999 + t"The value is {get_value:callback}." 

1000 + Template(f"</{tag}>") 

1001 ) 

1002 == f"<{tag}>The value is dynamic.</{tag}>" 

1003 ) 

1004 

1005 def test_callback_nonzero_callable_error(self): 

1006 def add(a, b): 

1007 return a + b 

1008 

1009 assert add(1, 2) == 3, "Make sure fixture could work..." 

1010 

1011 for tag in ("p", "script", "style"): 

1012 with pytest.raises(TypeError): 

1013 _ = html( 

1014 Template(f"<{tag}>") 

1015 + t"The sum is {add:callback}." 

1016 + Template(f"</{tag}>") 

1017 ) 

1018 

1019 

1020# -------------------------------------------------------------------------- 

1021# Conditional rendering and control flow 

1022# -------------------------------------------------------------------------- 

1023 

1024 

1025class TestUsagePatterns: 

1026 def test_conditional_rendering_with_if_else(self): 

1027 is_logged_in = True 

1028 user_profile = t"<span>Welcome, User!</span>" 

1029 login_prompt = t"<a href='/login'>Please log in</a>" 

1030 assert ( 

1031 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>") 

1032 == "<div><span>Welcome, User!</span></div>" 

1033 ) 

1034 

1035 is_logged_in = False 

1036 assert ( 

1037 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>") 

1038 == '<div><a href="/login">Please log in</a></div>' 

1039 ) 

1040 

1041 

1042# -------------------------------------------------------------------------- 

1043# Attributes 

1044# -------------------------------------------------------------------------- 

1045class TestLiteralAttribute: 

1046 """Test literal (non-dynamic) attributes.""" 

1047 

1048 def test_literal_attrs(self): 

1049 assert ( 

1050 html( 

1051 t"<a " 

1052 t" id=example_link" # no quotes required if value has no surrounding whitespace 

1053 t" autofocus" # bare / boolean 

1054 t' title=""' # empty attribute 

1055 t' href="https://example.com" target="_blank"' 

1056 t"></a>" 

1057 ) 

1058 == '<a id="example_link" autofocus title="" href="https://example.com" target="_blank"></a>' 

1059 ) 

1060 

1061 def test_literal_attr_escaped(self): 

1062 assert ( 

1063 html(t'<a title="&lt;&gt;&amp;&#39;&#34;"></a>') 

1064 == '<a title="&lt;&gt;&amp;&#39;&#34;"></a>' 

1065 ) 

1066 

1067 

1068class TestInterpolatedAttribute: 

1069 """Test interpolated attributes, entire value is an exact interpolation.""" 

1070 

1071 def test_interpolated_attr(self): 

1072 url = "https://example.com/" 

1073 assert html(t'<a href="{url}"></a>') == '<a href="https://example.com/"></a>' 

1074 

1075 def test_interpolated_attr_escaped(self): 

1076 url = 'https://example.com/?q="test"&lang=en' 

1077 assert ( 

1078 html(t'<a href="{url}"></a>') 

1079 == '<a href="https://example.com/?q=&#34;test&#34;&amp;lang=en"></a>' 

1080 ) 

1081 

1082 def test_interpolated_attr_unquoted(self): 

1083 id = "roquefort" 

1084 assert html(t"<div id={id}></div>") == '<div id="roquefort"></div>' 

1085 

1086 def test_interpolated_attr_true(self): 

1087 disabled = True 

1088 assert ( 

1089 html(t"<button disabled={disabled}></button>") 

1090 == "<button disabled></button>" 

1091 ) 

1092 

1093 def test_interpolated_attr_false(self): 

1094 disabled = False 

1095 assert html(t"<button disabled={disabled}></button>") == "<button></button>" 

1096 

1097 def test_interpolated_attr_none(self): 

1098 disabled = None 

1099 assert html(t"<button disabled={disabled}></button>") == "<button></button>" 

1100 

1101 def test_interpolate_attr_empty_string(self): 

1102 assert html(t'<div title=""></div>') == '<div title=""></div>' 

1103 

1104 

1105class TestSpreadAttribute: 

1106 """Test spread attributes.""" 

1107 

1108 def test_spread_attr(self): 

1109 attrs = {"href": "https://example.com/", "target": "_blank"} 

1110 assert ( 

1111 html(t"<a {attrs}></a>") 

1112 == '<a href="https://example.com/" target="_blank"></a>' 

1113 ) 

1114 

1115 def test_spread_attr_none(self): 

1116 attrs = None 

1117 assert html(t"<a {attrs}></a>") == "<a></a>" 

1118 

1119 def test_spread_attr_type_errors(self): 

1120 for attrs in (0, [], (), False, True): 

1121 with pytest.raises(TypeError): 

1122 _ = html(t"<a {attrs}></a>") 

1123 

1124 

1125class TestTemplatedAttribute: 

1126 def test_templated_attr_mixed_interpolations_start_end_and_nest(self): 

1127 left, middle, right = 1, 3, 5 

1128 prefix, suffix = t'<div data-range="', t'"></div>' 

1129 # Check interpolations at start, middle and/or end of templated attr 

1130 # or a combination of those to make sure text is not getting dropped. 

1131 for left_part, middle_part, right_part in product( 

1132 (t"{left}", Template(str(left))), 

1133 (t"{middle}", Template(str(middle))), 

1134 (t"{right}", Template(str(right))), 

1135 ): 

1136 test_t = ( 

1137 prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix 

1138 ) 

1139 assert html(test_t) == '<div data-range="1-3-5"></div>' 

1140 

1141 def test_templated_attr_no_quotes(self): 

1142 start = 1 

1143 end = 5 

1144 assert ( 

1145 html(t"<div data-range={start}-{end}></div>") 

1146 == '<div data-range="1-5"></div>' 

1147 ) 

1148 

1149 

1150class TestAttributeMerging: 

1151 def test_attr_merge_disjoint_interpolated_attr_spread_attr(self): 

1152 attrs = {"href": "https://example.com/", "id": "link1"} 

1153 target = "_blank" 

1154 assert ( 

1155 html(t"<a {attrs} target={target}></a>") 

1156 == '<a href="https://example.com/" id="link1" target="_blank"></a>' 

1157 ) 

1158 

1159 def test_attr_merge_overlapping_spread_attrs(self): 

1160 attrs1 = {"href": "https://example.com/", "id": "overwrtten"} 

1161 attrs2 = {"target": "_blank", "id": "link1"} 

1162 assert ( 

1163 html(t"<a {attrs1} {attrs2}></a>") 

1164 == '<a href="https://example.com/" target="_blank" id="link1"></a>' 

1165 ) 

1166 

1167 def test_attr_merge_replace_literal_attr_str_str(self): 

1168 assert ( 

1169 html(t'<div title="default" { {"title": "fresh"} }></div>') 

1170 == '<div title="fresh"></div>' 

1171 ) 

1172 

1173 def test_attr_merge_replace_literal_attr_str_true(self): 

1174 assert ( 

1175 html(t'<div title="default" { {"title": True} }></div>') 

1176 == "<div title></div>" 

1177 ) 

1178 

1179 def test_attr_merge_replace_literal_attr_true_str(self): 

1180 assert ( 

1181 html(t"<div title { {'title': 'fresh'} }></div>") 

1182 == '<div title="fresh"></div>' 

1183 ) 

1184 

1185 def test_attr_merge_remove_literal_attr_str_none(self): 

1186 assert html(t'<div title="default" { {"title": None} }></div>') == "<div></div>" 

1187 

1188 def test_attr_merge_remove_literal_attr_true_none(self): 

1189 assert html(t"<div title { {'title': None} }></div>") == "<div></div>" 

1190 

1191 def test_attr_merge_other_literal_attr_intact(self): 

1192 assert ( 

1193 html(t'<img title="default" { {"alt": "fresh"} }>') 

1194 == '<img title="default" alt="fresh" />' 

1195 ) 

1196 

1197 

1198class TestSpecialDataAttribute: 

1199 """Special data attribute handling.""" 

1200 

1201 def test_interpolated_data_attributes(self): 

1202 data = { 

1203 "user-id": 123, 

1204 "role": "admin", 

1205 "wild": True, 

1206 "false": False, 

1207 "none": None, 

1208 } 

1209 assert ( 

1210 html(t"<div data={data}>User Info</div>") 

1211 == '<div data-user-id="123" data-role="admin" data-wild>User Info</div>' 

1212 ) 

1213 

1214 def test_data_attr_toggle_to_str(self): 

1215 for res in [ 

1216 html(t"<div data-selected data={ {'selected': 'yes'} }></div>"), 

1217 html(t'<div data-selected="no" data={ {"selected": "yes"} }></div>'), 

1218 ]: 

1219 assert res == '<div data-selected="yes"></div>' 

1220 

1221 def test_data_attr_toggle_to_true(self): 

1222 res = html(t'<div data-selected="yes" data={ {"selected": True} }></div>') 

1223 assert res == "<div data-selected></div>" 

1224 

1225 def test_data_attr_unrelated_unaffected(self): 

1226 res = html(t"<div data-selected data={ {'active': True} }></div>") 

1227 assert res == "<div data-selected data-active></div>" 

1228 

1229 def test_data_attr_templated_error(self): 

1230 data1 = {"user-id": "user-123"} 

1231 data2 = {"role": "admin"} 

1232 with pytest.raises(TypeError): 

1233 _ = html(t'<div data="{data1} {data2}"></div>') 

1234 

1235 def test_data_attr_none(self): 

1236 button_data = None 

1237 res = html(t"<button data={button_data}>X</button>") 

1238 assert res == "<button>X</button>" 

1239 

1240 def test_data_attr_errors(self): 

1241 for v in [False, [], (), 0, "data?"]: 

1242 with pytest.raises(TypeError): 

1243 _ = html(t"<button data={v}>X</button>") 

1244 

1245 def test_data_literal_attr_bypass(self): 

1246 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1247 res = html(t'<p data="passthru" id={"resolved"}></p>') 

1248 assert res == '<p data="passthru" id="resolved"></p>', ( 

1249 "A single literal attribute should not trigger data expansion." 

1250 ) 

1251 

1252 

1253class TestSpecialAriaAttribute: 

1254 """Special aria attribute handling.""" 

1255 

1256 def test_aria_templated_attr_error(self): 

1257 aria1 = {"label": "close"} 

1258 aria2 = {"hidden": "true"} 

1259 with pytest.raises(TypeError): 

1260 _ = html(t'<div aria="{aria1} {aria2}"></div>') 

1261 

1262 def test_interpolated_mapping(self): 

1263 aria_dict = {"label": "Close", "hidden": True, "another": False, "more": None} 

1264 for aria_mapping in ( 

1265 aria_dict, 

1266 UserDict(aria_dict.items()), 

1267 ): # dict and non-dict Mapping 

1268 res = html(t"<button aria={aria_mapping}>X</button>") 

1269 assert ( 

1270 res 

1271 == '<button aria-label="Close" aria-hidden="true" aria-another="false">X</button>' 

1272 ) 

1273 

1274 def test_aria_interpolate_attr_none(self): 

1275 button_aria = None 

1276 res = html(t"<button aria={button_aria}>X</button>") 

1277 assert res == "<button>X</button>" 

1278 

1279 def test_aria_attr_errors(self): 

1280 for v in [False, [], (), 0, "aria?"]: 

1281 with pytest.raises(TypeError): 

1282 _ = html(t"<button aria={v}>X</button>") 

1283 

1284 def test_aria_literal_attr_bypass(self): 

1285 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1286 res = html(t'<p aria="passthru" id={"resolved"}></p>') 

1287 assert res == '<p aria="passthru" id="resolved"></p>', ( 

1288 "A single literal attribute should not trigger aria expansion." 

1289 ) 

1290 

1291 

1292class TestSpecialClassAttribute: 

1293 """Special class attribute handling.""" 

1294 

1295 def test_interpolated_class_attribute(self): 

1296 class_list = ["btn", "btn-primary", "one two", None] 

1297 class_dict = {"active": True, "btn-secondary": False} 

1298 class_str = "blue" 

1299 class_space_sep_str = "green yellow" 

1300 class_none = None 

1301 class_empty_list = [] 

1302 class_empty_dict = {} 

1303 button_t = ( 

1304 t"<button " 

1305 t' class="red" class={class_list} class={class_dict}' 

1306 t" class={class_empty_list} class={class_empty_dict}" # ignored 

1307 t" class={class_none}" # ignored 

1308 t" class={class_str} class={class_space_sep_str}" 

1309 t" >Click me</button>" 

1310 ) 

1311 res = html(button_t) 

1312 assert ( 

1313 res 

1314 == '<button class="red btn btn-primary one two active blue green yellow">Click me</button>' 

1315 ) 

1316 

1317 def test_interpolated_mapping(self): 

1318 class_dict = {"active": True, "btn-secondary": False} 

1319 for class_mapping in ( 

1320 class_dict, 

1321 UserDict(class_dict.items()), 

1322 ): # dict and non-dict Mapping 

1323 res = html( 

1324 t"<button class='btn-secondary' class={class_mapping}>X</button>" 

1325 ) 

1326 assert res == '<button class="active">X</button>' 

1327 

1328 def test_interpolated_class_attribute_with_multiple_placeholders(self): 

1329 classes1 = ["btn", "btn-primary"] 

1330 classes2 = [None, {"active": True}] 

1331 res = html(t'<button class="{classes1} {classes2}">Click me</button>') 

1332 # CONSIDER: Is this what we want? Currently, when we have multiple 

1333 # placeholders in a single attribute, we treat it as a string attribute. 

1334 assert ( 

1335 res 

1336 == f'<button class="{escape_html_text(str(classes1))} {escape_html_text(str(classes2))}">Click me</button>' 

1337 ), ( 

1338 "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified." 

1339 ) 

1340 

1341 def test_interpolated_attribute_spread_with_class_attribute(self): 

1342 attrs = {"id": "button1", "class": ["btn", "btn-primary"]} 

1343 res = html(t"<button {attrs}>Click me</button>") 

1344 assert res == '<button id="button1" class="btn btn-primary">Click me</button>' 

1345 

1346 def test_class_literal_attr_bypass(self): 

1347 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1348 res = html(t'<p class="red red" id={"veryred"}></p>') 

1349 assert res == '<p class="red red" id="veryred"></p>', ( 

1350 "A single literal attribute should not trigger class accumulator." 

1351 ) 

1352 

1353 def test_class_none_ignored(self): 

1354 class_item = None 

1355 res = html(t"<p class={class_item}></p>") 

1356 assert res == "<p></p>" 

1357 # Also ignored inside a sequence. 

1358 res = html(t"<p class={[class_item]}></p>") 

1359 assert res == "<p></p>" 

1360 

1361 def test_class_type_errors(self): 

1362 for class_item in (False, True, 0): 

1363 with pytest.raises(TypeError): 

1364 _ = html(t"<p class={class_item}></p>") 

1365 with pytest.raises(TypeError): 

1366 _ = html(t"<p class={[class_item]}></p>") 

1367 

1368 def test_class_merge_literals(self): 

1369 res = html(t'<p class="red" class="blue"></p>') 

1370 assert res == '<p class="red blue"></p>' 

1371 

1372 def test_class_merge_literal_then_interpolation(self): 

1373 class_item = "blue" 

1374 res = html(t'<p class="red" class="{[class_item]}"></p>') 

1375 assert res == '<p class="red blue"></p>' 

1376 

1377 

1378class TestSpecialStyleAttribute: 

1379 """Special style attribute handling.""" 

1380 

1381 def test_style_literal_attr_passthru(self): 

1382 p_id = "para1" # non-literal attribute to cause attr resolution 

1383 res = html(t'<p style="color: red" id={p_id}>Warning!</p>') 

1384 assert res == '<p style="color: red" id="para1">Warning!</p>' 

1385 

1386 def test_style_in_interpolated_attr(self): 

1387 styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} 

1388 res = html(t"<p style={styles}>Warning!</p>") 

1389 assert ( 

1390 res 

1391 == '<p style="color: red; font-weight: bold; font-size: 16px">Warning!</p>' 

1392 ) 

1393 

1394 def test_style_in_templated_attr(self): 

1395 color = "red" 

1396 res = html(t'<p style="color: {color}">Warning!</p>') 

1397 assert res == '<p style="color: red">Warning!</p>' 

1398 

1399 def test_style_in_spread_attr(self): 

1400 attrs = {"style": {"color": "red"}} 

1401 res = html(t"<p {attrs}>Warning!</p>") 

1402 assert res == '<p style="color: red">Warning!</p>' 

1403 

1404 def test_style_merged_from_all_attrs(self): 

1405 attrs = {"style": "font-size: 15px"} 

1406 style = {"font-weight": "bold"} 

1407 color = "red" 

1408 res = html( 

1409 t'<p style="font-family: serif" style="color: {color}" style={style} {attrs}></p>' 

1410 ) 

1411 assert ( 

1412 res 

1413 == '<p style="font-family: serif; color: red; font-weight: bold; font-size: 15px"></p>' 

1414 ) 

1415 

1416 def test_style_override_left_to_right(self): 

1417 suffix = t"></p>" 

1418 parts = [ 

1419 (t'<p style="color: red"', "color: red"), 

1420 (t" style={ {'color': 'blue'} }", "color: blue"), 

1421 (t' style="color: {"green"}"', "color: green"), 

1422 (t""" { {"style": {"color": "yellow"}} }""", "color: yellow"), 

1423 ] 

1424 for index in range(len(parts)): 

1425 expected_style = parts[index][1] 

1426 t = sum((part[0] for part in parts[: index + 1]), t"") + suffix 

1427 res = html(t) 

1428 assert res == f'<p style="{expected_style}"></p>' 

1429 

1430 def test_interpolated_style_attribute_multiple_placeholders(self): 

1431 styles1 = {"color": "red"} 

1432 styles2 = {"font-weight": "bold"} 

1433 # CONSIDER: Is this what we want? Currently, when we have multiple 

1434 # placeholders in a single attribute, we treat it as a string attribute 

1435 # which produces an invalid style attribute. 

1436 with pytest.raises(ValueError): 

1437 _ = html(t"<p style='{styles1} {styles2}'>Warning!</p>") 

1438 

1439 def test_interpolated_style_attribute_merged(self): 

1440 styles1 = {"color": "red"} 

1441 styles2 = {"font-weight": "bold"} 

1442 res = html(t"<p style={styles1} style={styles2}>Warning!</p>") 

1443 assert res == '<p style="color: red; font-weight: bold">Warning!</p>' 

1444 

1445 def test_interpolated_style_attribute_merged_override(self): 

1446 styles1 = {"color": "red", "font-weight": "normal"} 

1447 styles2 = {"font-weight": "bold"} 

1448 res = html(t"<p style={styles1} style={styles2}>Warning!</p>") 

1449 assert res == '<p style="color: red; font-weight: bold">Warning!</p>' 

1450 

1451 def test_style_attribute_str(self): 

1452 styles = "color: red; font-weight: bold;" 

1453 res = html(t"<p style={styles}>Warning!</p>") 

1454 assert res == '<p style="color: red; font-weight: bold">Warning!</p>' 

1455 

1456 def test_style_attribute_non_str_non_dict(self): 

1457 styles = [1, 2] 

1458 with pytest.raises(TypeError): 

1459 _ = html(t"<p style={styles}>Warning!</p>") 

1460 

1461 def test_style_literal_attr_bypass(self): 

1462 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1463 res = html(t'<p style="invalid;invalid:" id={"resolved"}></p>') 

1464 assert res == '<p style="invalid;invalid:" id="resolved"></p>', ( 

1465 "A single literal attribute should bypass style accumulator." 

1466 ) 

1467 

1468 def test_style_none(self): 

1469 styles = None 

1470 res = html(t"<p style={styles}></p>") 

1471 assert res == "<p></p>" 

1472 

1473 

1474class TestSpecialAttrMerging: 

1475 """ 

1476 Attributes should be merged left to right and displayed at the last 

1477 location they were updated. 

1478 """ 

1479 

1480 def test_accumulator_order(self): 

1481 # Accumlated attrs are flattened to a value at the end of the attribute 

1482 # resolution process which caused them to jump but this asserts that fix. 

1483 attrs = { 

1484 "class": {"btn": True, "active": True}, # Accumulated 

1485 "id": "act_now", # static 

1486 "data": {"wow": "such-attr"}, # Expanded 

1487 "title": "mega", # static 

1488 } 

1489 button = html(t"<button {attrs}>Click me</button>") 

1490 assert ( 

1491 button 

1492 == '<button class="btn active" id="act_now" data-wow="such-attr" title="mega">Click me</button>' 

1493 ) 

1494 

1495 

1496class TestPrepComponentKwargs: 

1497 def test_named(self): 

1498 def InputElement(size=10, type="text"): 

1499 pass 

1500 

1501 callable_info = get_callable_info(InputElement) 

1502 assert prep_component_kwargs(callable_info, {"size": 20}, children=t"") == { 

1503 "size": 20 

1504 } 

1505 assert prep_component_kwargs( 

1506 callable_info, {"type": "email"}, children=t"" 

1507 ) == {"type": "email"} 

1508 assert prep_component_kwargs(callable_info, {}, children=t"") == {} 

1509 

1510 def test_unused_kwargs(self): 

1511 def InputElement(size=10, type="text"): 

1512 pass 

1513 

1514 callable_info = get_callable_info(InputElement) 

1515 with pytest.raises(ValueError): 

1516 assert ( 

1517 prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {} 

1518 ) 

1519 

1520 def test_accepts_children(self): 

1521 def DivWrapper( 

1522 children: Template, add_classes: list[str] | None = None 

1523 ) -> Template: 

1524 return t"<div class={add_classes}>{children}</div>" 

1525 

1526 callable_info = get_callable_info(DivWrapper) 

1527 kwargs = prep_component_kwargs(callable_info, {}, children=t"") 

1528 assert tuple(kwargs.keys()) == ("children",) 

1529 assert isinstance(kwargs["children"], Template) and kwargs[ 

1530 "children" 

1531 ].strings == ("",) 

1532 

1533 add_classes = ["red"] 

1534 kwargs = prep_component_kwargs( 

1535 callable_info, {"add_classes": add_classes}, children=t"<span></span>" 

1536 ) 

1537 assert set(kwargs.keys()) == {"children", "add_classes"} 

1538 assert isinstance(kwargs["children"], Template) and kwargs[ 

1539 "children" 

1540 ].strings == ("<span></span>",) 

1541 assert kwargs["add_classes"] == add_classes 

1542 

1543 def test_no_children(self): 

1544 def SpanMaker(content_text: str) -> Template: 

1545 return t"<span>{content_text}</span>" 

1546 

1547 callable_info = get_callable_info(SpanMaker) 

1548 content_text = "inner" 

1549 kwargs = prep_component_kwargs( 

1550 callable_info, {"content_text": content_text}, children=t"<div></div>" 

1551 ) 

1552 assert kwargs == {"content_text": content_text} # no children 

1553 

1554 def test_children_attr_error(self): 

1555 def Comp(children: Template) -> Template: 

1556 return t"<div>{children}</div>" 

1557 

1558 callable_info = get_callable_info(Comp) 

1559 with pytest.raises(ValueError, match="The children attribute is reserved"): 

1560 _ = prep_component_kwargs( 

1561 callable_info, {"children": t""}, children=t"<span></span>" 

1562 ) 

1563 

1564 def test_to_snake(self): 

1565 def Wrapper(mw_em, **kwargs): 

1566 pass 

1567 

1568 callable_info = get_callable_info(Wrapper) 

1569 assert prep_component_kwargs(callable_info, {"mw-em": 20}, children=t"") == { 

1570 "mw_em": 20 

1571 } 

1572 

1573 def test_to_snake_pass_through(self): 

1574 def Wrapper(mw_em, **kwargs): 

1575 pass 

1576 

1577 callable_info = get_callable_info(Wrapper) 

1578 assert prep_component_kwargs( 

1579 callable_info, 

1580 {"mw-em": 20, "hx-on:click": "alert('Clicked!')"}, 

1581 children=t"", 

1582 ) == { 

1583 "mw_em": 20, # mangled, passed as named param 

1584 "hx-on:click": "alert('Clicked!')", # not mangled, will go in kwargs 

1585 } 

1586 

1587 @pytest.mark.parametrize( 

1588 "attrs", 

1589 [ 

1590 ( 

1591 ("prefix_attr_value", 10), 

1592 ("prefix_attr-value", 20), 

1593 ), # exact and not exact match 

1594 ( 

1595 ("prefix-attr_value", 10), 

1596 ("prefix_attr-value", 20), 

1597 ), # both not exact match 

1598 ], 

1599 ) 

1600 def test_to_snake_ambiguous_collision(self, attrs): 

1601 def Wrapper(prefix_attr_value, **kwargs): 

1602 pass 

1603 

1604 callable_info = get_callable_info(Wrapper) 

1605 with pytest.raises(ValueError, match="Ambiguous attribute "): 

1606 _ = prep_component_kwargs( 

1607 callable_info, 

1608 dict(attrs), 

1609 children=t"", 

1610 ) 

1611 

1612 

1613class TestFunctionComponent: 

1614 @staticmethod 

1615 def FunctionComponent( 

1616 children: Template, first: str, second: int, third_arg: str, **attrs: t.Any 

1617 ) -> Template: 

1618 # Ensure type correctness of props at runtime for testing purposes 

1619 assert isinstance(first, str) 

1620 assert isinstance(second, int) 

1621 assert isinstance(third_arg, str) 

1622 new_attrs = { 

1623 "id": third_arg, 

1624 "data": {"first": first, "second": second}, 

1625 **attrs, 

1626 } 

1627 return t"<div {new_attrs}>Component: {children}</div>" 

1628 

1629 def test_with_children(self): 

1630 res = html( 

1631 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{self.FunctionComponent}>' 

1632 ) 

1633 assert ( 

1634 res 

1635 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>' 

1636 ) 

1637 

1638 def test_with_no_children(self): 

1639 """Same test, but the caller didn't provide any children.""" 

1640 res = html( 

1641 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' 

1642 ) 

1643 assert ( 

1644 res 

1645 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>' 

1646 ) 

1647 

1648 def test_missing_props_error(self): 

1649 with pytest.raises(TypeError): 

1650 _ = html( 

1651 t"<{self.FunctionComponent}>Missing props</{self.FunctionComponent}>" 

1652 ) 

1653 

1654 

1655class TestFunctionComponentNoChildren: 

1656 @staticmethod 

1657 def FunctionComponentNoChildren( 

1658 first: str, second: int, third_arg: str 

1659 ) -> Template: 

1660 # Ensure type correctness of props at runtime for testing purposes 

1661 assert isinstance(first, str) 

1662 assert isinstance(second, int) 

1663 assert isinstance(third_arg, str) 

1664 new_attrs = { 

1665 "id": third_arg, 

1666 "data": {"first": first, "second": second}, 

1667 } 

1668 return t"<div {new_attrs}>Component: ignore children</div>" 

1669 

1670 def test_interpolated_template_component_ignore_children(self): 

1671 res = html( 

1672 t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{self.FunctionComponentNoChildren}>' 

1673 ) 

1674 assert ( 

1675 res 

1676 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>' 

1677 ) 

1678 

1679 

1680class TestFunctionComponentKeywordArgs: 

1681 @staticmethod 

1682 def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template: 

1683 # Ensure type correctness of props at runtime for testing purposes 

1684 assert isinstance(first, str) 

1685 if "children" in attrs: 

1686 raise ValueError("Children not expected in attrs.") 

1687 new_attrs = {"data-first": first, **attrs} 

1688 return t"<div {new_attrs}>No children in kwargs</div>" 

1689 

1690 def test_children_not_passed_via_kwargs(self): 

1691 res = html( 

1692 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content</{self.FunctionComponentKeywordArgs}>' 

1693 ) 

1694 assert res == '<div data-first="value" extra="info">No children in kwargs</div>' 

1695 

1696 def test_children_not_passed_via_kwargs_even_when_empty(self): 

1697 res = html( 

1698 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />' 

1699 ) 

1700 assert res == '<div data-first="value" extra="info">No children in kwargs</div>' 

1701 

1702 

1703class TestComponentSpecialUsage: 

1704 @staticmethod 

1705 def ColumnsComponent() -> Template: 

1706 return t"""<td>Column 1</td><td>Column 2</td>""" 

1707 

1708 def test_fragment_from_component(self): 

1709 # This test assumes that if a component returns a template that parses 

1710 # into multiple root elements, they are treated as a fragment. 

1711 res = html(t"<table><tr><{self.ColumnsComponent} /></tr></table>") 

1712 assert res == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>" 

1713 

1714 def test_component_passed_as_attr_value(self): 

1715 def Wrapper( 

1716 children: Template, sub_component: Callable, **attrs: t.Any 

1717 ) -> Template: 

1718 return t"<{sub_component} {attrs}>{children}</{sub_component}>" 

1719 

1720 res = html( 

1721 t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>' 

1722 ) 

1723 assert ( 

1724 res 

1725 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>' 

1726 ) 

1727 

1728 def test_nested_component_gh23(self): 

1729 # @DESIGN: Do we need this? Should we recommend an alternative? 

1730 # See https://github.com/t-strings/tdom/issues/23 for context 

1731 def Header() -> Template: 

1732 return t"{'Hello World'}" 

1733 

1734 res = html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div")) 

1735 assert res == "Hello World" 

1736 

1737 

1738class TestClassComponent: 

1739 @dataclass 

1740 class ClassComponent: 

1741 """Example class-based component.""" 

1742 

1743 user_name: str 

1744 image_url: str 

1745 children: Template 

1746 homepage: str = "#" 

1747 

1748 def __call__(self) -> Template: 

1749 return ( 

1750 t"<div class='avatar'>" 

1751 t"<a href={self.homepage}>" 

1752 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />" 

1753 t"</a>" 

1754 t"<span>{self.user_name}</span>" 

1755 t"{self.children}" 

1756 t"</div>" 

1757 ) 

1758 

1759 def test_class_component_implicit_invocation_with_children(self): 

1760 res = html( 

1761 t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponent}>" 

1762 ) 

1763 assert ( 

1764 res 

1765 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>' 

1766 ) 

1767 

1768 def test_class_component_direct_invocation(self): 

1769 avatar = self.ClassComponent( 

1770 user_name="Alice", 

1771 image_url="https://example.com/alice.png", 

1772 homepage="https://example.com/users/alice", 

1773 children=t"", # Children is required so we set it to an empty template. 

1774 ) 

1775 res = html(t"<{avatar} />") 

1776 assert ( 

1777 res 

1778 == '<div class="avatar"><a href="https://example.com/users/alice"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span></div>' 

1779 ) 

1780 

1781 @dataclass 

1782 class ClassComponentNoChildren: 

1783 """Example class-based component that does not ask for children.""" 

1784 

1785 user_name: str 

1786 image_url: str 

1787 homepage: str = "#" 

1788 

1789 def __call__(self) -> Template: 

1790 return ( 

1791 t"<div class='avatar'>" 

1792 t"<a href={self.homepage}>" 

1793 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />" 

1794 t"</a>" 

1795 t"<span>{self.user_name}</span>" 

1796 t"ignore children" 

1797 t"</div>" 

1798 ) 

1799 

1800 def test_implicit_invocation_ignore_children(self): 

1801 res = html( 

1802 t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponentNoChildren}>" 

1803 ) 

1804 assert ( 

1805 res 

1806 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>' 

1807 ) 

1808 

1809 

1810def test_attribute_type_component(): 

1811 def AttributeTypeComponent( 

1812 data_int: int, 

1813 data_true: bool, 

1814 data_false: bool, 

1815 data_none: None, 

1816 data_float: float, 

1817 data_dt: datetime.datetime, 

1818 **kws: dict[str, object | None], 

1819 ) -> Template: 

1820 """Component to test that we don't incorrectly convert attribute types.""" 

1821 assert isinstance(data_int, int) 

1822 assert data_true is True 

1823 assert data_false is False 

1824 assert data_none is None 

1825 assert isinstance(data_float, float) 

1826 assert isinstance(data_dt, datetime.datetime) 

1827 for kw, v_type in [ 

1828 ("spread_true", True), 

1829 ("spread_false", False), 

1830 ("spread_int", int), 

1831 ("spread_none", None), 

1832 ("spread_float", float), 

1833 ("spread_dt", datetime.datetime), 

1834 ("spread_dict", dict), 

1835 ("spread_list", list), 

1836 ]: 

1837 if v_type in (True, False, None): 

1838 assert kw in kws and kws[kw] is v_type, ( 

1839 f"{kw} should be {v_type} but got {kws=}" 

1840 ) 

1841 else: 

1842 assert kw in kws and isinstance(kws[kw], v_type), ( 

1843 f"{kw} should instance of {v_type} but got {kws=}" 

1844 ) 

1845 return t"Looks good!" 

1846 

1847 an_int: int = 42 

1848 a_true: bool = True 

1849 a_false: bool = False 

1850 a_none: None = None 

1851 a_float: float = math.pi 

1852 a_dt: datetime.datetime = datetime.datetime( 

1853 2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC 

1854 ) 

1855 spread_attrs: dict[str, object | None] = { 

1856 "spread_true": True, 

1857 "spread_false": False, 

1858 "spread_none": None, 

1859 "spread_int": 0, 

1860 "spread_float": 0.0, 

1861 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1, tzinfo=datetime.UTC), 

1862 "spread_dict": {}, 

1863 "spread_list": ["eggs", "milk"], 

1864 } 

1865 res = html( 

1866 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} " 

1867 t"data-false={a_false} data-none={a_none} data-float={a_float} " 

1868 t"data-dt={a_dt} {spread_attrs}/>" 

1869 ) 

1870 assert res == "Looks good!" 

1871 

1872 

1873class TestComponentErrors: 

1874 def test_component_non_callable_fails(self): 

1875 with pytest.raises(TypeError): 

1876 _ = html(t"<{'not a function'} />") 

1877 

1878 def test_component_requiring_positional_arg_fails(self): 

1879 def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover 

1880 return t"<p>Positional arg: {whoops}</p>" 

1881 

1882 with pytest.raises(TypeError): 

1883 _ = html(t"<{RequiresPositional} />") 

1884 

1885 def test_mismatched_component_closing_tag_fails(self): 

1886 def OpenTag(children: Template) -> Template: 

1887 return t"<div>open</div>" 

1888 

1889 def CloseTag(children: Template) -> Template: 

1890 return t"<div>close</div>" 

1891 

1892 with pytest.raises(TypeError): 

1893 _ = html(t"<{OpenTag}>Hello</{CloseTag}>") 

1894 

1895 @pytest.mark.parametrize( 

1896 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs")) 

1897 ) 

1898 def test_function_component_returns_nontemplate_fails(self, bad_value): 

1899 def BadFunctionComp(children: Template): 

1900 return bad_value 

1901 

1902 with pytest.raises( 

1903 TypeError, match="Component callable must return Template or Callable:" 

1904 ): 

1905 _ = html(t"<{BadFunctionComp}>Hello</{BadFunctionComp}>") 

1906 

1907 @pytest.mark.parametrize( 

1908 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs")) 

1909 ) 

1910 def test_component_object_returns_nontemplate_fails(self, bad_value): 

1911 def BadFactoryComp(children: Template): 

1912 def component_object(): 

1913 return bad_value 

1914 

1915 return component_object 

1916 

1917 with pytest.raises( 

1918 TypeError, match="Component object must return Template when called:" 

1919 ): 

1920 _ = html(t"<{BadFactoryComp}>Hello</{BadFactoryComp}>") 

1921 

1922 

1923def test_integration_basic(): 

1924 comment_text = "comment is not literal" 

1925 interpolated_class = "red" 

1926 text_in_element = "text is not literal" 

1927 templated = "not literal" 

1928 spread_attrs = {"data-on": True} 

1929 markup_content = Markup("<div>safe</div>") 

1930 

1931 def WrapperComponent(children): 

1932 return t"<div>{children}</div>" 

1933 

1934 smoke_t = t"""<!doctype html> 

1935<html> 

1936<body> 

1937<!-- literal --> 

1938<span attr="literal">literal</span> 

1939<!-- {comment_text} --> 

1940<span>{text_in_element}</span> 

1941<span attr="literal" class={interpolated_class} title="is {templated}" {spread_attrs}>{text_in_element}</span> 

1942<{WrapperComponent}><span>comp body</span></{WrapperComponent}> 

1943{markup_content} 

1944</body> 

1945</html>""" 

1946 smoke_str = """<!DOCTYPE html> 

1947<html> 

1948<body> 

1949<!-- literal --> 

1950<span attr="literal">literal</span> 

1951<!-- comment is not literal --> 

1952<span>text is not literal</span> 

1953<span attr="literal" class="red" title="is not literal" data-on>text is not literal</span> 

1954<div><span>comp body</span></div> 

1955<div>safe</div> 

1956</body> 

1957</html>""" 

1958 assert html(smoke_t) == smoke_str 

1959 

1960 

1961def struct_repr(st): 

1962 """Breakdown Templates into comparable parts for test verification.""" 

1963 return st.strings, tuple( 

1964 (i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations 

1965 ) 

1966 

1967 

1968def test_process_template_internal_cache(): 

1969 """Test that cache and non-cache both generally work as expected.""" 

1970 # @NOTE: We use a made-up custom element so that we can be sure to 

1971 # miss the cache. If this element is used elsewhere than the global 

1972 # cache might cache it and it will ruin our counting, specifically 

1973 # the first miss will instead be a hit. 

1974 sample_t = t"<div>{'content'}<tdom-cache-test-element /></div>" 

1975 sample_diff_t = t"<div>{'diffcontent'}<tdom-cache-test-element /></div>" 

1976 alt_t = t"<span>{'content'}</span>" 

1977 process_api = TemplateProcessor(parser_api=TemplateParserProxy()) 

1978 cached_process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy()) 

1979 # Because the cache is stored on the class itself this can be affect by 

1980 # other tests, so save this off and take the difference to determine the result, 

1981 # this is not great and hopefully we can find a better solution. 

1982 assert isinstance(cached_process_api, TemplateProcessor) 

1983 assert isinstance(cached_process_api.parser_api, CachedTemplateParserProxy) 

1984 start_ci = cached_process_api.parser_api._to_ttree.cache_info() 

1985 tnode1 = process_api.parser_api.to_ttree(sample_t).root 

1986 tnode2 = process_api.parser_api.to_ttree(sample_t).root 

1987 cached_tnode1 = cached_process_api.parser_api.to_ttree(sample_t).root 

1988 cached_tnode2 = cached_process_api.parser_api.to_ttree(sample_t).root 

1989 cached_tnode3 = cached_process_api.parser_api.to_ttree(sample_diff_t).root 

1990 # Check that the uncached and cached services are actually 

1991 # returning non-identical results. 

1992 assert tnode1 is not cached_tnode1 

1993 assert tnode1 is not cached_tnode2 

1994 assert tnode1 is not cached_tnode3 

1995 # Check that the uncached service returns a brand new result everytime. 

1996 assert tnode1 is not tnode2 

1997 # Check that the cached service is returning the exact same, identical, result. 

1998 assert cached_tnode1 is cached_tnode2 

1999 # Even if the input templates are not identical (but are still equivalent). 

2000 assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t 

2001 # Check that the cached service and uncached services return 

2002 # results that are equivalent (even though they are not (id)entical). 

2003 assert tnode1 == cached_tnode1 

2004 assert tnode2 == cached_tnode1 

2005 # Now that we are setup we check that the cache is internally 

2006 # working as we intended. 

2007 ci = cached_process_api.parser_api._to_ttree.cache_info() 

2008 # cached_tnode2 and cached_tnode3 are hits after cached_tnode1 

2009 assert ci.hits - start_ci.hits == 2 

2010 # cached_tf1 was a miss because cache was empty (brand new) 

2011 assert ci.misses - start_ci.misses == 1 

2012 cached_tnode4 = cached_process_api.parser_api.to_ttree(alt_t).root 

2013 # A different template produces a brand new tf. 

2014 assert cached_tnode1 is not cached_tnode4 

2015 # The template is new AND has a different structure so it also 

2016 # produces an unequivalent tf. 

2017 assert cached_tnode1 != cached_tnode4 

2018 

2019 

2020def test_cached_component_span_extracts_current_interpolations(): 

2021 def Component(children): 

2022 return children 

2023 

2024 def make_template(child): 

2025 return t"<{Component}><tdom-span-cache>{child}</tdom-span-cache></{Component}>" 

2026 

2027 process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy()) 

2028 

2029 assert process_api.process(make_template("first"), ProcessContext()) == ( 

2030 "<tdom-span-cache>first</tdom-span-cache>" 

2031 ) 

2032 assert process_api.process(make_template("second"), ProcessContext()) == ( 

2033 "<tdom-span-cache>second</tdom-span-cache>" 

2034 ) 

2035 

2036 

2037def test_repeat_calls(): 

2038 """Crude check for any unintended state being kept between calls.""" 

2039 

2040 def get_sample_t(idx, spread_attrs, button_text): 

2041 return t"""<div><button data-key={idx} {spread_attrs}>{button_text}</button></div>""" 

2042 

2043 for idx in range(3): 

2044 spread_attrs = {"data-enabled": True} 

2045 button_text = "PROCESS" 

2046 sample_t = get_sample_t(idx, spread_attrs, button_text) 

2047 assert ( 

2048 html(sample_t) 

2049 == f'<div><button data-key="{idx}" data-enabled>PROCESS</button></div>' 

2050 ) 

2051 

2052 

2053def get_select_t_with_list(options, selected_values): 

2054 return t"""<select>{ 

2055 [ 

2056 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>" 

2057 for opt in options 

2058 ] 

2059 }</select>""" 

2060 

2061 

2062def get_select_t_with_generator(options, selected_values): 

2063 return t"""<select>{ 

2064 ( 

2065 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>" 

2066 for opt in options 

2067 ) 

2068 }</select>""" 

2069 

2070 

2071def get_select_t_with_concat(options, selected_values): 

2072 parts = [t"<select>"] 

2073 parts.extend( 

2074 [ 

2075 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>" 

2076 for opt in options 

2077 ] 

2078 ) 

2079 parts.append(t"</select>") 

2080 return sum(parts, t"") 

2081 

2082 

2083@pytest.mark.parametrize( 

2084 "provider", 

2085 ( 

2086 get_select_t_with_list, 

2087 get_select_t_with_generator, 

2088 get_select_t_with_concat, 

2089 ), 

2090) 

2091def test_process_template_iterables(provider): 

2092 def get_color_select_t(selected_values: set, provider: Callable) -> Template: 

2093 PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] 

2094 assert set(selected_values).issubset({opt[0] for opt in PRIMARY_COLORS}) 

2095 return provider(PRIMARY_COLORS, selected_values) 

2096 

2097 no_selection_t = get_color_select_t(set(), provider) 

2098 assert ( 

2099 html(no_selection_t) 

2100 == '<select><option value="R">Red</option><option value="Y">Yellow</option><option value="B">Blue</option></select>' 

2101 ) 

2102 selected_yellow_t = get_color_select_t({"Y"}, provider) 

2103 assert ( 

2104 html(selected_yellow_t) 

2105 == '<select><option value="R">Red</option><option value="Y" selected>Yellow</option><option value="B">Blue</option></select>' 

2106 ) 

2107 

2108 

2109def test_component_integration(): 

2110 """Broadly test that common template component usage works.""" 

2111 

2112 def PageComponent(children, root_attrs=None): 

2113 return t"""<div class="content" {root_attrs}>{children}</div>""" 

2114 

2115 def FooterComponent(classes=("footer-default",)): 

2116 return t'<div class="footer" class={classes}><a href="about">About</a></div>' 

2117 

2118 def LayoutComponent(children, body_classes=None): 

2119 return t"""<!doctype html> 

2120<html> 

2121 <head> 

2122 <meta charset="utf-8"> 

2123 <script src="scripts.js"></script> 

2124 <link rel="stylesheet" href="styles.css"> 

2125 </head> 

2126 <body class={body_classes}> 

2127 {children} 

2128 <{FooterComponent} /> 

2129 </body> 

2130</html> 

2131""" 

2132 

2133 content = "HTML never goes out of style." 

2134 content_str = html( 

2135 t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}</{PageComponent}></{LayoutComponent}>" 

2136 ) 

2137 assert ( 

2138 content_str 

2139 == """<!DOCTYPE html> 

2140<html> 

2141 <head> 

2142 <meta charset="utf-8" /> 

2143 <script src="scripts.js"></script> 

2144 <link rel="stylesheet" href="styles.css" /> 

2145 </head> 

2146 <body class="theme-default"> 

2147 <div class="content">HTML never goes out of style.</div> 

2148 <div class="footer footer-default"><a href="about">About</a></div> 

2149 </body> 

2150</html> 

2151""" 

2152 ) 

2153 

2154 

2155class TestInterpolatingHTMLInTemplateWithDynamicParentTag: 

2156 """ 

2157 When a template does not have a parent tag we cannot determine the type 

2158 of text that should be allowed and therefore we cannot determine how to 

2159 escape that text. Once the type is known we should escape any 

2160 interpolations in that text correctly. 

2161 """ 

2162 

2163 def test_dynamic_raw_text(self): 

2164 """Type raw text should fail because template is already not allowed.""" 

2165 content = '<script>console.log("123!");</script>' 

2166 content_t = t"{content}" 

2167 content_t = t'<script>console.log("{123}!");</script>' 

2168 with pytest.raises( 

2169 ValueError, match="Recursive includes are not supported within script" 

2170 ): 

2171 _ = html(t"<script>{content_t}</script>") 

2172 

2173 def test_dynamic_escapable_raw_text(self): 

2174 """Type escapable raw text should fail because template is already not allowed.""" 

2175 content = '<script>console.log("123!");</script>' 

2176 content_t = t"{content}" 

2177 with pytest.raises( 

2178 ValueError, match="Recursive includes are not supported within textarea" 

2179 ): 

2180 _ = html(t"<textarea>{content_t}</textarea>") 

2181 

2182 def test_dynamic_normal_text(self): 

2183 """Escaping should be applied when normal text type is goes into effect.""" 

2184 content = '<script>console.log("123!");</script>' 

2185 content_t = t"{content}" 

2186 LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) 

2187 assert ( 

2188 html(t"<div>{content_t}</div>") 

2189 == f"<div>{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}</div>" 

2190 ) 

2191 

2192 

2193class TestPagerComponentExample: 

2194 @dataclass 

2195 class Pager: 

2196 left_pages: tuple = () 

2197 page: int = 0 

2198 right_pages: tuple = () 

2199 prev_page: int | None = None 

2200 next_page: int | None = None 

2201 

2202 @dataclass 

2203 class PagerDisplay: 

2204 pager: TestPagerComponentExample.Pager 

2205 paginate_url: Callable[[int], str] 

2206 root_classes: tuple[str, ...] = ("cb", "tc", "w-100") 

2207 part_classes: tuple[str, ...] = ("dib", "pa1") 

2208 

2209 def __call__(self) -> Template: 

2210 parts = [t"<div class={self.root_classes}>"] 

2211 if self.pager.prev_page: 

2212 parts.append( 

2213 t"<a class={self.part_classes} href={self.paginate_url(self.pager.prev_page)}>Prev</a>" 

2214 ) 

2215 for left_page in self.pager.left_pages: 

2216 parts.append( 

2217 t'<a class={self.part_classes} href="{self.paginate_url(left_page)}">{left_page}</a>' 

2218 ) 

2219 parts.append(t"<span class={self.part_classes}>{self.pager.page}</span>") 

2220 for right_page in self.pager.right_pages: 

2221 parts.append( 

2222 t'<a class={self.part_classes} href="{self.paginate_url(right_page)}">{right_page}</a>' 

2223 ) 

2224 if self.pager.next_page: 

2225 parts.append( 

2226 t"<a class={self.part_classes} href={self.paginate_url(self.pager.next_page)}>Next</a>" 

2227 ) 

2228 parts.append(t"</div>") 

2229 return Template(*chain.from_iterable(parts)) 

2230 

2231 def test_example(self): 

2232 def paginate_url(page: int) -> str: 

2233 return f"/pages?page={page}" 

2234 

2235 def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: 

2236 return t"<div class={footer_classes}><{self.PagerDisplay} pager={pager} paginate_url={paginate_url} /></div>" 

2237 

2238 pager = self.Pager( 

2239 left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None 

2240 ) 

2241 content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />" 

2242 res = html(content_t) 

2243 print(res) 

2244 assert ( 

2245 res 

2246 == '<div class="footer"><div class="cb tc w-100"><a class="dib pa1" href="/pages?page=1">1</a><a class="dib pa1" href="/pages?page=2">2</a><span class="dib pa1">3</span><a class="dib pa1" href="/pages?page=4">4</a><a class="dib pa1" href="/pages?page=5">5</a><a class="dib pa1" href="/pages?page=6">Next</a></div></div>' 

2247 ) 

2248 

2249 

2250def test_mathml(): 

2251 num = 1 

2252 denom = 3 

2253 mathml_t = t"""<p> 

2254 The fraction 

2255 <math> 

2256 <mfrac> 

2257 <mn>{num}</mn> 

2258 <mn>{denom}</mn> 

2259 </mfrac> 

2260 </math> 

2261 is not a decimal number. 

2262</p>""" 

2263 res = html(mathml_t) 

2264 assert ( 

2265 str(res) 

2266 == """<p> 

2267 The fraction 

2268 <math> 

2269 <mfrac> 

2270 <mn>1</mn> 

2271 <mn>3</mn> 

2272 </mfrac> 

2273 </math> 

2274 is not a decimal number. 

2275</p>""" 

2276 ) 

2277 

2278 

2279def test_issue_166(): 

2280 template = t"<button disabled={True}>x</button><button disabled={True}>y</button>" 

2281 expected = "<button disabled>x</button><button disabled>y</button>" 

2282 assert html(template) == expected