Coverage for tdom/parser_test.py: 96%

284 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 00:48 +0000

1from string.templatelib import Interpolation, Template 

2 

3import pytest 

4 

5from .parser import TemplateParser, configure_source_tracker 

6from .placeholders import make_placeholder_config 

7from .template_utils import TemplateRef 

8from .tnodes import ( 

9 TComment, 

10 TComponent, 

11 TDocumentType, 

12 TElement, 

13 TFragment, 

14 TInterpolatedAttribute, 

15 TLiteralAttribute, 

16 TSpreadAttribute, 

17 TTemplatedAttribute, 

18 TText, 

19) 

20 

21 

22def test_parse_mixed_literal_content(): 

23 node = TemplateParser.parse( 

24 t"<!DOCTYPE html>" 

25 t"<!-- Comment -->" 

26 t'<div class="container">' 

27 t"Hello, <br class='funky' />world <!-- neato -->!" 

28 t"</div>" 

29 ) 

30 assert node == TFragment( 

31 children=( 

32 TDocumentType("html"), 

33 TComment.literal(" Comment "), 

34 TElement( 

35 "div", 

36 attrs=(TLiteralAttribute("class", "container"),), 

37 children=( 

38 TText.literal("Hello, "), 

39 TElement("br", attrs=(TLiteralAttribute("class", "funky"),)), 

40 TText.literal("world "), 

41 TComment.literal(" neato "), 

42 TText.literal("!"), 

43 ), 

44 ), 

45 ) 

46 ) 

47 

48 

49# 

50# Text 

51# 

52def test_parse_empty(): 

53 node = TemplateParser.parse(t"") 

54 assert node == TFragment() 

55 

56 

57def test_parse_text(): 

58 node = TemplateParser.parse(t"Hello, world!") 

59 assert node == TText.literal("Hello, world!") 

60 

61 

62def test_parse_text_multiline(): 

63 node = TemplateParser.parse(t"""Hello, world! 

64 Hello, moon! 

65Hello, sun! 

66""") 

67 assert node == TText.literal("""Hello, world! 

68 Hello, moon! 

69Hello, sun! 

70""") 

71 

72 

73def test_parse_text_with_entities(): 

74 node = TemplateParser.parse(t"a &lt; b") 

75 assert node == TText.literal("a < b") 

76 

77 

78def test_parse_text_with_template_singleton(): 

79 greeting = "Hello, World!" 

80 node = TemplateParser.parse(t"{greeting}") 

81 assert node == TText(ref=TemplateRef(strings=("", ""), i_indexes=(0,))) 

82 

83 

84def test_parse_text_with_template(): 

85 who = "World" 

86 node = TemplateParser.parse(t"Hello, {who}!") 

87 assert node == TText(ref=TemplateRef(strings=("Hello, ", "!"), i_indexes=(0,))) 

88 

89 

90# 

91# Elements 

92# 

93def test_parse_void_element(): 

94 node = TemplateParser.parse(t"<br>") 

95 assert node == TElement("br") 

96 

97 

98def test_parse_void_element_self_closed(): 

99 node = TemplateParser.parse(t"<br />") 

100 assert node == TElement("br") 

101 

102 

103def test_parse_uppercase_void_element(): 

104 node = TemplateParser.parse(t"<BR>") 

105 assert node == TElement("br") 

106 

107 

108def test_parse_standard_element_with_text(): 

109 node = TemplateParser.parse(t"<div>Hello, world!</div>") 

110 assert node == TElement("div", children=(TText.literal("Hello, world!"),)) 

111 

112 

113def test_parse_nested_elements(): 

114 node = TemplateParser.parse(t"<div><span>Nested</span> content</div>") 

115 assert node == TElement( 

116 "div", 

117 children=( 

118 TElement("span", children=(TText.literal("Nested"),)), 

119 TText.literal(" content"), 

120 ), 

121 ) 

122 

123 

124def test_parse_element_with_template(): 

125 who = "World" 

126 node = TemplateParser.parse(t"<div>Hello, {who}!</div>") 

127 assert node == TElement( 

128 "div", 

129 children=(TText(ref=TemplateRef(strings=("Hello, ", "!"), i_indexes=(0,))),), 

130 ) 

131 

132 

133def test_parse_element_with_template_singleton(): 

134 greeting = "Hello, World!" 

135 node = TemplateParser.parse(t"<div>{greeting}</div>") 

136 assert node == TElement( 

137 "div", children=(TText(ref=TemplateRef(strings=("", ""), i_indexes=(0,))),) 

138 ) 

139 

140 

141def test_parse_multiple_voids(): 

142 node = TemplateParser.parse(t"<br><hr><hr /><hr /><br /><br><br>") 

143 assert node == TFragment( 

144 children=( 

145 TElement("br"), 

146 TElement("hr"), 

147 TElement("hr"), 

148 TElement("hr"), 

149 TElement("br"), 

150 TElement("br"), 

151 TElement("br"), 

152 ) 

153 ) 

154 

155 

156def test_parse_text_entities(): 

157 node = TemplateParser.parse(t"<p>&lt;/p&gt;</p>") 

158 assert node == TElement( 

159 "p", 

160 children=(TText.literal("</p>"),), 

161 ) 

162 

163 

164def test_parse_script_tag_content(): 

165 node = TemplateParser.parse( 

166 t"<script>if (a < b && c > d) { alert('wow'); } </script>" 

167 ) 

168 assert node == TElement( 

169 "script", 

170 children=(TText.literal("if (a < b && c > d) { alert('wow'); }"),), 

171 ) 

172 

173 

174def test_parse_script_with_entities(): 

175 # The <script> tag (and <style>) tag uses the CDATA content model. 

176 node = TemplateParser.parse(t"<script>var x = 'a &amp; b';</script>") 

177 assert node == TElement( 

178 "script", 

179 children=(TText.literal("var x = 'a &amp; b';"),), 

180 ), "Entities SHOULD NOT be evaluated in scripts." 

181 

182 

183def test_parse_textarea_tag_content(): 

184 node = TemplateParser.parse( 

185 t"<textarea>if (a < b && c > d) { alert('wow'); } </textarea>" 

186 ) 

187 assert node == TElement( 

188 "textarea", 

189 children=(TText.literal("if (a < b && c > d) { alert('wow'); }"),), 

190 ) 

191 

192 

193def test_parse_textarea_with_entities(): 

194 # The <textarea> (and <title>) tag uses the RCDATA content model. 

195 node = TemplateParser.parse(t"<textarea>var x = 'a &amp; b';</textarea>") 

196 assert node == TElement( 

197 "textarea", 

198 children=(TText.literal("var x = 'a & b';"),), 

199 ), "Entities SHOULD be evaluated in textarea/title." 

200 

201 

202def test_parse_title_unusual(): 

203 node = TemplateParser.parse(t"<title>My & Awesome <Site></title>") 

204 assert node == TElement( 

205 "title", 

206 children=(TText.literal("My & Awesome <Site>"),), 

207 ) 

208 

209 

210def test_parse_mismatched_tags(): 

211 with pytest.raises(ValueError): 

212 _ = TemplateParser.parse(t"<div><span>Mismatched</div></span>") 

213 

214 

215def test_parse_unclosed_tag(): 

216 with pytest.raises(ValueError): 

217 _ = TemplateParser.parse(t"<div>Unclosed") 

218 

219 

220def test_parse_unexpected_closing_tag(): 

221 with pytest.raises(ValueError): 

222 _ = TemplateParser.parse(t"Unopened</div>") 

223 

224 

225def test_self_closing_tags(): 

226 node = TemplateParser.parse(t"<div/><p></p>") 

227 assert node == TFragment( 

228 children=( 

229 TElement("div"), 

230 TElement("p"), 

231 ) 

232 ) 

233 

234 

235def test_nested_self_closing_tags(): 

236 node = TemplateParser.parse(t"<div><br><div /><br></div>") 

237 assert node == TElement( 

238 "div", children=(TElement("br"), TElement("div"), TElement("br")) 

239 ) 

240 node = TemplateParser.parse(t"<div><div /></div>") 

241 assert node == TElement("div", children=(TElement("div"),)) 

242 

243 

244def test_self_closing_tags_unexpected_closing_tag(): 

245 with pytest.raises(ValueError): 

246 _ = TemplateParser.parse(t"<div /></div>") 

247 

248 

249def test_self_closing_void_tags_unexpected_closing_tag(): 

250 with pytest.raises(ValueError): 

251 _ = TemplateParser.parse(t"<input /></input>") 

252 

253 

254# 

255# Attributes 

256# 

257def test_literal_attrs(): 

258 node = TemplateParser.parse( 

259 t"<a" 

260 t" id=example_link" # no quotes allowed without spaces 

261 t" autofocus" # bare / boolean 

262 t' title=""' # empty attribute 

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

264 t">Link</a>" 

265 ) 

266 assert node == TElement( 

267 "a", 

268 attrs=( 

269 TLiteralAttribute("id", "example_link"), 

270 TLiteralAttribute("autofocus", None), 

271 TLiteralAttribute("title", ""), 

272 TLiteralAttribute("href", "https://example.com"), 

273 TLiteralAttribute("target", "_blank"), 

274 ), 

275 children=(TText.literal("Link"),), 

276 ) 

277 

278 

279def test_literal_attr_entities(): 

280 node = TemplateParser.parse(t'<a title="&lt;">Link</a>') 

281 assert node == TElement( 

282 "a", 

283 attrs=(TLiteralAttribute("title", "<"),), 

284 children=(TText.literal("Link"),), 

285 ) 

286 

287 

288def test_literal_attr_order(): 

289 node = TemplateParser.parse(t'<a title="a" href="b" title="c"></a>') 

290 assert isinstance(node, TElement) 

291 assert node.attrs == ( 

292 TLiteralAttribute("title", "a"), 

293 TLiteralAttribute("href", "b"), 

294 TLiteralAttribute("title", "c"), # dupe IS allowed 

295 ) 

296 

297 

298def test_interpolated_attr(): 

299 value1 = 42 

300 value2 = 99 

301 node = TemplateParser.parse(t'<div value1="{value1}" value2={value2} />') 

302 assert node == TElement( 

303 "div", 

304 attrs=( 

305 TInterpolatedAttribute("value1", 0), 

306 TInterpolatedAttribute("value2", 1), 

307 ), 

308 children=(), 

309 ) 

310 

311 

312def test_templated_attr(): 

313 value1 = 42 

314 value2 = 99 

315 node = TemplateParser.parse( 

316 t'<div value1="{value1}-burrito" value2="neato-{value2}-wow" />' 

317 ) 

318 value1_ref = TemplateRef(strings=("", "-burrito"), i_indexes=(0,)) 

319 value2_ref = TemplateRef(strings=("neato-", "-wow"), i_indexes=(1,)) 

320 assert node == TElement( 

321 "div", 

322 attrs=( 

323 TTemplatedAttribute("value1", value1_ref), 

324 TTemplatedAttribute("value2", value2_ref), 

325 ), 

326 children=(), 

327 ) 

328 

329 

330def test_spread_attr(): 

331 spread_attrs = {} 

332 node = TemplateParser.parse(t"<div {spread_attrs} />") 

333 assert node == TElement( 

334 "div", 

335 attrs=(TSpreadAttribute(i_index=0),), 

336 children=(), 

337 ) 

338 

339 

340def test_templated_attribute_name_error(): 

341 with pytest.raises(ValueError): 

342 attr_name = "some-attr" 

343 _ = TemplateParser.parse(t'<div {attr_name}="value" />') 

344 

345 

346def test_templated_attribute_name_and_value_error(): 

347 with pytest.raises(ValueError): 

348 attr_name = "some-attr" 

349 value = "value" 

350 _ = TemplateParser.parse(t'<div {attr_name}="{value}" />') 

351 

352 

353def test_adjacent_spread_attrs_error(): 

354 with pytest.raises(ValueError): 

355 attrs1 = {} 

356 attrs2 = {} 

357 _ = TemplateParser.parse(t"<div {attrs1}{attrs2} />") 

358 

359 

360# 

361# Comments 

362# 

363def test_parse_comment(): 

364 node = TemplateParser.parse(t"<!-- This is a comment -->") 

365 assert node == TComment.literal(" This is a comment ") 

366 

367 

368def test_parse_comment_interpolation(): 

369 text = "comment" 

370 node = TemplateParser.parse(t"<!-- This is a {text} -->") 

371 assert node == TComment( 

372 ref=TemplateRef(strings=(" This is a ", " "), i_indexes=(0,)) 

373 ) 

374 

375 

376# 

377# Doctypes 

378# 

379def test_parse_doctype(): 

380 node = TemplateParser.parse(t"<!DOCTYPE html>") 

381 assert node == TDocumentType("html") 

382 

383 

384def test_parse_doctype_interpolation_error(): 

385 extra = "SYSTEM" 

386 with pytest.raises(ValueError): 

387 _ = TemplateParser.parse(t"<!DOCTYPE html {extra}>") 

388 

389 

390def test_unsupported_decl_error(): 

391 with pytest.raises(NotImplementedError): 

392 _ = TemplateParser.parse(t"<!doctype-alt html500>") # Unknown declaration 

393 with pytest.raises(NotImplementedError): 

394 _ = TemplateParser.parse(t"<!doctype>") # missing DTD 

395 

396 

397# 

398# Components. 

399# 

400def test_component_element_with_children(): 

401 def Component(children): 

402 return t"{children}" 

403 

404 node = TemplateParser.parse(t"<{Component}><div>Hello, World!</div></{Component}>") 

405 assert node == TComponent( 

406 start_i_index=0, 

407 end_i_index=1, 

408 children_ref=TemplateRef(strings=("<div>Hello, World!</div>",), i_indexes=()), 

409 ) 

410 

411 

412def test_component_element_self_closing(): 

413 def Component(): 

414 pass 

415 

416 node = TemplateParser.parse(t"<{Component} />") 

417 assert node == TComponent(start_i_index=0) 

418 

419 

420def test_component_element_with_closing_tag(): 

421 def Component(): 

422 pass 

423 

424 node = TemplateParser.parse(t"<{Component}></{Component}>") 

425 assert node == TComponent(start_i_index=0, end_i_index=1) 

426 

427 

428def test_component_element_special_case_mismatched_closing_tag_still_parses(): 

429 def Component1(): 

430 pass 

431 

432 def Component2(): 

433 pass 

434 

435 node = TemplateParser.parse(t"<{Component1}></{Component2}>") 

436 assert node == TComponent(start_i_index=0, end_i_index=1) 

437 

438 

439def test_component_element_invalid_closing_tag(): 

440 def Component(): 

441 pass 

442 

443 with pytest.raises(ValueError): 

444 _ = TemplateParser.parse(t"<{Component}></div>") 

445 

446 

447def test_component_element_invalid_opening_tag(): 

448 def Component(): 

449 pass 

450 

451 with pytest.raises(ValueError): 

452 _ = TemplateParser.parse(t"<div></{Component}>") 

453 

454 

455def test_adjacent_start_component_tag_error(): 

456 def Component(): 

457 pass 

458 

459 with pytest.raises(ValueError): 

460 _ = TemplateParser.parse(t"<{Component}{Component}></{Component}>") 

461 

462 

463def test_adjacent_end_component_tag_error(): 

464 def Component(): 

465 pass 

466 

467 with pytest.raises(ValueError): 

468 _ = TemplateParser.parse(t"<{Component}></{Component}{Component}>") 

469 

470 

471def test_placeholder_collision_avoidance(): 

472 config = make_placeholder_config() 

473 # This test is to ensure that our placeholder detection avoids collisions 

474 # even with content that might look like a placeholder. 

475 tricky = "0" 

476 template = Template( 

477 f'<div data-tricky="{config.prefix}', 

478 Interpolation(tricky, "tricky", None, ""), 

479 f'{config.suffix}"></div>', 

480 ) 

481 tnode = TemplateParser.parse(template) 

482 value_ref = TemplateRef(strings=(config.prefix, config.suffix), i_indexes=(0,)) 

483 assert tnode == TElement( 

484 "div", attrs=(TTemplatedAttribute(name="data-tricky", value_ref=value_ref),) 

485 ) 

486 

487 

488def test_unresolved_placeholder(): 

489 t = t"<div>{0}<span>{1}</span>{2}</div>" 

490 tp = TemplateParser() 

491 tp.feed_template(t) 

492 # This would be a bug in the parser so we have to fabricate 

493 # this error manually. 

494 tp.get_source().placeholders.add_placeholder(3) 

495 with pytest.raises(ValueError, match="Some placeholders were never resolved"): 

496 tp.close() 

497 

498 

499class TestSourceTracker: 

500 @pytest.mark.parametrize("t", (t"", t"simple")) 

501 def test_only_string(self, t: Template): 

502 st = configure_source_tracker(t) 

503 itr = iter(st) 

504 part = next(itr) 

505 assert isinstance(part, str) and part == t.strings[0] 

506 assert itr.index == 0 

507 with pytest.raises(StopIteration): 

508 _ = next(itr) 

509 assert itr.index == 0, "Still at 0." 

510 

511 def test_iter(self): 

512 t = t"<div>{0}</div>" 

513 st = configure_source_tracker(t) 

514 itr = iter(st) 

515 parts = [] 

516 parts.append(next(itr)) 

517 assert itr.index == 0 

518 assert not itr.has_placeholders() 

519 assert parts[0] == t.strings[0], "String parts pass through." 

520 parts.append(next(itr)) 

521 assert itr.index == 1 

522 assert itr.has_placeholders(), "Placeholder was addeded." 

523 tref_find = st.find_placeholders(parts[1]) 

524 assert itr.has_placeholders() and tref_find.i_indexes[0] == 0 

525 tref_removed = st.remove_placeholders(parts[1]) 

526 assert not itr.has_placeholders() and tref_removed.i_indexes[0] == 0, ( 

527 "Placeholder removed." 

528 ) 

529 parts.append(next(itr)) 

530 assert itr.index == 2 

531 with pytest.raises(StopIteration): 

532 next(itr) 

533 assert itr.index == 2, ( 

534 "Once the iter is exhausted the index remains at the last element." 

535 ) 

536 

537 

538class TestIncompleteParsing: 

539 def test_dangling_quotes(self): 

540 with pytest.raises(ValueError, match="Parser expects more data"): 

541 _ = TemplateParser.parse(t"<div a='") 

542 with pytest.raises(ValueError, match="Parser expects more data"): 

543 _ = TemplateParser.parse(t'<div a="') 

544 

545 def test_unfinished_attribute(self): 

546 with pytest.raises(ValueError, match="Parser expects more data"): 

547 _ = TemplateParser.parse(t"<div a=") 

548 

549 def test_placeholder_missing_from_dangling_quote(self): 

550 with pytest.raises(ValueError, match="Parser expects more data"): 

551 _ = TemplateParser.parse(t'<div a="{None}') 

552 

553 

554class TestComponentExtractChildrenTemplate: 

555 @pytest.fixture 

556 def Component(self): 

557 def Component(children: Template, **attrs: str) -> Template: 

558 return t"" 

559 

560 return Component 

561 

562 def test_extract_no_content(self, Component): 

563 node = TemplateParser.parse(t"<{Component}></{Component}>") 

564 assert node == TComponent( 

565 start_i_index=0, 

566 end_i_index=1, 

567 children_ref=TemplateRef(strings=("",), i_indexes=()), 

568 ) 

569 

570 def test_extract_startend(self, Component): 

571 node = TemplateParser.parse(t"<{Component} />") 

572 assert node == TComponent( 

573 start_i_index=0, 

574 end_i_index=None, 

575 children_ref=TemplateRef(strings=("",), i_indexes=()), 

576 ) 

577 

578 def test_extract(self, Component): 

579 node = TemplateParser.parse( 

580 t"<{Component}><div>Hello, World!</div></{Component}>" 

581 ) 

582 assert node == TComponent( 

583 start_i_index=0, 

584 end_i_index=1, 

585 children_ref=TemplateRef( 

586 strings=("<div>Hello, World!</div>",), i_indexes=() 

587 ), 

588 ) 

589 

590 def test_extract_with_attr_interpolation(self, Component): 

591 # Unquoted ... 

592 node = TemplateParser.parse( 

593 t"<{Component} title={'Skip over this.'}><div>Hello, World!</div></{Component}>" 

594 ) 

595 assert node == TComponent( 

596 start_i_index=0, 

597 end_i_index=2, 

598 attrs=(TInterpolatedAttribute(name="title", value_i_index=1),), 

599 children_ref=TemplateRef( 

600 strings=("<div>Hello, World!</div>",), i_indexes=() 

601 ), 

602 ) 

603 # Quoted... 

604 node2 = TemplateParser.parse( 

605 t'<{Component} title="{"Skip over this."}"><div>Hello, World!</div></{Component}>' 

606 ) 

607 assert node2 == node 

608 

609 def test_extract_with_literal_attr_gt_char(self, Component): 

610 node = TemplateParser.parse( 

611 t'<{Component} title="1 > 0"><div>Hello, World!</div></{Component}>' 

612 ) 

613 assert node == TComponent( 

614 start_i_index=0, 

615 end_i_index=1, 

616 attrs=(TLiteralAttribute("title", "1 > 0"),), 

617 children_ref=TemplateRef( 

618 strings=("<div>Hello, World!</div>",), i_indexes=() 

619 ), 

620 ) 

621 

622 def test_extract_with_interpolated_attr_literal_attr_gt_char(self, Component): 

623 node = TemplateParser.parse( 

624 t'<{Component} id={"simple"} title="1 > 0"><div>Hello, World!</div></{Component}>' 

625 ) 

626 assert node == TComponent( 

627 start_i_index=0, 

628 end_i_index=2, 

629 attrs=( 

630 TInterpolatedAttribute(name="id", value_i_index=1), 

631 TLiteralAttribute("title", "1 > 0"), 

632 ), 

633 children_ref=TemplateRef( 

634 strings=("<div>Hello, World!</div>",), i_indexes=() 

635 ), 

636 ) 

637 

638 def test_extract_with_templated_attr_gt_char(self, Component): 

639 node = TemplateParser.parse( 

640 t'<{Component} id="{"header"}_{"container"}" title="1 > 0"><div>Hello, World!</div></{Component}>' 

641 ) 

642 assert node == TComponent( 

643 start_i_index=0, 

644 end_i_index=3, 

645 attrs=( 

646 TTemplatedAttribute( 

647 "id", TemplateRef(strings=("", "_", ""), i_indexes=(1, 2)) 

648 ), 

649 TLiteralAttribute("title", "1 > 0"), 

650 ), 

651 children_ref=TemplateRef( 

652 strings=("<div>Hello, World!</div>",), i_indexes=() 

653 ), 

654 )