Coverage for tdom / processor_test.py: 99%

1055 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-03 21:23 +0000

1import datetime 

2import typing as t 

3from collections.abc import Callable 

4from dataclasses import dataclass 

5from itertools import chain, product 

6from string.templatelib import Template 

7 

8import pytest 

9from markupsafe import Markup 

10from markupsafe import escape as markupsafe_escape 

11 

12from .callables import get_callable_info 

13from .escaping import escape_html_text 

14from .processor import ( 

15 CachedTemplateParserProxy, 

16 ProcessContext, 

17 TemplateParserProxy, 

18 TemplateProcessor, 

19 _make_default_template_processor, 

20) 

21from .processor import ( 

22 _prep_component_kwargs as prep_component_kwargs, 

23) 

24from .protocols import HasHTMLDunder 

25 

26processor_api = _make_default_template_processor( 

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

28) 

29 

30 

31def make_ctx(**kwargs): 

32 return ProcessContext(**kwargs) 

33 

34 

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

36 if assume_ctx is None: 

37 assume_ctx = ProcessContext() 

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

39 

40 

41# -------------------------------------------------------------------------- 

42# Basic HTML parsing tests 

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

44 

45 

46# 

47# Text 

48# 

49class TestBareTemplate: 

50 def test_empty(self): 

51 assert html(t"") == "" 

52 

53 def test_text_literal(self): 

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

55 

56 def test_text_singleton(self): 

57 greeting = "Hello, Alice!" 

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

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

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

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

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

63 

64 def test_text_singleton_without_parent(self): 

65 greeting = "</script>" 

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

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

68 assert res != greeting 

69 

70 def test_text_singleton_explicit_parent_script(self): 

71 greeting = "</script>" 

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

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

74 assert res != "</script>" 

75 

76 def test_text_singleton_explicit_parent_div(self): 

77 greeting = "</div>" 

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

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

80 assert res != "</div>" 

81 

82 def test_text_template(self): 

83 name = "Alice" 

84 assert ( 

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

86 == "Hello, Alice!" 

87 ) 

88 

89 def test_text_template_escaping(self): 

90 name = "Alice & Bob" 

91 assert ( 

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

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

94 ) 

95 

96 def test_parse_entities_are_escaped_no_parent_tag(self): 

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

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

99 

100 

101class LiteralHTML: 

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

103 

104 def __init__(self, text): 

105 self.text = text 

106 

107 def __html__(self): 

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

109 return self.text 

110 

111 

112def test_literal_html_has_html_dunder(): 

113 assert isinstance(LiteralHTML, HasHTMLDunder) 

114 

115 

116def test_markup_has_html_dunder(): 

117 assert isinstance(Markup, HasHTMLDunder) 

118 

119 

120class TestComment: 

121 def test_literal(self): 

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

123 

124 # 

125 # Singleton / Exact Match 

126 # 

127 def test_singleton_str(self): 

128 text = "This is a comment" 

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

130 

131 def test_singleton_object(self): 

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

133 

134 def test_singleton_none(self): 

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

136 

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

138 def test_singleton_bool(self, bool_value): 

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

140 

141 @pytest.mark.parametrize( 

142 "html_dunder_cls", 

143 ( 

144 LiteralHTML, 

145 Markup, 

146 ), 

147 ) 

148 def test_singleton_has_html_dunder(self, html_dunder_cls): 

149 content = html_dunder_cls("-->") 

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

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

152 ) 

153 

154 def test_singleton_escaping(self): 

155 text = "-->comment" 

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

157 

158 # 

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

160 # 

161 def test_templated_str(self): 

162 text = "comment" 

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

164 

165 def test_templated_object(self): 

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

167 

168 def test_templated_none(self): 

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

170 

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

172 def test_templated_bool(self, bool_value): 

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

174 

175 @pytest.mark.parametrize( 

176 "html_dunder_cls", 

177 ( 

178 LiteralHTML, 

179 Markup, 

180 ), 

181 ) 

182 def test_templated_has_html_dunder_error(self, html_dunder_cls): 

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

184 text = html_dunder_cls("in a comment") 

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

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

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

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

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

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

191 

192 def test_templated_multiple_interpolations(self): 

193 text = "comment" 

194 assert ( 

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

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

197 ) 

198 

199 def test_templated_escaping(self): 

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

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

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

203 # might be something better for this. 

204 text = "-->comment" 

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

206 

207 def test_not_supported__recursive_template_error(self): 

208 text_t = t"comment" 

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

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

211 

212 def test_not_supported_recursive_iterable_error(self): 

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

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

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

216 

217 

218class TestDocumentType: 

219 def test_literal(self): 

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

221 

222 def test_literal_lowercase(self): 

223 tp = TemplateProcessor(uppercase_doctype=False) 

224 assert ( 

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

226 == "<!doctype html>" 

227 ) 

228 

229 

230class TestVoidElementLiteral: 

231 def test_void(self): 

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

233 

234 def test_void_self_closed(self): 

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

236 

237 def test_void_mixed_closing(self): 

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

239 

240 def test_chain_of_void_elements(self): 

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

242 assert ( 

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

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

245 ) 

246 

247 

248class TestNormalTextElementLiteral: 

249 def test_empty(self): 

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

251 

252 def test_with_text(self): 

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

254 

255 def test_nested_elements(self): 

256 assert ( 

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

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

259 ) 

260 

261 def test_entities_are_escaped(self): 

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

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

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

265 

266 

267class TestNormalTextElementDynamic: 

268 def test_singleton_None(self): 

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

270 

271 def test_singleton_str(self): 

272 name = "Alice" 

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

274 

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

276 def test_singleton_bool(self, bool_value): 

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

278 

279 def test_singleton_object(self): 

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

281 

282 @pytest.mark.parametrize( 

283 "html_dunder_cls", 

284 ( 

285 LiteralHTML, 

286 Markup, 

287 ), 

288 ) 

289 def test_singleton_has_html_dunder(self, html_dunder_cls): 

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

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

292 

293 def test_singleton_simple_template(self): 

294 name = "Alice" 

295 text_t = t"Hi {name}" 

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

297 

298 def test_singleton_simple_iterable(self): 

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

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

301 

302 def test_singleton_escaping(self): 

303 text = '''<>&'"''' 

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

305 

306 def test_templated_None(self): 

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

308 

309 def test_templated_str(self): 

310 name = "Alice" 

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

312 

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

314 def test_templated_bool(self, bool_value): 

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

316 

317 def test_templated_object(self): 

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

319 

320 @pytest.mark.parametrize( 

321 "html_dunder_cls", 

322 ( 

323 LiteralHTML, 

324 Markup, 

325 ), 

326 ) 

327 def test_templated_has_html_dunder(self, html_dunder_cls): 

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

329 assert ( 

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

331 ) 

332 

333 def test_templated_simple_template(self): 

334 name = "Alice" 

335 text_t = t"Hi {name}" 

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

337 

338 def test_templated_simple_iterable(self): 

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

340 assert ( 

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

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

343 ) 

344 

345 def test_templated_escaping(self): 

346 text = '''<>&'"''' 

347 assert ( 

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

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

350 ) 

351 

352 def test_templated_escaping_in_literals(self): 

353 text = "This text is fine" 

354 assert ( 

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

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

357 ) 

358 

359 def test_iterable_of_templates(self): 

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

361 assert ( 

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

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

364 ) 

365 

366 def test_iterable_of_templates_of_iterable_of_templates(self): 

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

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

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

370 outer_items = [ 

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

372 ] 

373 assert ( 

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

375 == "<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>" 

376 ) 

377 

378 

379class TestRawTextElementLiteral: 

380 def test_script_empty(self): 

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

382 

383 def test_style_empty(self): 

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

385 

386 def test_script_with_content(self): 

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

388 

389 def test_style_with_content(self): 

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

391 assert ( 

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

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

394 ) 

395 

396 def test_script_with_content_escaped_in_normal_text(self): 

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

398 assert ( 

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

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

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

402 

403 def test_style_with_content_escaped_in_normal_text(self): 

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

405 assert ( 

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

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

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

409 

410 def test_not_supported_recursive_template_error(self): 

411 text_t = t"comment" 

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

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

414 

415 def test_not_supported_recursive_iterable_error(self): 

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

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

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

419 

420 

421class TestEscapableRawTextElementLiteral: 

422 def test_title_empty(self): 

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

424 

425 def test_textarea_empty(self): 

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

427 

428 def test_title_with_content(self): 

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

430 

431 def test_textarea_with_content(self): 

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

433 

434 def test_title_with_escapable_content(self): 

435 assert ( 

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

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

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

439 

440 def test_textarea_with_escapable_content(self): 

441 assert ( 

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

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

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

445 

446 

447class TestRawTextScriptDynamic: 

448 def test_singleton_none(self): 

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

450 

451 def test_singleton_str(self): 

452 content = "var x = 1;" 

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

454 

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

456 def test_singleton_bool(self, bool_value): 

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

458 

459 def test_singleton_object(self): 

460 content = 0 

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

462 

463 @pytest.mark.parametrize( 

464 "html_dunder_cls", 

465 ( 

466 LiteralHTML, 

467 Markup, 

468 ), 

469 ) 

470 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

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

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

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

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

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

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

477 ) 

478 

479 def test_singleton_escaping(self): 

480 content = "</script>" 

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

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

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

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

485 

486 def test_templated_none(self): 

487 assert ( 

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

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

490 ) 

491 

492 def test_templated_str(self): 

493 content = "var x = 1" 

494 assert ( 

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

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

497 ) 

498 

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

500 def test_templated_bool(self, bool_value): 

501 assert ( 

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

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

504 ) 

505 

506 def test_templated_object(self): 

507 content = 0 

508 assert ( 

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

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

511 ) 

512 

513 @pytest.mark.parametrize( 

514 "html_dunder_cls", 

515 ( 

516 LiteralHTML, 

517 Markup, 

518 ), 

519 ) 

520 def test_templated_has_html_dunder(self, html_dunder_cls): 

521 content = html_dunder_cls("anything") 

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

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

524 

525 def test_templated_escaping(self): 

526 content = "</script>" 

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

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

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

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

531 

532 def test_templated_multiple_interpolations(self): 

533 assert ( 

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

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

536 ) 

537 

538 def test_not_supported_recursive_template_error(self): 

539 text_t = t"script" 

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

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

542 

543 def test_not_supported_recursive_iterable_error(self): 

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

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

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

547 

548 

549class TestRawTextStyleDynamic: 

550 def test_singleton_none(self): 

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

552 

553 def test_singleton_str(self): 

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

555 assert ( 

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

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

558 ) 

559 

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

561 def test_singleton_bool(self, bool_value): 

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

563 

564 def test_singleton_object(self): 

565 content = 0 

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

567 

568 @pytest.mark.parametrize( 

569 "html_dunder_cls", 

570 ( 

571 LiteralHTML, 

572 Markup, 

573 ), 

574 ) 

575 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

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

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

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

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

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

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

582 ) 

583 

584 def test_singleton_escaping(self): 

585 content = "</style>" 

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

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

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

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

590 

591 def test_templated_none(self): 

592 assert ( 

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

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

595 ) 

596 

597 def test_templated_str(self): 

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

599 assert ( 

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

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

602 ) 

603 

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

605 def test_templated_bool(self, bool_value): 

606 assert ( 

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

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

609 ) 

610 

611 def test_templated_object(self): 

612 padding_right = 0 

613 assert ( 

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

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

616 ) 

617 

618 @pytest.mark.parametrize( 

619 "html_dunder_cls", 

620 ( 

621 LiteralHTML, 

622 Markup, 

623 ), 

624 ) 

625 def test_templated_has_html_dunder(self, html_dunder_cls): 

626 content = html_dunder_cls("anything") 

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

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

629 

630 def test_templated_escaping(self): 

631 content = "</style>" 

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

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

634 assert ( 

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

636 ) 

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

638 

639 def test_templated_multiple_interpolations(self): 

640 assert ( 

641 html( 

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

643 ) 

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

645 ) 

646 

647 def test_exact_not_supported_recursive_template_error(self): 

648 text_t = t"style" 

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

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

651 

652 def test_inexact_not_supported_recursive_template_error(self): 

653 text_t = t"style" 

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

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

656 

657 def test_exact_not_supported_recursive_iterable_error(self): 

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

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

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

661 

662 def test_inexact_not_supported_recursive_iterable_error(self): 

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

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

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

666 

667 

668class TestEscapableRawTextTitleDynamic: 

669 def test_singleton_none(self): 

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

671 

672 def test_singleton_str(self): 

673 content = "Welcome To TDOM" 

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

675 

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

677 def test_singleton_bool(self, bool_value): 

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

679 

680 def test_singleton_object(self): 

681 content = 0 

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

683 

684 @pytest.mark.parametrize( 

685 "html_dunder_cls", 

686 ( 

687 LiteralHTML, 

688 Markup, 

689 ), 

690 ) 

691 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

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

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

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

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

696 ) 

697 

698 def test_singleton_escaping(self): 

699 content = "</title>" 

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

701 

702 def test_templated_none(self): 

703 assert ( 

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

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

706 ) 

707 

708 def test_templated_str(self): 

709 content = "TDOM" 

710 assert ( 

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

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

713 ) 

714 

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

716 def test_templated_bool(self, bool_value): 

717 assert ( 

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

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

720 ) 

721 

722 def test_templated_object(self): 

723 content = 0 

724 assert ( 

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

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

727 ) 

728 

729 @pytest.mark.parametrize( 

730 "html_dunder_cls", 

731 ( 

732 LiteralHTML, 

733 Markup, 

734 ), 

735 ) 

736 def test_templated_has_html_dunder(self, html_dunder_cls): 

737 content = html_dunder_cls("No") 

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

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

740 

741 def test_templated_escaping(self): 

742 content = "</title>" 

743 assert ( 

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

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

746 ) 

747 

748 def test_templated_multiple_interpolations(self): 

749 assert ( 

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

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

752 ) 

753 

754 def test_exact_not_supported_recursive_template_error(self): 

755 text_t = t"title" 

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

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

758 

759 def test_exact_not_supported_recursive_iterable_error(self): 

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

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

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

763 

764 def test_inexact_not_supported_recursive_template_error(self): 

765 text_t = t"title" 

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

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

768 

769 def test_inexact_not_supported_recursive_iterable_error(self): 

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

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

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

773 

774 

775class TestEscapableRawTextTextareaDynamic: 

776 def test_singleton_none(self): 

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

778 

779 def test_singleton_str(self): 

780 content = "Welcome To TDOM" 

781 assert ( 

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

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

784 ) 

785 

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

787 def test_singleton_bool(self, bool_value): 

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

789 

790 def test_singleton_object(self): 

791 content = 0 

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

793 

794 @pytest.mark.parametrize( 

795 "html_dunder_cls", 

796 ( 

797 LiteralHTML, 

798 Markup, 

799 ), 

800 ) 

801 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

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

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

804 assert ( 

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

806 == "<textarea></textarea></textarea>" 

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

808 

809 def test_singleton_escaping(self): 

810 content = "</textarea>" 

811 assert ( 

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

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

814 ) 

815 

816 def test_templated_none(self): 

817 assert ( 

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

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

820 ) 

821 

822 def test_templated_str(self): 

823 content = "TDOM" 

824 assert ( 

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

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

827 ) 

828 

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

830 def test_templated_bool(self, bool_value): 

831 assert ( 

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

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

834 ) 

835 

836 def test_templated_object(self): 

837 content = 0 

838 assert ( 

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

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

841 ) 

842 

843 @pytest.mark.parametrize( 

844 "html_dunder_cls", 

845 ( 

846 LiteralHTML, 

847 Markup, 

848 ), 

849 ) 

850 def test_templated_has_html_dunder(self, html_dunder_cls): 

851 content = html_dunder_cls("No") 

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

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

854 

855 def test_templated_multiple_interpolations(self): 

856 assert ( 

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

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

859 ) 

860 

861 def test_templated_escaping(self): 

862 content = "</textarea>" 

863 assert ( 

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

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

866 ) 

867 

868 def test_not_supported_recursive_template_error(self): 

869 text_t = t"textarea" 

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

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

872 

873 def test_not_supported_recursive_iterable_error(self): 

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

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

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

877 

878 

879class Convertible: 

880 def __str__(self): 

881 return "string" 

882 

883 def __repr__(self): 

884 return "repr" 

885 

886 

887def test_convertible_fixture(): 

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

889 c = Convertible() 

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

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

892 

893 

894def wrap_template_in_tags( 

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

896): 

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

898 if end_tag is None: 

899 end_tag = start_tag 

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

901 

902 

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

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

905 if end_tag is None: 

906 end_tag = start_tag 

907 # Stringify to flatten `Markup()` 

908 content = str(content) 

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

910 

911 

912class TestInterpolationConversion: 

913 def test_str(self): 

914 c = Convertible() 

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

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

917 tag, "string" 

918 ) 

919 

920 def test_repr(self): 

921 c = Convertible() 

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

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

924 tag, "repr" 

925 ) 

926 

927 def test_ascii_raw_text(self): 

928 # single quotes are not escaped in raw text 

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

930 "script", ascii("😊") 

931 ) 

932 

933 def test_ascii_escapable_normal_and_raw(self): 

934 # single quotes are escaped 

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

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

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

938 ) 

939 

940 

941class TestInterpolationFormatSpec: 

942 def test_normal_text_safe(self): 

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

944 assert ( 

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

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

947 ) 

948 

949 def test_raw_text_safe(self): 

950 # @TODO: What should even happen here? 

951 raw_content = "</script>" 

952 assert ( 

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

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

955 

956 def test_escapable_raw_text_safe(self): 

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

958 assert ( 

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

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

961 ) 

962 

963 def test_normal_text_unsafe(self): 

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

965 assert ( 

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

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

968 ) 

969 

970 def test_raw_text_unsafe(self): 

971 # @TODO: What should even happen here? 

972 supposedly_safe = "</script>" 

973 assert ( 

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

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

976 ) 

977 assert ( 

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

979 != "<script></script></script>" 

980 ) # Sanity check 

981 

982 def test_escapable_raw_text_unsafe(self): 

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

984 assert ( 

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

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

987 ) 

988 

989 def test_all_text_callback(self): 

990 def get_value(): 

991 return "dynamic" 

992 

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

994 assert ( 

995 html( 

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

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

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

999 ) 

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

1001 ) 

1002 

1003 def test_callback_nonzero_callable_error(self): 

1004 def add(a, b): 

1005 return a + b 

1006 

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

1008 

1009 with pytest.raises(TypeError): 

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

1011 _ = html( 

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

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

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

1015 ) 

1016 

1017 

1018# -------------------------------------------------------------------------- 

1019# Conditional rendering and control flow 

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

1021 

1022 

1023class TestUsagePatterns: 

1024 def test_conditional_rendering_with_if_else(self): 

1025 is_logged_in = True 

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

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

1028 assert ( 

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

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

1031 ) 

1032 

1033 is_logged_in = False 

1034 assert ( 

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

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

1037 ) 

1038 

1039 

1040# -------------------------------------------------------------------------- 

1041# Attributes 

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

1043class TestLiteralAttribute: 

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

1045 

1046 def test_literal_attrs(self): 

1047 assert ( 

1048 html( 

1049 t"<a " 

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

1051 t" autofocus" # bare / boolean 

1052 t' title=""' # empty attribute 

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

1054 t"></a>" 

1055 ) 

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

1057 ) 

1058 

1059 def test_literal_attr_escaped(self): 

1060 assert ( 

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

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

1063 ) 

1064 

1065 

1066class TestInterpolatedAttribute: 

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

1068 

1069 def test_interpolated_attr(self): 

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

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

1072 

1073 def test_interpolated_attr_escaped(self): 

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

1075 assert ( 

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

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

1078 ) 

1079 

1080 def test_interpolated_attr_unquoted(self): 

1081 id = "roquefort" 

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

1083 

1084 def test_interpolated_attr_true(self): 

1085 disabled = True 

1086 assert ( 

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

1088 == "<button disabled></button>" 

1089 ) 

1090 

1091 def test_interpolated_attr_false(self): 

1092 disabled = False 

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

1094 

1095 def test_interpolated_attr_none(self): 

1096 disabled = None 

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

1098 

1099 def test_interpolate_attr_empty_string(self): 

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

1101 

1102 

1103class TestSpreadAttribute: 

1104 """Test spread attributes.""" 

1105 

1106 def test_spread_attr(self): 

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

1108 assert ( 

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

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

1111 ) 

1112 

1113 def test_spread_attr_none(self): 

1114 attrs = None 

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

1116 

1117 def test_spread_attr_type_errors(self): 

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

1119 with pytest.raises(TypeError): 

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

1121 

1122 

1123class TestTemplatedAttribute: 

1124 def test_templated_attr_mixed_interpolations_start_end_and_nest(self): 

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

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

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

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

1129 for left_part, middle_part, right_part in product( 

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

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

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

1133 ): 

1134 test_t = ( 

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

1136 ) 

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

1138 

1139 def test_templated_attr_no_quotes(self): 

1140 start = 1 

1141 end = 5 

1142 assert ( 

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

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

1145 ) 

1146 

1147 

1148class TestAttributeMerging: 

1149 def test_attr_merge_disjoint_interpolated_attr_spread_attr(self): 

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

1151 target = "_blank" 

1152 assert ( 

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

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

1155 ) 

1156 

1157 def test_attr_merge_overlapping_spread_attrs(self): 

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

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

1160 assert ( 

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

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

1163 ) 

1164 

1165 def test_attr_merge_replace_literal_attr_str_str(self): 

1166 assert ( 

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

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

1169 ) 

1170 

1171 def test_attr_merge_replace_literal_attr_str_true(self): 

1172 assert ( 

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

1174 == "<div title></div>" 

1175 ) 

1176 

1177 def test_attr_merge_replace_literal_attr_true_str(self): 

1178 assert ( 

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

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

1181 ) 

1182 

1183 def test_attr_merge_remove_literal_attr_str_none(self): 

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

1185 

1186 def test_attr_merge_remove_literal_attr_true_none(self): 

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

1188 

1189 def test_attr_merge_other_literal_attr_intact(self): 

1190 assert ( 

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

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

1193 ) 

1194 

1195 

1196class TestSpecialDataAttribute: 

1197 """Special data attribute handling.""" 

1198 

1199 def test_interpolated_data_attributes(self): 

1200 data = { 

1201 "user-id": 123, 

1202 "role": "admin", 

1203 "wild": True, 

1204 "false": False, 

1205 "none": None, 

1206 } 

1207 assert ( 

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

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

1210 ) 

1211 

1212 def test_data_attr_toggle_to_str(self): 

1213 for res in [ 

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

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

1216 ]: 

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

1218 

1219 def test_data_attr_toggle_to_true(self): 

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

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

1222 

1223 def test_data_attr_unrelated_unaffected(self): 

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

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

1226 

1227 def test_data_attr_templated_error(self): 

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

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

1230 with pytest.raises(TypeError): 

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

1232 

1233 def test_data_attr_none(self): 

1234 button_data = None 

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

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

1237 

1238 def test_data_attr_errors(self): 

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

1240 with pytest.raises(TypeError): 

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

1242 

1243 def test_data_literal_attr_bypass(self): 

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

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

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

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

1248 ) 

1249 

1250 

1251class TestSpecialAriaAttribute: 

1252 """Special aria attribute handling.""" 

1253 

1254 def test_aria_templated_attr_error(self): 

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

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

1257 with pytest.raises(TypeError): 

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

1259 

1260 def test_aria_interpolated_attr_dict(self): 

1261 aria = {"label": "Close", "hidden": True, "another": False, "more": None} 

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

1263 assert ( 

1264 res 

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

1266 ) 

1267 

1268 def test_aria_interpolate_attr_none(self): 

1269 button_aria = None 

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

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

1272 

1273 def test_aria_attr_errors(self): 

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

1275 with pytest.raises(TypeError): 

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

1277 

1278 def test_aria_literal_attr_bypass(self): 

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

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

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

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

1283 ) 

1284 

1285 

1286class TestSpecialClassAttribute: 

1287 """Special class attribute handling.""" 

1288 

1289 def test_interpolated_class_attribute(self): 

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

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

1292 class_str = "blue" 

1293 class_space_sep_str = "green yellow" 

1294 class_none = None 

1295 class_empty_list = [] 

1296 class_empty_dict = {} 

1297 button_t = ( 

1298 t"<button " 

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

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

1301 t" class={class_none}" # ignored 

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

1303 t" >Click me</button>" 

1304 ) 

1305 res = html(button_t) 

1306 assert ( 

1307 res 

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

1309 ) 

1310 

1311 def test_interpolated_class_attribute_with_multiple_placeholders(self): 

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

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

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

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

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

1317 assert ( 

1318 res 

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

1320 ), ( 

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

1322 ) 

1323 

1324 def test_interpolated_attribute_spread_with_class_attribute(self): 

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

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

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

1328 

1329 def test_class_literal_attr_bypass(self): 

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

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

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

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

1334 ) 

1335 

1336 def test_class_none_ignored(self): 

1337 class_item = None 

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

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

1340 # Also ignored inside a sequence. 

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

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

1343 

1344 def test_class_type_errors(self): 

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

1346 with pytest.raises(TypeError): 

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

1348 with pytest.raises(TypeError): 

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

1350 

1351 def test_class_merge_literals(self): 

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

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

1354 

1355 def test_class_merge_literal_then_interpolation(self): 

1356 class_item = "blue" 

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

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

1359 

1360 

1361class TestSpecialStyleAttribute: 

1362 """Special style attribute handling.""" 

1363 

1364 def test_style_literal_attr_passthru(self): 

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

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

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

1368 

1369 def test_style_in_interpolated_attr(self): 

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

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

1372 assert ( 

1373 res 

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

1375 ) 

1376 

1377 def test_style_in_templated_attr(self): 

1378 color = "red" 

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

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

1381 

1382 def test_style_in_spread_attr(self): 

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

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

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

1386 

1387 def test_style_merged_from_all_attrs(self): 

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

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

1390 color = "red" 

1391 res = html( 

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

1393 ) 

1394 assert ( 

1395 res 

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

1397 ) 

1398 

1399 def test_style_override_left_to_right(self): 

1400 suffix = t"></p>" 

1401 parts = [ 

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

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

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

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

1406 ] 

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

1408 expected_style = parts[index][1] 

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

1410 res = html(t) 

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

1412 

1413 def test_interpolated_style_attribute_multiple_placeholders(self): 

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

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

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

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

1418 # which produces an invalid style attribute. 

1419 with pytest.raises(ValueError): 

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

1421 

1422 def test_interpolated_style_attribute_merged(self): 

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

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

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

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

1427 

1428 def test_interpolated_style_attribute_merged_override(self): 

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

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

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

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

1433 

1434 def test_style_attribute_str(self): 

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

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

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

1438 

1439 def test_style_attribute_non_str_non_dict(self): 

1440 with pytest.raises(TypeError): 

1441 styles = [1, 2] 

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

1443 

1444 def test_style_literal_attr_bypass(self): 

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

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

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

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

1449 ) 

1450 

1451 def test_style_none(self): 

1452 styles = None 

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

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

1455 

1456 

1457class TestPrepComponentKwargs: 

1458 def test_named(self): 

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

1460 pass 

1461 

1462 callable_info = get_callable_info(InputElement) 

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

1464 "size": 20 

1465 } 

1466 assert prep_component_kwargs( 

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

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

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

1470 

1471 @pytest.mark.skip("Should we just ignore unused user-specified kwargs?") 

1472 def test_unused_kwargs(self): 

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

1474 pass 

1475 

1476 callable_info = get_callable_info(InputElement) 

1477 with pytest.raises(ValueError): 

1478 assert ( 

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

1480 ) 

1481 

1482 def test_accepts_children(self): 

1483 def DivWrapper( 

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

1485 ) -> Template: 

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

1487 

1488 callable_info = get_callable_info(DivWrapper) 

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

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

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

1492 "children" 

1493 ].strings == ("",) 

1494 

1495 add_classes = ["red"] 

1496 kwargs = prep_component_kwargs( 

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

1498 ) 

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

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

1501 "children" 

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

1503 assert kwargs["add_classes"] == add_classes 

1504 

1505 def test_no_children(self): 

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

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

1508 

1509 callable_info = get_callable_info(SpanMaker) 

1510 content_text = "inner" 

1511 kwargs = prep_component_kwargs( 

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

1513 ) 

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

1515 

1516 

1517class TestFunctionComponent: 

1518 @staticmethod 

1519 def FunctionComponent( 

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

1521 ) -> Template: 

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

1523 assert isinstance(first, str) 

1524 assert isinstance(second, int) 

1525 assert isinstance(third_arg, str) 

1526 new_attrs = { 

1527 "id": third_arg, 

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

1529 **attrs, 

1530 } 

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

1532 

1533 def test_with_children(self): 

1534 res = html( 

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

1536 ) 

1537 assert ( 

1538 res 

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

1540 ) 

1541 

1542 def test_with_no_children(self): 

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

1544 res = html( 

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

1546 ) 

1547 assert ( 

1548 res 

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

1550 ) 

1551 

1552 def test_missing_props_error(self): 

1553 with pytest.raises(TypeError): 

1554 _ = html( 

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

1556 ) 

1557 

1558 

1559class TestFunctionComponentNoChildren: 

1560 @staticmethod 

1561 def FunctionComponentNoChildren( 

1562 first: str, second: int, third_arg: str 

1563 ) -> Template: 

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

1565 assert isinstance(first, str) 

1566 assert isinstance(second, int) 

1567 assert isinstance(third_arg, str) 

1568 new_attrs = { 

1569 "id": third_arg, 

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

1571 } 

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

1573 

1574 def test_interpolated_template_component_ignore_children(self): 

1575 res = html( 

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

1577 ) 

1578 assert ( 

1579 res 

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

1581 ) 

1582 

1583 

1584class TestFunctionComponentKeywordArgs: 

1585 @staticmethod 

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

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

1588 assert isinstance(first, str) 

1589 assert "children" in attrs 

1590 children = attrs.pop("children") 

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

1592 return t"<div {new_attrs}>Component with kwargs: {children}</div>" 

1593 

1594 def test_children_always_passed_via_kwargs(self): 

1595 res = html( 

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

1597 ) 

1598 assert ( 

1599 res 

1600 == '<div data-first="value" extra="info">Component with kwargs: Child content</div>' 

1601 ) 

1602 

1603 def test_children_always_passed_via_kwargs_even_when_empty(self): 

1604 res = html( 

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

1606 ) 

1607 assert ( 

1608 res == '<div data-first="value" extra="info">Component with kwargs: </div>' 

1609 ) 

1610 

1611 

1612class TestComponentSpecialUsage: 

1613 @staticmethod 

1614 def ColumnsComponent() -> Template: 

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

1616 

1617 def test_fragment_from_component(self): 

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

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

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

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

1622 

1623 def test_component_passed_as_attr_value(self): 

1624 def Wrapper( 

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

1626 ) -> Template: 

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

1628 

1629 res = html( 

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

1631 ) 

1632 assert ( 

1633 res 

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

1635 ) 

1636 

1637 def test_nested_component_gh23(self): 

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

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

1640 def Header() -> Template: 

1641 return t"{'Hello World'}" 

1642 

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

1644 assert res == "Hello World" 

1645 

1646 

1647class TestClassComponent: 

1648 @dataclass 

1649 class ClassComponent: 

1650 """Example class-based component.""" 

1651 

1652 user_name: str 

1653 image_url: str 

1654 children: Template 

1655 homepage: str = "#" 

1656 

1657 def __call__(self) -> Template: 

1658 return ( 

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

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

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

1662 t"</a>" 

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

1664 t"{self.children}" 

1665 t"</div>" 

1666 ) 

1667 

1668 def test_class_component_implicit_invocation_with_children(self): 

1669 res = html( 

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

1671 ) 

1672 assert ( 

1673 res 

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

1675 ) 

1676 

1677 def test_class_component_direct_invocation(self): 

1678 avatar = self.ClassComponent( 

1679 user_name="Alice", 

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

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

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

1683 ) 

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

1685 assert ( 

1686 res 

1687 == '<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>' 

1688 ) 

1689 

1690 @dataclass 

1691 class ClassComponentNoChildren: 

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

1693 

1694 user_name: str 

1695 image_url: str 

1696 homepage: str = "#" 

1697 

1698 def __call__(self) -> Template: 

1699 return ( 

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

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

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

1703 t"</a>" 

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

1705 t"ignore children" 

1706 t"</div>" 

1707 ) 

1708 

1709 def test_implicit_invocation_ignore_children(self): 

1710 res = html( 

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

1712 ) 

1713 assert ( 

1714 res 

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

1716 ) 

1717 

1718 

1719def test_attribute_type_component(): 

1720 def AttributeTypeComponent( 

1721 data_int: int, 

1722 data_true: bool, 

1723 data_false: bool, 

1724 data_none: None, 

1725 data_float: float, 

1726 data_dt: datetime.datetime, 

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

1728 ) -> Template: 

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

1730 assert isinstance(data_int, int) 

1731 assert data_true is True 

1732 assert data_false is False 

1733 assert data_none is None 

1734 assert isinstance(data_float, float) 

1735 assert isinstance(data_dt, datetime.datetime) 

1736 for kw, v_type in [ 

1737 ("spread_true", True), 

1738 ("spread_false", False), 

1739 ("spread_int", int), 

1740 ("spread_none", None), 

1741 ("spread_float", float), 

1742 ("spread_dt", datetime.datetime), 

1743 ("spread_dict", dict), 

1744 ("spread_list", list), 

1745 ]: 

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

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

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

1749 ) 

1750 else: 

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

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

1753 ) 

1754 return t"Looks good!" 

1755 

1756 an_int: int = 42 

1757 a_true: bool = True 

1758 a_false: bool = False 

1759 a_none: None = None 

1760 a_float: float = 3.14 

1761 a_dt: datetime.datetime = datetime.datetime( 

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

1763 ) 

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

1765 "spread_true": True, 

1766 "spread_false": False, 

1767 "spread_none": None, 

1768 "spread_int": 0, 

1769 "spread_float": 0.0, 

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

1771 "spread_dict": {}, 

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

1773 } 

1774 res = html( 

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

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

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

1778 ) 

1779 assert res == "Looks good!" 

1780 

1781 

1782class TestComponentErrors: 

1783 def test_component_non_callable_fails(self): 

1784 with pytest.raises(TypeError): 

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

1786 

1787 def test_component_requiring_positional_arg_fails(self): 

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

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

1790 

1791 with pytest.raises(TypeError): 

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

1793 

1794 def test_mismatched_component_closing_tag_fails(self): 

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

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

1797 

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

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

1800 

1801 with pytest.raises(TypeError): 

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

1803 

1804 @pytest.mark.parametrize( 

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

1806 ) 

1807 def test_function_component_returns_nontemplate_fails(self, bad_value): 

1808 def BadFunctionComp(children: Template): 

1809 return bad_value 

1810 

1811 with pytest.raises( 

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

1813 ): 

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

1815 

1816 @pytest.mark.parametrize( 

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

1818 ) 

1819 def test_component_object_returns_nontemplate_fails(self, bad_value): 

1820 def BadFactoryComp(children: Template): 

1821 def component_object(): 

1822 return bad_value 

1823 

1824 return component_object 

1825 

1826 with pytest.raises( 

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

1828 ): 

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

1830 

1831 

1832def test_integration_basic(): 

1833 comment_text = "comment is not literal" 

1834 interpolated_class = "red" 

1835 text_in_element = "text is not literal" 

1836 templated = "not literal" 

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

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

1839 

1840 def WrapperComponent(children): 

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

1842 

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

1844<html> 

1845<body> 

1846<!-- literal --> 

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

1848<!-- {comment_text} --> 

1849<span>{text_in_element}</span> 

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

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

1852{markup_content} 

1853</body> 

1854</html>""" 

1855 smoke_str = """<!DOCTYPE html> 

1856<html> 

1857<body> 

1858<!-- literal --> 

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

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

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

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

1863<div><span>comp body</span></div> 

1864<div>safe</div> 

1865</body> 

1866</html>""" 

1867 assert html(smoke_t) == smoke_str 

1868 

1869 

1870def struct_repr(st): 

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

1872 return st.strings, tuple( 

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

1874 ) 

1875 

1876 

1877def test_process_template_internal_cache(): 

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

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

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

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

1882 # the first miss will instead be a hit. 

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

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

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

1886 process_api = TemplateProcessor(parser_api=TemplateParserProxy()) 

1887 cached_process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy()) 

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

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

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

1891 assert isinstance(cached_process_api, TemplateProcessor) 

1892 assert isinstance(cached_process_api.parser_api, CachedTemplateParserProxy) 

1893 start_ci = cached_process_api.parser_api._to_tnode.cache_info() 

1894 tnode1 = process_api.parser_api.to_tnode(sample_t) 

1895 tnode2 = process_api.parser_api.to_tnode(sample_t) 

1896 cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t) 

1897 cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t) 

1898 cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t) 

1899 # Check that the uncached and cached services are actually 

1900 # returning non-identical results. 

1901 assert tnode1 is not cached_tnode1 

1902 assert tnode1 is not cached_tnode2 

1903 assert tnode1 is not cached_tnode3 

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

1905 assert tnode1 is not tnode2 

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

1907 assert cached_tnode1 is cached_tnode2 

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

1909 assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t 

1910 # Check that the cached service and uncached services return 

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

1912 assert tnode1 == cached_tnode1 

1913 assert tnode2 == cached_tnode1 

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

1915 # working as we intended. 

1916 ci = cached_process_api.parser_api._to_tnode.cache_info() 

1917 # cached_tnode2 and cached_tnode3 are hits after cached_tnode1 

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

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

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

1921 cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t) 

1922 # A different template produces a brand new tf. 

1923 assert cached_tnode1 is not cached_tnode4 

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

1925 # produces an unequivalent tf. 

1926 assert cached_tnode1 != cached_tnode4 

1927 

1928 

1929def test_repeat_calls(): 

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

1931 

1932 def get_sample_t(idx, spread_attrs, button_text): 

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

1934 

1935 for idx in range(3): 

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

1937 button_text = "PROCESS" 

1938 sample_t = get_sample_t(idx, spread_attrs, button_text) 

1939 assert ( 

1940 html(sample_t) 

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

1942 ) 

1943 

1944 

1945def get_select_t_with_list(options, selected_values): 

1946 return t"""<select>{ 

1947 [ 

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

1949 for opt in options 

1950 ] 

1951 }</select>""" 

1952 

1953 

1954def get_select_t_with_generator(options, selected_values): 

1955 return t"""<select>{ 

1956 ( 

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

1958 for opt in options 

1959 ) 

1960 }</select>""" 

1961 

1962 

1963def get_select_t_with_concat(options, selected_values): 

1964 parts = [t"<select>"] 

1965 parts.extend( 

1966 [ 

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

1968 for opt in options 

1969 ] 

1970 ) 

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

1972 return sum(parts, t"") 

1973 

1974 

1975@pytest.mark.parametrize( 

1976 "provider", 

1977 ( 

1978 get_select_t_with_list, 

1979 get_select_t_with_generator, 

1980 get_select_t_with_concat, 

1981 ), 

1982) 

1983def test_process_template_iterables(provider): 

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

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

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

1987 return provider(PRIMARY_COLORS, selected_values) 

1988 

1989 no_selection_t = get_color_select_t(set(), provider) 

1990 assert ( 

1991 html(no_selection_t) 

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

1993 ) 

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

1995 assert ( 

1996 html(selected_yellow_t) 

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

1998 ) 

1999 

2000 

2001def test_component_integration(): 

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

2003 

2004 def PageComponent(children, root_attrs=None): 

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

2006 

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

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

2009 

2010 def LayoutComponent(children, body_classes=None): 

2011 return t"""<!doctype html> 

2012<html> 

2013 <head> 

2014 <meta charset="utf-8"> 

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

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

2017 </head> 

2018 <body class={body_classes}> 

2019 {children} 

2020 <{FooterComponent} /> 

2021 </body> 

2022</html> 

2023""" 

2024 

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

2026 content_str = html( 

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

2028 ) 

2029 assert ( 

2030 content_str 

2031 == """<!DOCTYPE html> 

2032<html> 

2033 <head> 

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

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

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

2037 </head> 

2038 <body class="theme-default"> 

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

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

2041 </body> 

2042</html> 

2043""" 

2044 ) 

2045 

2046 

2047class TestInterpolatingHTMLInTemplateWithDynamicParentTag: 

2048 """ 

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

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

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

2052 interpolations in that text correctly. 

2053 """ 

2054 

2055 def test_dynamic_raw_text(self): 

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

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

2058 content_t = t"{content}" 

2059 with pytest.raises( 

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

2061 ): 

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

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

2064 

2065 def test_dynamic_escapable_raw_text(self): 

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

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

2068 content_t = t"{content}" 

2069 with pytest.raises( 

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

2071 ): 

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

2073 

2074 def test_dynamic_normal_text(self): 

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

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

2077 content_t = t"{content}" 

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

2079 assert ( 

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

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

2082 ) 

2083 

2084 

2085class TestPagerComponentExample: 

2086 @dataclass 

2087 class Pager: 

2088 left_pages: tuple = () 

2089 page: int = 0 

2090 right_pages: tuple = () 

2091 prev_page: int | None = None 

2092 next_page: int | None = None 

2093 

2094 @dataclass 

2095 class PagerDisplay: 

2096 pager: TestPagerComponentExample.Pager 

2097 paginate_url: Callable[[int], str] 

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

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

2100 

2101 def __call__(self) -> Template: 

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

2103 if self.pager.prev_page: 

2104 parts.append( 

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

2106 ) 

2107 for left_page in self.pager.left_pages: 

2108 parts.append( 

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

2110 ) 

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

2112 for right_page in self.pager.right_pages: 

2113 parts.append( 

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

2115 ) 

2116 if self.pager.next_page: 

2117 parts.append( 

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

2119 ) 

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

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

2122 

2123 def test_example(self): 

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

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

2126 

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

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

2129 

2130 pager = self.Pager( 

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

2132 ) 

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

2134 res = html(content_t) 

2135 print(res) 

2136 assert ( 

2137 res 

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

2139 ) 

2140 

2141 

2142def test_mathml(): 

2143 num = 1 

2144 denom = 3 

2145 mathml_t = t"""<p> 

2146 The fraction 

2147 <math> 

2148 <mfrac> 

2149 <mn>{num}</mn> 

2150 <mn>{denom}</mn> 

2151 </mfrac> 

2152 </math> 

2153 is not a decimal number. 

2154</p>""" 

2155 res = html(mathml_t) 

2156 assert ( 

2157 str(res) 

2158 == """<p> 

2159 The fraction 

2160 <math> 

2161 <mfrac> 

2162 <mn>1</mn> 

2163 <mn>3</mn> 

2164 </mfrac> 

2165 </math> 

2166 is not a decimal number. 

2167</p>""" 

2168 )