# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt """Tests for coverage.templite.""" from __future__ import annotations import re from types import SimpleNamespace from typing import Any, ContextManager, Dict, List, Optional import pytest from coverage.templite import Templite, TempliteSyntaxError, TempliteValueError from tests.coveragetest import CoverageTest # pylint: disable=possibly-unused-variable class TempliteTest(CoverageTest): """Tests for Templite.""" run_in_temp_dir = False def try_render( self, text: str, ctx: Optional[Dict[str, Any]] = None, result: Optional[str] = None, ) -> None: """Render `text` through `ctx`, and it had better be `result`. Result defaults to None so we can shorten the calls where we expect an exception and never get to the result comparison. """ actual = Templite(text).render(ctx or {}) # If result is None, then an exception should have prevented us getting # to here. assert result is not None assert actual == result def assertSynErr(self, msg: str) -> ContextManager[None]: """Assert that a `TempliteSyntaxError` will happen. A context manager, and the message should be `msg`. """ pat = "^" + re.escape(msg) + "$" return pytest.raises(TempliteSyntaxError, match=pat) # type: ignore def test_passthrough(self) -> None: # Strings without variables are passed through unchanged. assert Templite("Hello").render() == "Hello" assert Templite("Hello, 20% fun time!").render() == "Hello, 20% fun time!" def test_variables(self) -> None: # Variables use {{var}} syntax. self.try_render("Hello, {{name}}!", {'name':'Ned'}, "Hello, Ned!") def test_undefined_variables(self) -> None: # Using undefined names is an error. with pytest.raises(Exception, match="'name'"): self.try_render("Hi, {{name}}!") def test_pipes(self) -> None: # Variables can be filtered with pipes. data = { 'name': 'Ned', 'upper': lambda x: x.upper(), 'second': lambda x: x[1], } self.try_render("Hello, {{name|upper}}!", data, "Hello, NED!") # Pipes can be concatenated. self.try_render("Hello, {{name|upper|second}}!", data, "Hello, E!") def test_reusability(self) -> None: # A single Templite can be used more than once with different data. globs = { 'upper': lambda x: x.upper(), 'punct': '!', } template = Templite("This is {{name|upper}}{{punct}}", globs) assert template.render({'name':'Ned'}) == "This is NED!" assert template.render({'name':'Ben'}) == "This is BEN!" def test_attribute(self) -> None: # Variables' attributes can be accessed with dots. obj = SimpleNamespace(a="Ay") self.try_render("{{obj.a}}", locals(), "Ay") obj2 = SimpleNamespace(obj=obj, b="Bee") self.try_render("{{obj2.obj.a}} {{obj2.b}}", locals(), "Ay Bee") def test_member_function(self) -> None: # Variables' member functions can be used, as long as they are nullary. class WithMemberFns(SimpleNamespace): """A class to try out member function access.""" def ditto(self) -> str: """Return twice the .txt attribute.""" return self.txt + self.txt # type: ignore obj = WithMemberFns(txt="Once") self.try_render("{{obj.ditto}}", locals(), "OnceOnce") def test_item_access(self) -> None: # Variables' items can be used. d = {'a':17, 'b':23} self.try_render("{{d.a}} < {{d.b}}", locals(), "17 < 23") def test_loops(self) -> None: # Loops work like in Django. nums = [1,2,3,4] self.try_render( "Look: {% for n in nums %}{{n}}, {% endfor %}done.", locals(), "Look: 1, 2, 3, 4, done." ) # Loop iterables can be filtered. def rev(l: List[int]) -> List[int]: """Return the reverse of `l`.""" l = l[:] l.reverse() return l self.try_render( "Look: {% for n in nums|rev %}{{n}}, {% endfor %}done.", locals(), "Look: 4, 3, 2, 1, done." ) def test_empty_loops(self) -> None: self.try_render( "Empty: {% for n in nums %}{{n}}, {% endfor %}done.", {'nums':[]}, "Empty: done." ) def test_multiline_loops(self) -> None: self.try_render( "Look: \n{% for n in nums %}\n{{n}}, \n{% endfor %}done.", {'nums':[1,2,3]}, "Look: \n\n1, \n\n2, \n\n3, \ndone." ) def test_multiple_loops(self) -> None: self.try_render( "{% for n in nums %}{{n}}{% endfor %} and " + "{% for n in nums %}{{n}}{% endfor %}", {'nums': [1,2,3]}, "123 and 123" ) def test_comments(self) -> None: # Single-line comments work: self.try_render( "Hello, {# Name goes here: #}{{name}}!", {'name':'Ned'}, "Hello, Ned!" ) # and so do multi-line comments: self.try_render( "Hello, {# Name\ngoes\nhere: #}{{name}}!", {'name':'Ned'}, "Hello, Ned!" ) def test_if(self) -> None: self.try_render( "Hi, {% if ned %}NED{% endif %}{% if ben %}BEN{% endif %}!", {'ned': 1, 'ben': 0}, "Hi, NED!" ) self.try_render( "Hi, {% if ned %}NED{% endif %}{% if ben %}BEN{% endif %}!", {'ned': 0, 'ben': 1}, "Hi, BEN!" ) self.try_render( "Hi, {% if ned %}NED{% if ben %}BEN{% endif %}{% endif %}!", {'ned': 0, 'ben': 0}, "Hi, !" ) self.try_render( "Hi, {% if ned %}NED{% if ben %}BEN{% endif %}{% endif %}!", {'ned': 1, 'ben': 0}, "Hi, NED!" ) self.try_render( "Hi, {% if ned %}NED{% if ben %}BEN{% endif %}{% endif %}!", {'ned': 1, 'ben': 1}, "Hi, NEDBEN!" ) def test_complex_if(self) -> None: class Complex(SimpleNamespace): """A class to try out complex data access.""" def getit(self): # type: ignore """Return it.""" return self.it obj = Complex(it={'x':"Hello", 'y': 0}) self.try_render( "@" + "{% if obj.getit.x %}X{% endif %}" + "{% if obj.getit.y %}Y{% endif %}" + "{% if obj.getit.y|str %}S{% endif %}" + "!", { 'obj': obj, 'str': str }, "@XS!" ) def test_loop_if(self) -> None: self.try_render( "@{% for n in nums %}{% if n %}Z{% endif %}{{n}}{% endfor %}!", {'nums': [0,1,2]}, "@0Z1Z2!" ) self.try_render( "X{%if nums%}@{% for n in nums %}{{n}}{% endfor %}{%endif%}!", {'nums': [0,1,2]}, "X@012!" ) self.try_render( "X{%if nums%}@{% for n in nums %}{{n}}{% endfor %}{%endif%}!", {'nums': []}, "X!" ) def test_nested_loops(self) -> None: self.try_render( "@" + "{% for n in nums %}" + "{% for a in abc %}{{a}}{{n}}{% endfor %}" + "{% endfor %}" + "!", {'nums': [0,1,2], 'abc': ['a', 'b', 'c']}, "@a0b0c0a1b1c1a2b2c2!" ) def test_whitespace_handling(self) -> None: self.try_render( "@{% for n in nums %}\n" + " {% for a in abc %}{{a}}{{n}}{% endfor %}\n" + "{% endfor %}!\n", {'nums': [0, 1, 2], 'abc': ['a', 'b', 'c']}, "@\n a0b0c0\n\n a1b1c1\n\n a2b2c2\n!\n" ) self.try_render( "@{% for n in nums -%}\n" + " {% for a in abc -%}\n" + " {# this disappears completely -#}\n" + " {{a-}}\n" + " {{n -}}\n" + " {{n -}}\n" + " {% endfor %}\n" + "{% endfor %}!\n", {'nums': [0, 1, 2], 'abc': ['a', 'b', 'c']}, "@a00b00c00\na11b11c11\na22b22c22\n!\n" ) self.try_render( "@{% for n in nums -%}\n" + " {{n -}}\n" + " x\n" + "{% endfor %}!\n", {'nums': [0, 1, 2]}, "@0x\n1x\n2x\n!\n" ) self.try_render(" hello ", {}, " hello ") def test_eat_whitespace(self) -> None: self.try_render( "Hey!\n" + "{% joined %}\n" + "@{% for n in nums %}\n" + " {% for a in abc %}\n" + " {# this disappears completely #}\n" + " X\n" + " Y\n" + " {{a}}\n" + " {{n }}\n" + " {% endfor %}\n" + "{% endfor %}!\n" + "{% endjoined %}\n", {'nums': [0, 1, 2], 'abc': ['a', 'b', 'c']}, "Hey!\n@XYa0XYb0XYc0XYa1XYb1XYc1XYa2XYb2XYc2!\n" ) def test_non_ascii(self) -> None: self.try_render( "{{where}} ollǝɥ", { 'where': 'ǝɹǝɥʇ' }, "ǝɹǝɥʇ ollǝɥ" ) def test_exception_during_evaluation(self) -> None: # TypeError: Couldn't evaluate {{ foo.bar.baz }}: regex = "^Couldn't evaluate None.bar$" with pytest.raises(TempliteValueError, match=regex): self.try_render( "Hey {{foo.bar.baz}} there", {'foo': None}, "Hey ??? there" ) def test_bad_names(self) -> None: with self.assertSynErr("Not a valid name: 'var%&!@'"): self.try_render("Wat: {{ var%&!@ }}") with self.assertSynErr("Not a valid name: 'filter%&!@'"): self.try_render("Wat: {{ foo|filter%&!@ }}") with self.assertSynErr("Not a valid name: '@'"): self.try_render("Wat: {% for @ in x %}{% endfor %}") def test_bogus_tag_syntax(self) -> None: with self.assertSynErr("Don't understand tag: 'bogus'"): self.try_render("Huh: {% bogus %}!!{% endbogus %}??") def test_malformed_if(self) -> None: with self.assertSynErr("Don't understand if: '{% if %}'"): self.try_render("Buh? {% if %}hi!{% endif %}") with self.assertSynErr("Don't understand if: '{% if this or that %}'"): self.try_render("Buh? {% if this or that %}hi!{% endif %}") def test_malformed_for(self) -> None: with self.assertSynErr("Don't understand for: '{% for %}'"): self.try_render("Weird: {% for %}loop{% endfor %}") with self.assertSynErr("Don't understand for: '{% for x from y %}'"): self.try_render("Weird: {% for x from y %}loop{% endfor %}") with self.assertSynErr("Don't understand for: '{% for x, y in z %}'"): self.try_render("Weird: {% for x, y in z %}loop{% endfor %}") def test_bad_nesting(self) -> None: with self.assertSynErr("Unmatched action tag: 'if'"): self.try_render("{% if x %}X") with self.assertSynErr("Mismatched end tag: 'for'"): self.try_render("{% if x %}X{% endfor %}") with self.assertSynErr("Too many ends: '{% endif %}'"): self.try_render("{% if x %}{% endif %}{% endif %}") def test_malformed_end(self) -> None: with self.assertSynErr("Don't understand end: '{% end if %}'"): self.try_render("{% if x %}X{% end if %}") with self.assertSynErr("Don't understand end: '{% endif now %}'"): self.try_render("{% if x %}X{% endif now %}")