Coverage for tdom/processor_test.py: 99%

1065 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-23 04:35 +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 TestSpecialAttrMerging: 

1458 """ 

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

1460 location they were updated. 

1461 """ 

1462 

1463 def test_accumulator_order(self): 

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

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

1466 attrs = { 

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

1468 "id": "act_now", # static 

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

1470 "title": "mega", # static 

1471 } 

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

1473 assert ( 

1474 button 

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

1476 ) 

1477 

1478 

1479class TestPrepComponentKwargs: 

1480 def test_named(self): 

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

1482 pass 

1483 

1484 callable_info = get_callable_info(InputElement) 

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

1486 "size": 20 

1487 } 

1488 assert prep_component_kwargs( 

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

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

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

1492 

1493 def test_unused_kwargs(self): 

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

1495 pass 

1496 

1497 callable_info = get_callable_info(InputElement) 

1498 with pytest.raises(ValueError): 

1499 assert ( 

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

1501 ) 

1502 

1503 def test_accepts_children(self): 

1504 def DivWrapper( 

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

1506 ) -> Template: 

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

1508 

1509 callable_info = get_callable_info(DivWrapper) 

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

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

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

1513 "children" 

1514 ].strings == ("",) 

1515 

1516 add_classes = ["red"] 

1517 kwargs = prep_component_kwargs( 

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

1519 ) 

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

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

1522 "children" 

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

1524 assert kwargs["add_classes"] == add_classes 

1525 

1526 def test_no_children(self): 

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

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

1529 

1530 callable_info = get_callable_info(SpanMaker) 

1531 content_text = "inner" 

1532 kwargs = prep_component_kwargs( 

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

1534 ) 

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

1536 

1537 def test_children_attr_error(self): 

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

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

1540 

1541 callable_info = get_callable_info(Comp) 

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

1543 _ = prep_component_kwargs( 

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

1545 ) 

1546 

1547 

1548class TestFunctionComponent: 

1549 @staticmethod 

1550 def FunctionComponent( 

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

1552 ) -> Template: 

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

1554 assert isinstance(first, str) 

1555 assert isinstance(second, int) 

1556 assert isinstance(third_arg, str) 

1557 new_attrs = { 

1558 "id": third_arg, 

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

1560 **attrs, 

1561 } 

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

1563 

1564 def test_with_children(self): 

1565 res = html( 

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

1567 ) 

1568 assert ( 

1569 res 

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

1571 ) 

1572 

1573 def test_with_no_children(self): 

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

1575 res = html( 

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

1577 ) 

1578 assert ( 

1579 res 

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

1581 ) 

1582 

1583 def test_missing_props_error(self): 

1584 with pytest.raises(TypeError): 

1585 _ = html( 

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

1587 ) 

1588 

1589 

1590class TestFunctionComponentNoChildren: 

1591 @staticmethod 

1592 def FunctionComponentNoChildren( 

1593 first: str, second: int, third_arg: str 

1594 ) -> Template: 

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

1596 assert isinstance(first, str) 

1597 assert isinstance(second, int) 

1598 assert isinstance(third_arg, str) 

1599 new_attrs = { 

1600 "id": third_arg, 

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

1602 } 

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

1604 

1605 def test_interpolated_template_component_ignore_children(self): 

1606 res = html( 

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

1608 ) 

1609 assert ( 

1610 res 

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

1612 ) 

1613 

1614 

1615class TestFunctionComponentKeywordArgs: 

1616 @staticmethod 

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

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

1619 assert isinstance(first, str) 

1620 if "children" in attrs: 

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

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

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

1624 

1625 def test_children_not_passed_via_kwargs(self): 

1626 res = html( 

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

1628 ) 

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

1630 

1631 def test_children_not_passed_via_kwargs_even_when_empty(self): 

1632 res = html( 

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

1634 ) 

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

1636 

1637 

1638class TestComponentSpecialUsage: 

1639 @staticmethod 

1640 def ColumnsComponent() -> Template: 

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

1642 

1643 def test_fragment_from_component(self): 

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

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

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

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

1648 

1649 def test_component_passed_as_attr_value(self): 

1650 def Wrapper( 

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

1652 ) -> Template: 

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

1654 

1655 res = html( 

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

1657 ) 

1658 assert ( 

1659 res 

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

1661 ) 

1662 

1663 def test_nested_component_gh23(self): 

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

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

1666 def Header() -> Template: 

1667 return t"{'Hello World'}" 

1668 

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

1670 assert res == "Hello World" 

1671 

1672 

1673class TestClassComponent: 

1674 @dataclass 

1675 class ClassComponent: 

1676 """Example class-based component.""" 

1677 

1678 user_name: str 

1679 image_url: str 

1680 children: Template 

1681 homepage: str = "#" 

1682 

1683 def __call__(self) -> Template: 

1684 return ( 

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

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

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

1688 t"</a>" 

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

1690 t"{self.children}" 

1691 t"</div>" 

1692 ) 

1693 

1694 def test_class_component_implicit_invocation_with_children(self): 

1695 res = html( 

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

1697 ) 

1698 assert ( 

1699 res 

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

1701 ) 

1702 

1703 def test_class_component_direct_invocation(self): 

1704 avatar = self.ClassComponent( 

1705 user_name="Alice", 

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

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

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

1709 ) 

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

1711 assert ( 

1712 res 

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

1714 ) 

1715 

1716 @dataclass 

1717 class ClassComponentNoChildren: 

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

1719 

1720 user_name: str 

1721 image_url: str 

1722 homepage: str = "#" 

1723 

1724 def __call__(self) -> Template: 

1725 return ( 

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

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

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

1729 t"</a>" 

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

1731 t"ignore children" 

1732 t"</div>" 

1733 ) 

1734 

1735 def test_implicit_invocation_ignore_children(self): 

1736 res = html( 

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

1738 ) 

1739 assert ( 

1740 res 

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

1742 ) 

1743 

1744 

1745def test_attribute_type_component(): 

1746 def AttributeTypeComponent( 

1747 data_int: int, 

1748 data_true: bool, 

1749 data_false: bool, 

1750 data_none: None, 

1751 data_float: float, 

1752 data_dt: datetime.datetime, 

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

1754 ) -> Template: 

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

1756 assert isinstance(data_int, int) 

1757 assert data_true is True 

1758 assert data_false is False 

1759 assert data_none is None 

1760 assert isinstance(data_float, float) 

1761 assert isinstance(data_dt, datetime.datetime) 

1762 for kw, v_type in [ 

1763 ("spread_true", True), 

1764 ("spread_false", False), 

1765 ("spread_int", int), 

1766 ("spread_none", None), 

1767 ("spread_float", float), 

1768 ("spread_dt", datetime.datetime), 

1769 ("spread_dict", dict), 

1770 ("spread_list", list), 

1771 ]: 

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

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

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

1775 ) 

1776 else: 

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

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

1779 ) 

1780 return t"Looks good!" 

1781 

1782 an_int: int = 42 

1783 a_true: bool = True 

1784 a_false: bool = False 

1785 a_none: None = None 

1786 a_float: float = 3.14 

1787 a_dt: datetime.datetime = datetime.datetime( 

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

1789 ) 

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

1791 "spread_true": True, 

1792 "spread_false": False, 

1793 "spread_none": None, 

1794 "spread_int": 0, 

1795 "spread_float": 0.0, 

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

1797 "spread_dict": {}, 

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

1799 } 

1800 res = html( 

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

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

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

1804 ) 

1805 assert res == "Looks good!" 

1806 

1807 

1808class TestComponentErrors: 

1809 def test_component_non_callable_fails(self): 

1810 with pytest.raises(TypeError): 

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

1812 

1813 def test_component_requiring_positional_arg_fails(self): 

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

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

1816 

1817 with pytest.raises(TypeError): 

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

1819 

1820 def test_mismatched_component_closing_tag_fails(self): 

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

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

1823 

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

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

1826 

1827 with pytest.raises(TypeError): 

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

1829 

1830 @pytest.mark.parametrize( 

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

1832 ) 

1833 def test_function_component_returns_nontemplate_fails(self, bad_value): 

1834 def BadFunctionComp(children: Template): 

1835 return bad_value 

1836 

1837 with pytest.raises( 

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

1839 ): 

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

1841 

1842 @pytest.mark.parametrize( 

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

1844 ) 

1845 def test_component_object_returns_nontemplate_fails(self, bad_value): 

1846 def BadFactoryComp(children: Template): 

1847 def component_object(): 

1848 return bad_value 

1849 

1850 return component_object 

1851 

1852 with pytest.raises( 

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

1854 ): 

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

1856 

1857 

1858def test_integration_basic(): 

1859 comment_text = "comment is not literal" 

1860 interpolated_class = "red" 

1861 text_in_element = "text is not literal" 

1862 templated = "not literal" 

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

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

1865 

1866 def WrapperComponent(children): 

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

1868 

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

1870<html> 

1871<body> 

1872<!-- literal --> 

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

1874<!-- {comment_text} --> 

1875<span>{text_in_element}</span> 

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

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

1878{markup_content} 

1879</body> 

1880</html>""" 

1881 smoke_str = """<!DOCTYPE html> 

1882<html> 

1883<body> 

1884<!-- literal --> 

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

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

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

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

1889<div><span>comp body</span></div> 

1890<div>safe</div> 

1891</body> 

1892</html>""" 

1893 assert html(smoke_t) == smoke_str 

1894 

1895 

1896def struct_repr(st): 

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

1898 return st.strings, tuple( 

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

1900 ) 

1901 

1902 

1903def test_process_template_internal_cache(): 

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

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

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

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

1908 # the first miss will instead be a hit. 

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

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

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

1912 process_api = TemplateProcessor(parser_api=TemplateParserProxy()) 

1913 cached_process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy()) 

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

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

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

1917 assert isinstance(cached_process_api, TemplateProcessor) 

1918 assert isinstance(cached_process_api.parser_api, CachedTemplateParserProxy) 

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

1920 tnode1 = process_api.parser_api.to_tnode(sample_t) 

1921 tnode2 = process_api.parser_api.to_tnode(sample_t) 

1922 cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t) 

1923 cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t) 

1924 cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t) 

1925 # Check that the uncached and cached services are actually 

1926 # returning non-identical results. 

1927 assert tnode1 is not cached_tnode1 

1928 assert tnode1 is not cached_tnode2 

1929 assert tnode1 is not cached_tnode3 

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

1931 assert tnode1 is not tnode2 

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

1933 assert cached_tnode1 is cached_tnode2 

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

1935 assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t 

1936 # Check that the cached service and uncached services return 

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

1938 assert tnode1 == cached_tnode1 

1939 assert tnode2 == cached_tnode1 

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

1941 # working as we intended. 

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

1943 # cached_tnode2 and cached_tnode3 are hits after cached_tnode1 

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

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

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

1947 cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t) 

1948 # A different template produces a brand new tf. 

1949 assert cached_tnode1 is not cached_tnode4 

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

1951 # produces an unequivalent tf. 

1952 assert cached_tnode1 != cached_tnode4 

1953 

1954 

1955def test_repeat_calls(): 

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

1957 

1958 def get_sample_t(idx, spread_attrs, button_text): 

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

1960 

1961 for idx in range(3): 

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

1963 button_text = "PROCESS" 

1964 sample_t = get_sample_t(idx, spread_attrs, button_text) 

1965 assert ( 

1966 html(sample_t) 

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

1968 ) 

1969 

1970 

1971def get_select_t_with_list(options, selected_values): 

1972 return t"""<select>{ 

1973 [ 

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

1975 for opt in options 

1976 ] 

1977 }</select>""" 

1978 

1979 

1980def get_select_t_with_generator(options, selected_values): 

1981 return t"""<select>{ 

1982 ( 

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

1984 for opt in options 

1985 ) 

1986 }</select>""" 

1987 

1988 

1989def get_select_t_with_concat(options, selected_values): 

1990 parts = [t"<select>"] 

1991 parts.extend( 

1992 [ 

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

1994 for opt in options 

1995 ] 

1996 ) 

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

1998 return sum(parts, t"") 

1999 

2000 

2001@pytest.mark.parametrize( 

2002 "provider", 

2003 ( 

2004 get_select_t_with_list, 

2005 get_select_t_with_generator, 

2006 get_select_t_with_concat, 

2007 ), 

2008) 

2009def test_process_template_iterables(provider): 

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

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

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

2013 return provider(PRIMARY_COLORS, selected_values) 

2014 

2015 no_selection_t = get_color_select_t(set(), provider) 

2016 assert ( 

2017 html(no_selection_t) 

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

2019 ) 

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

2021 assert ( 

2022 html(selected_yellow_t) 

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

2024 ) 

2025 

2026 

2027def test_component_integration(): 

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

2029 

2030 def PageComponent(children, root_attrs=None): 

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

2032 

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

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

2035 

2036 def LayoutComponent(children, body_classes=None): 

2037 return t"""<!doctype html> 

2038<html> 

2039 <head> 

2040 <meta charset="utf-8"> 

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

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

2043 </head> 

2044 <body class={body_classes}> 

2045 {children} 

2046 <{FooterComponent} /> 

2047 </body> 

2048</html> 

2049""" 

2050 

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

2052 content_str = html( 

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

2054 ) 

2055 assert ( 

2056 content_str 

2057 == """<!DOCTYPE html> 

2058<html> 

2059 <head> 

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

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

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

2063 </head> 

2064 <body class="theme-default"> 

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

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

2067 </body> 

2068</html> 

2069""" 

2070 ) 

2071 

2072 

2073class TestInterpolatingHTMLInTemplateWithDynamicParentTag: 

2074 """ 

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

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

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

2078 interpolations in that text correctly. 

2079 """ 

2080 

2081 def test_dynamic_raw_text(self): 

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

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

2084 content_t = t"{content}" 

2085 with pytest.raises( 

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

2087 ): 

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

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

2090 

2091 def test_dynamic_escapable_raw_text(self): 

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

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

2094 content_t = t"{content}" 

2095 with pytest.raises( 

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

2097 ): 

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

2099 

2100 def test_dynamic_normal_text(self): 

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

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

2103 content_t = t"{content}" 

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

2105 assert ( 

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

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

2108 ) 

2109 

2110 

2111class TestPagerComponentExample: 

2112 @dataclass 

2113 class Pager: 

2114 left_pages: tuple = () 

2115 page: int = 0 

2116 right_pages: tuple = () 

2117 prev_page: int | None = None 

2118 next_page: int | None = None 

2119 

2120 @dataclass 

2121 class PagerDisplay: 

2122 pager: TestPagerComponentExample.Pager 

2123 paginate_url: Callable[[int], str] 

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

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

2126 

2127 def __call__(self) -> Template: 

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

2129 if self.pager.prev_page: 

2130 parts.append( 

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

2132 ) 

2133 for left_page in self.pager.left_pages: 

2134 parts.append( 

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

2136 ) 

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

2138 for right_page in self.pager.right_pages: 

2139 parts.append( 

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

2141 ) 

2142 if self.pager.next_page: 

2143 parts.append( 

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

2145 ) 

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

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

2148 

2149 def test_example(self): 

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

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

2152 

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

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

2155 

2156 pager = self.Pager( 

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

2158 ) 

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

2160 res = html(content_t) 

2161 print(res) 

2162 assert ( 

2163 res 

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

2165 ) 

2166 

2167 

2168def test_mathml(): 

2169 num = 1 

2170 denom = 3 

2171 mathml_t = t"""<p> 

2172 The fraction 

2173 <math> 

2174 <mfrac> 

2175 <mn>{num}</mn> 

2176 <mn>{denom}</mn> 

2177 </mfrac> 

2178 </math> 

2179 is not a decimal number. 

2180</p>""" 

2181 res = html(mathml_t) 

2182 assert ( 

2183 str(res) 

2184 == """<p> 

2185 The fraction 

2186 <math> 

2187 <mfrac> 

2188 <mn>1</mn> 

2189 <mn>3</mn> 

2190 </mfrac> 

2191 </math> 

2192 is not a decimal number. 

2193</p>""" 

2194 )