1import re 2from io import BytesIO 3 4import pytest 5 6from jinja2 import contextfunction 7from jinja2 import DictLoader 8from jinja2 import Environment 9from jinja2 import nodes 10from jinja2.exceptions import TemplateAssertionError 11from jinja2.ext import Extension 12from jinja2.lexer import count_newlines 13from jinja2.lexer import Token 14 15importable_object = 23 16 17_gettext_re = re.compile(r"_\((.*?)\)", re.DOTALL) 18 19 20i18n_templates = { 21 "master.html": '<title>{{ page_title|default(_("missing")) }}</title>' 22 "{% block body %}{% endblock %}", 23 "child.html": '{% extends "master.html" %}{% block body %}' 24 "{% trans %}watch out{% endtrans %}{% endblock %}", 25 "plural.html": "{% trans user_count %}One user online{% pluralize %}" 26 "{{ user_count }} users online{% endtrans %}", 27 "plural2.html": "{% trans user_count=get_user_count() %}{{ user_count }}s" 28 "{% pluralize %}{{ user_count }}p{% endtrans %}", 29 "stringformat.html": '{{ _("User: %(num)s")|format(num=user_count) }}', 30} 31 32newstyle_i18n_templates = { 33 "master.html": '<title>{{ page_title|default(_("missing")) }}</title>' 34 "{% block body %}{% endblock %}", 35 "child.html": '{% extends "master.html" %}{% block body %}' 36 "{% trans %}watch out{% endtrans %}{% endblock %}", 37 "plural.html": "{% trans user_count %}One user online{% pluralize %}" 38 "{{ user_count }} users online{% endtrans %}", 39 "stringformat.html": '{{ _("User: %(num)s", num=user_count) }}', 40 "ngettext.html": '{{ ngettext("%(num)s apple", "%(num)s apples", apples) }}', 41 "ngettext_long.html": "{% trans num=apples %}{{ num }} apple{% pluralize %}" 42 "{{ num }} apples{% endtrans %}", 43 "transvars1.html": "{% trans %}User: {{ num }}{% endtrans %}", 44 "transvars2.html": "{% trans num=count %}User: {{ num }}{% endtrans %}", 45 "transvars3.html": "{% trans count=num %}User: {{ count }}{% endtrans %}", 46 "novars.html": "{% trans %}%(hello)s{% endtrans %}", 47 "vars.html": "{% trans %}{{ foo }}%(foo)s{% endtrans %}", 48 "explicitvars.html": '{% trans foo="42" %}%(foo)s{% endtrans %}', 49} 50 51 52languages = { 53 "de": { 54 "missing": "fehlend", 55 "watch out": "pass auf", 56 "One user online": "Ein Benutzer online", 57 "%(user_count)s users online": "%(user_count)s Benutzer online", 58 "User: %(num)s": "Benutzer: %(num)s", 59 "User: %(count)s": "Benutzer: %(count)s", 60 "%(num)s apple": "%(num)s Apfel", 61 "%(num)s apples": "%(num)s Äpfel", 62 } 63} 64 65 66@contextfunction 67def gettext(context, string): 68 language = context.get("LANGUAGE", "en") 69 return languages.get(language, {}).get(string, string) 70 71 72@contextfunction 73def ngettext(context, s, p, n): 74 language = context.get("LANGUAGE", "en") 75 if n != 1: 76 return languages.get(language, {}).get(p, p) 77 return languages.get(language, {}).get(s, s) 78 79 80i18n_env = Environment( 81 loader=DictLoader(i18n_templates), extensions=["jinja2.ext.i18n"] 82) 83i18n_env.globals.update({"_": gettext, "gettext": gettext, "ngettext": ngettext}) 84i18n_env_trimmed = Environment(extensions=["jinja2.ext.i18n"]) 85i18n_env_trimmed.policies["ext.i18n.trimmed"] = True 86i18n_env_trimmed.globals.update( 87 {"_": gettext, "gettext": gettext, "ngettext": ngettext} 88) 89 90newstyle_i18n_env = Environment( 91 loader=DictLoader(newstyle_i18n_templates), extensions=["jinja2.ext.i18n"] 92) 93newstyle_i18n_env.install_gettext_callables(gettext, ngettext, newstyle=True) 94 95 96class ExampleExtension(Extension): 97 tags = {"test"} 98 ext_attr = 42 99 context_reference_node_cls = nodes.ContextReference 100 101 def parse(self, parser): 102 return nodes.Output( 103 [ 104 self.call_method( 105 "_dump", 106 [ 107 nodes.EnvironmentAttribute("sandboxed"), 108 self.attr("ext_attr"), 109 nodes.ImportedName(__name__ + ".importable_object"), 110 self.context_reference_node_cls(), 111 ], 112 ) 113 ] 114 ).set_lineno(next(parser.stream).lineno) 115 116 def _dump(self, sandboxed, ext_attr, imported_object, context): 117 return ( 118 f"{sandboxed}|{ext_attr}|{imported_object}|{context.blocks}" 119 f"|{context.get('test_var')}" 120 ) 121 122 123class DerivedExampleExtension(ExampleExtension): 124 context_reference_node_cls = nodes.DerivedContextReference 125 126 127class PreprocessorExtension(Extension): 128 def preprocess(self, source, name, filename=None): 129 return source.replace("[[TEST]]", "({{ foo }})") 130 131 132class StreamFilterExtension(Extension): 133 def filter_stream(self, stream): 134 for token in stream: 135 if token.type == "data": 136 yield from self.interpolate(token) 137 else: 138 yield token 139 140 def interpolate(self, token): 141 pos = 0 142 end = len(token.value) 143 lineno = token.lineno 144 while 1: 145 match = _gettext_re.search(token.value, pos) 146 if match is None: 147 break 148 value = token.value[pos : match.start()] 149 if value: 150 yield Token(lineno, "data", value) 151 lineno += count_newlines(token.value) 152 yield Token(lineno, "variable_begin", None) 153 yield Token(lineno, "name", "gettext") 154 yield Token(lineno, "lparen", None) 155 yield Token(lineno, "string", match.group(1)) 156 yield Token(lineno, "rparen", None) 157 yield Token(lineno, "variable_end", None) 158 pos = match.end() 159 if pos < end: 160 yield Token(lineno, "data", token.value[pos:]) 161 162 163class TestExtensions: 164 def test_extend_late(self): 165 env = Environment() 166 env.add_extension("jinja2.ext.autoescape") 167 t = env.from_string('{% autoescape true %}{{ "<test>" }}{% endautoescape %}') 168 assert t.render() == "<test>" 169 170 def test_loop_controls(self): 171 env = Environment(extensions=["jinja2.ext.loopcontrols"]) 172 173 tmpl = env.from_string( 174 """ 175 {%- for item in [1, 2, 3, 4] %} 176 {%- if item % 2 == 0 %}{% continue %}{% endif -%} 177 {{ item }} 178 {%- endfor %}""" 179 ) 180 assert tmpl.render() == "13" 181 182 tmpl = env.from_string( 183 """ 184 {%- for item in [1, 2, 3, 4] %} 185 {%- if item > 2 %}{% break %}{% endif -%} 186 {{ item }} 187 {%- endfor %}""" 188 ) 189 assert tmpl.render() == "12" 190 191 def test_do(self): 192 env = Environment(extensions=["jinja2.ext.do"]) 193 tmpl = env.from_string( 194 """ 195 {%- set items = [] %} 196 {%- for char in "foo" %} 197 {%- do items.append(loop.index0 ~ char) %} 198 {%- endfor %}{{ items|join(', ') }}""" 199 ) 200 assert tmpl.render() == "0f, 1o, 2o" 201 202 def test_extension_nodes(self): 203 env = Environment(extensions=[ExampleExtension]) 204 tmpl = env.from_string("{% test %}") 205 assert tmpl.render() == "False|42|23|{}|None" 206 207 def test_contextreference_node_passes_context(self): 208 env = Environment(extensions=[ExampleExtension]) 209 tmpl = env.from_string('{% set test_var="test_content" %}{% test %}') 210 assert tmpl.render() == "False|42|23|{}|test_content" 211 212 def test_contextreference_node_can_pass_locals(self): 213 env = Environment(extensions=[DerivedExampleExtension]) 214 tmpl = env.from_string( 215 '{% for test_var in ["test_content"] %}{% test %}{% endfor %}' 216 ) 217 assert tmpl.render() == "False|42|23|{}|test_content" 218 219 def test_identifier(self): 220 assert ExampleExtension.identifier == __name__ + ".ExampleExtension" 221 222 def test_rebinding(self): 223 original = Environment(extensions=[ExampleExtension]) 224 overlay = original.overlay() 225 for env in original, overlay: 226 for ext in env.extensions.values(): 227 assert ext.environment is env 228 229 def test_preprocessor_extension(self): 230 env = Environment(extensions=[PreprocessorExtension]) 231 tmpl = env.from_string("{[[TEST]]}") 232 assert tmpl.render(foo=42) == "{(42)}" 233 234 def test_streamfilter_extension(self): 235 env = Environment(extensions=[StreamFilterExtension]) 236 env.globals["gettext"] = lambda x: x.upper() 237 tmpl = env.from_string("Foo _(bar) Baz") 238 out = tmpl.render() 239 assert out == "Foo BAR Baz" 240 241 def test_extension_ordering(self): 242 class T1(Extension): 243 priority = 1 244 245 class T2(Extension): 246 priority = 2 247 248 env = Environment(extensions=[T1, T2]) 249 ext = list(env.iter_extensions()) 250 assert ext[0].__class__ is T1 251 assert ext[1].__class__ is T2 252 253 def test_debug(self): 254 env = Environment(extensions=["jinja2.ext.debug"]) 255 t = env.from_string("Hello\n{% debug %}\nGoodbye") 256 out = t.render() 257 258 for value in ("context", "cycler", "filters", "abs", "tests", "!="): 259 assert f"'{value}'" in out 260 261 262class TestInternationalization: 263 def test_trans(self): 264 tmpl = i18n_env.get_template("child.html") 265 assert tmpl.render(LANGUAGE="de") == "<title>fehlend</title>pass auf" 266 267 def test_trans_plural(self): 268 tmpl = i18n_env.get_template("plural.html") 269 assert tmpl.render(LANGUAGE="de", user_count=1) == "Ein Benutzer online" 270 assert tmpl.render(LANGUAGE="de", user_count=2) == "2 Benutzer online" 271 272 def test_trans_plural_with_functions(self): 273 tmpl = i18n_env.get_template("plural2.html") 274 275 def get_user_count(): 276 get_user_count.called += 1 277 return 1 278 279 get_user_count.called = 0 280 assert tmpl.render(LANGUAGE="de", get_user_count=get_user_count) == "1s" 281 assert get_user_count.called == 1 282 283 def test_complex_plural(self): 284 tmpl = i18n_env.from_string( 285 "{% trans foo=42, count=2 %}{{ count }} item{% " 286 "pluralize count %}{{ count }} items{% endtrans %}" 287 ) 288 assert tmpl.render() == "2 items" 289 pytest.raises( 290 TemplateAssertionError, 291 i18n_env.from_string, 292 "{% trans foo %}...{% pluralize bar %}...{% endtrans %}", 293 ) 294 295 def test_trans_stringformatting(self): 296 tmpl = i18n_env.get_template("stringformat.html") 297 assert tmpl.render(LANGUAGE="de", user_count=5) == "Benutzer: 5" 298 299 def test_trimmed(self): 300 tmpl = i18n_env.from_string( 301 "{%- trans trimmed %} hello\n world {% endtrans -%}" 302 ) 303 assert tmpl.render() == "hello world" 304 305 def test_trimmed_policy(self): 306 s = "{%- trans %} hello\n world {% endtrans -%}" 307 tmpl = i18n_env.from_string(s) 308 trimmed_tmpl = i18n_env_trimmed.from_string(s) 309 assert tmpl.render() == " hello\n world " 310 assert trimmed_tmpl.render() == "hello world" 311 312 def test_trimmed_policy_override(self): 313 tmpl = i18n_env_trimmed.from_string( 314 "{%- trans notrimmed %} hello\n world {% endtrans -%}" 315 ) 316 assert tmpl.render() == " hello\n world " 317 318 def test_trimmed_vars(self): 319 tmpl = i18n_env.from_string( 320 '{%- trans trimmed x="world" %} hello\n {{ x }} {% endtrans -%}' 321 ) 322 assert tmpl.render() == "hello world" 323 324 def test_trimmed_varname_trimmed(self): 325 # unlikely variable name, but when used as a variable 326 # it should not enable trimming 327 tmpl = i18n_env.from_string( 328 "{%- trans trimmed = 'world' %} hello\n {{ trimmed }} {% endtrans -%}" 329 ) 330 assert tmpl.render() == " hello\n world " 331 332 def test_extract(self): 333 from jinja2.ext import babel_extract 334 335 source = BytesIO( 336 b""" 337 {{ gettext('Hello World') }} 338 {% trans %}Hello World{% endtrans %} 339 {% trans %}{{ users }} user{% pluralize %}{{ users }} users{% endtrans %} 340 """ 341 ) 342 assert list(babel_extract(source, ("gettext", "ngettext", "_"), [], {})) == [ 343 (2, "gettext", "Hello World", []), 344 (3, "gettext", "Hello World", []), 345 (4, "ngettext", ("%(users)s user", "%(users)s users", None), []), 346 ] 347 348 def test_extract_trimmed(self): 349 from jinja2.ext import babel_extract 350 351 source = BytesIO( 352 b""" 353 {{ gettext(' Hello \n World') }} 354 {% trans trimmed %} Hello \n World{% endtrans %} 355 {% trans trimmed %}{{ users }} \n user 356 {%- pluralize %}{{ users }} \n users{% endtrans %} 357 """ 358 ) 359 assert list(babel_extract(source, ("gettext", "ngettext", "_"), [], {})) == [ 360 (2, "gettext", " Hello \n World", []), 361 (4, "gettext", "Hello World", []), 362 (6, "ngettext", ("%(users)s user", "%(users)s users", None), []), 363 ] 364 365 def test_extract_trimmed_option(self): 366 from jinja2.ext import babel_extract 367 368 source = BytesIO( 369 b""" 370 {{ gettext(' Hello \n World') }} 371 {% trans %} Hello \n World{% endtrans %} 372 {% trans %}{{ users }} \n user 373 {%- pluralize %}{{ users }} \n users{% endtrans %} 374 """ 375 ) 376 opts = {"trimmed": "true"} 377 assert list(babel_extract(source, ("gettext", "ngettext", "_"), [], opts)) == [ 378 (2, "gettext", " Hello \n World", []), 379 (4, "gettext", "Hello World", []), 380 (6, "ngettext", ("%(users)s user", "%(users)s users", None), []), 381 ] 382 383 def test_comment_extract(self): 384 from jinja2.ext import babel_extract 385 386 source = BytesIO( 387 b""" 388 {# trans first #} 389 {{ gettext('Hello World') }} 390 {% trans %}Hello World{% endtrans %}{# trans second #} 391 {#: third #} 392 {% trans %}{{ users }} user{% pluralize %}{{ users }} users{% endtrans %} 393 """ 394 ) 395 assert list( 396 babel_extract(source, ("gettext", "ngettext", "_"), ["trans", ":"], {}) 397 ) == [ 398 (3, "gettext", "Hello World", ["first"]), 399 (4, "gettext", "Hello World", ["second"]), 400 (6, "ngettext", ("%(users)s user", "%(users)s users", None), ["third"]), 401 ] 402 403 404class TestScope: 405 def test_basic_scope_behavior(self): 406 # This is what the old with statement compiled down to 407 class ScopeExt(Extension): 408 tags = {"scope"} 409 410 def parse(self, parser): 411 node = nodes.Scope(lineno=next(parser.stream).lineno) 412 assignments = [] 413 while parser.stream.current.type != "block_end": 414 lineno = parser.stream.current.lineno 415 if assignments: 416 parser.stream.expect("comma") 417 target = parser.parse_assign_target() 418 parser.stream.expect("assign") 419 expr = parser.parse_expression() 420 assignments.append(nodes.Assign(target, expr, lineno=lineno)) 421 node.body = assignments + list( 422 parser.parse_statements(("name:endscope",), drop_needle=True) 423 ) 424 return node 425 426 env = Environment(extensions=[ScopeExt]) 427 tmpl = env.from_string( 428 """\ 429 {%- scope a=1, b=2, c=b, d=e, e=5 -%} 430 {{ a }}|{{ b }}|{{ c }}|{{ d }}|{{ e }} 431 {%- endscope -%} 432 """ 433 ) 434 assert tmpl.render(b=3, e=4) == "1|2|2|4|5" 435 436 437class TestNewstyleInternationalization: 438 def test_trans(self): 439 tmpl = newstyle_i18n_env.get_template("child.html") 440 assert tmpl.render(LANGUAGE="de") == "<title>fehlend</title>pass auf" 441 442 def test_trans_plural(self): 443 tmpl = newstyle_i18n_env.get_template("plural.html") 444 assert tmpl.render(LANGUAGE="de", user_count=1) == "Ein Benutzer online" 445 assert tmpl.render(LANGUAGE="de", user_count=2) == "2 Benutzer online" 446 447 def test_complex_plural(self): 448 tmpl = newstyle_i18n_env.from_string( 449 "{% trans foo=42, count=2 %}{{ count }} item{% " 450 "pluralize count %}{{ count }} items{% endtrans %}" 451 ) 452 assert tmpl.render() == "2 items" 453 pytest.raises( 454 TemplateAssertionError, 455 i18n_env.from_string, 456 "{% trans foo %}...{% pluralize bar %}...{% endtrans %}", 457 ) 458 459 def test_trans_stringformatting(self): 460 tmpl = newstyle_i18n_env.get_template("stringformat.html") 461 assert tmpl.render(LANGUAGE="de", user_count=5) == "Benutzer: 5" 462 463 def test_newstyle_plural(self): 464 tmpl = newstyle_i18n_env.get_template("ngettext.html") 465 assert tmpl.render(LANGUAGE="de", apples=1) == "1 Apfel" 466 assert tmpl.render(LANGUAGE="de", apples=5) == "5 Äpfel" 467 468 def test_autoescape_support(self): 469 env = Environment(extensions=["jinja2.ext.autoescape", "jinja2.ext.i18n"]) 470 env.install_gettext_callables( 471 lambda x: "<strong>Wert: %(name)s</strong>", 472 lambda s, p, n: s, 473 newstyle=True, 474 ) 475 t = env.from_string( 476 '{% autoescape ae %}{{ gettext("foo", name=' 477 '"<test>") }}{% endautoescape %}' 478 ) 479 assert t.render(ae=True) == "<strong>Wert: <test></strong>" 480 assert t.render(ae=False) == "<strong>Wert: <test></strong>" 481 482 def test_autoescape_macros(self): 483 env = Environment(autoescape=False, extensions=["jinja2.ext.autoescape"]) 484 template = ( 485 "{% macro m() %}<html>{% endmacro %}" 486 "{% autoescape true %}{{ m() }}{% endautoescape %}" 487 ) 488 assert env.from_string(template).render() == "<html>" 489 490 def test_num_used_twice(self): 491 tmpl = newstyle_i18n_env.get_template("ngettext_long.html") 492 assert tmpl.render(apples=5, LANGUAGE="de") == "5 Äpfel" 493 494 def test_num_called_num(self): 495 source = newstyle_i18n_env.compile( 496 """ 497 {% trans num=3 %}{{ num }} apple{% pluralize 498 %}{{ num }} apples{% endtrans %} 499 """, 500 raw=True, 501 ) 502 # quite hacky, but the only way to properly test that. The idea is 503 # that the generated code does not pass num twice (although that 504 # would work) for better performance. This only works on the 505 # newstyle gettext of course 506 assert ( 507 re.search(r"u?'%\(num\)s apple', u?'%\(num\)s apples', 3", source) 508 is not None 509 ) 510 511 def test_trans_vars(self): 512 t1 = newstyle_i18n_env.get_template("transvars1.html") 513 t2 = newstyle_i18n_env.get_template("transvars2.html") 514 t3 = newstyle_i18n_env.get_template("transvars3.html") 515 assert t1.render(num=1, LANGUAGE="de") == "Benutzer: 1" 516 assert t2.render(count=23, LANGUAGE="de") == "Benutzer: 23" 517 assert t3.render(num=42, LANGUAGE="de") == "Benutzer: 42" 518 519 def test_novars_vars_escaping(self): 520 t = newstyle_i18n_env.get_template("novars.html") 521 assert t.render() == "%(hello)s" 522 t = newstyle_i18n_env.get_template("vars.html") 523 assert t.render(foo="42") == "42%(foo)s" 524 t = newstyle_i18n_env.get_template("explicitvars.html") 525 assert t.render() == "%(foo)s" 526 527 528class TestAutoEscape: 529 def test_scoped_setting(self): 530 env = Environment(extensions=["jinja2.ext.autoescape"], autoescape=True) 531 tmpl = env.from_string( 532 """ 533 {{ "<HelloWorld>" }} 534 {% autoescape false %} 535 {{ "<HelloWorld>" }} 536 {% endautoescape %} 537 {{ "<HelloWorld>" }} 538 """ 539 ) 540 assert tmpl.render().split() == [ 541 "<HelloWorld>", 542 "<HelloWorld>", 543 "<HelloWorld>", 544 ] 545 546 env = Environment(extensions=["jinja2.ext.autoescape"], autoescape=False) 547 tmpl = env.from_string( 548 """ 549 {{ "<HelloWorld>" }} 550 {% autoescape true %} 551 {{ "<HelloWorld>" }} 552 {% endautoescape %} 553 {{ "<HelloWorld>" }} 554 """ 555 ) 556 assert tmpl.render().split() == [ 557 "<HelloWorld>", 558 "<HelloWorld>", 559 "<HelloWorld>", 560 ] 561 562 def test_nonvolatile(self): 563 env = Environment(extensions=["jinja2.ext.autoescape"], autoescape=True) 564 tmpl = env.from_string('{{ {"foo": "<test>"}|xmlattr|escape }}') 565 assert tmpl.render() == ' foo="<test>"' 566 tmpl = env.from_string( 567 '{% autoescape false %}{{ {"foo": "<test>"}' 568 "|xmlattr|escape }}{% endautoescape %}" 569 ) 570 assert tmpl.render() == " foo="&lt;test&gt;"" 571 572 def test_volatile(self): 573 env = Environment(extensions=["jinja2.ext.autoescape"], autoescape=True) 574 tmpl = env.from_string( 575 '{% autoescape foo %}{{ {"foo": "<test>"}' 576 "|xmlattr|escape }}{% endautoescape %}" 577 ) 578 assert tmpl.render(foo=False) == " foo="&lt;test&gt;"" 579 assert tmpl.render(foo=True) == ' foo="<test>"' 580 581 def test_scoping(self): 582 env = Environment(extensions=["jinja2.ext.autoescape"]) 583 tmpl = env.from_string( 584 '{% autoescape true %}{% set x = "<x>" %}{{ x }}' 585 '{% endautoescape %}{{ x }}{{ "<y>" }}' 586 ) 587 assert tmpl.render(x=1) == "<x>1<y>" 588 589 def test_volatile_scoping(self): 590 env = Environment(extensions=["jinja2.ext.autoescape"]) 591 tmplsource = """ 592 {% autoescape val %} 593 {% macro foo(x) %} 594 [{{ x }}] 595 {% endmacro %} 596 {{ foo().__class__.__name__ }} 597 {% endautoescape %} 598 {{ '<testing>' }} 599 """ 600 tmpl = env.from_string(tmplsource) 601 assert tmpl.render(val=True).split()[0] == "Markup" 602 assert tmpl.render(val=False).split()[0] == "str" 603 604 # looking at the source we should see <testing> there in raw 605 # (and then escaped as well) 606 env = Environment(extensions=["jinja2.ext.autoescape"]) 607 pysource = env.compile(tmplsource, raw=True) 608 assert "<testing>\\n" in pysource 609 610 env = Environment(extensions=["jinja2.ext.autoescape"], autoescape=True) 611 pysource = env.compile(tmplsource, raw=True) 612 assert "<testing>\\n" in pysource 613 614 def test_overlay_scopes(self): 615 class MagicScopeExtension(Extension): 616 tags = {"overlay"} 617 618 def parse(self, parser): 619 node = nodes.OverlayScope(lineno=next(parser.stream).lineno) 620 node.body = list( 621 parser.parse_statements(("name:endoverlay",), drop_needle=True) 622 ) 623 node.context = self.call_method("get_scope") 624 return node 625 626 def get_scope(self): 627 return {"x": [1, 2, 3]} 628 629 env = Environment(extensions=[MagicScopeExtension]) 630 631 tmpl = env.from_string( 632 """ 633 {{- x }}|{% set z = 99 %} 634 {%- overlay %} 635 {{- y }}|{{ z }}|{% for item in x %}[{{ item }}]{% endfor %} 636 {%- endoverlay %}| 637 {{- x -}} 638 """ 639 ) 640 assert tmpl.render(x=42, y=23) == "42|23|99|[1][2][3]|42" 641