diff --git a/README.rst b/README.rst index a92a872..25ebbfa 100644 --- a/README.rst +++ b/README.rst @@ -107,6 +107,17 @@ If you use ``pyproject.toml`` for tool configuration use:: [tool.coverage.django_coverage_plugin] template_extensions = 'html, txt, tex, email' +You can exclude template lines from coverage with ``{# pragma: no cover #}``. +On a block tag, it excludes the entire block through its closing tag:: + + {% if debug %}{# pragma: no cover #} +
{{ debug_info }}
+ {% endif %} + +On a plain text or variable line, it excludes that line only. Custom +``exclude_lines`` patterns from your coverage configuration are supported +automatically. + Caveats ~~~~~~~ diff --git a/django_coverage_plugin/plugin.py b/django_coverage_plugin/plugin.py index 45bb83b..d4c217d 100644 --- a/django_coverage_plugin/plugin.py +++ b/django_coverage_plugin/plugin.py @@ -115,6 +115,7 @@ def __init__(self, options): self.extensions = [e.strip() for e in extensions.split(",")] self.debug_checked = False + self.exclude_lines = [] self.django_template_dir = os.path.normcase( os.path.realpath(os.path.dirname(django.template.__file__)) @@ -135,6 +136,7 @@ def sys_info(self): def configure(self, config): self.html_report_dir = os.path.abspath(config.get_option("html:directory")) + self.exclude_lines = config.get_option("report:exclude_lines") def file_tracer(self, filename): if os.path.normcase(filename).startswith(self.django_template_dir): @@ -147,7 +149,7 @@ def file_tracer(self, filename): return None def file_reporter(self, filename): - return FileReporter(filename) + return FileReporter(filename, self.exclude_lines) def find_executable_files(self, src_dir): # We're only interested in files that look like reasonable HTML @@ -253,11 +255,16 @@ def get_line_map(self, filename): class FileReporter(coverage.plugin.FileReporter): - def __init__(self, filename): + exclude_re = None + + def __init__(self, filename, exclude_patterns=None): super().__init__(filename) # TODO: html filenames are absolute. self._source = None + self._excluded = None + if exclude_patterns: + self.exclude_re = re.compile("|".join(f"(?:{p})" for p in exclude_patterns)) def source(self): if self._source is None: @@ -269,12 +276,13 @@ def source(self): def lines(self): source_lines = set() + excluded = set() if SHOW_PARSING: print(f"-------------- {self.filename}") lexer = Lexer(self.source()) - tokens = lexer.tokenize() + tokens = list(lexer.tokenize()) # Are we inside a comment? comment = False @@ -282,8 +290,12 @@ def lines(self): extends = False # Are we inside a block? inblock = False + # Pragma exclusion state + excluding = False + exclude_depth = 0 # tracks nesting so we find the correct closing tag + last_block_opener = None - for token in tokens: + for token_idx, token in enumerate(tokens): if SHOW_PARSING: print( "%10s %2d: %r" @@ -293,6 +305,30 @@ def lines(self): token.contents, ) ) + + # While inside an excluded block, add all lines to the + # excluded set and track nesting depth to find the matching + # closing tag. + if excluding: + excluded.add(token.lineno) + if token.token_type == TokenType.TEXT: + lineno = token.lineno + lines = token.contents.splitlines(True) + num_lines = len(lines) + if lines[0].isspace(): + lineno += 1 + num_lines -= 1 + excluded.update(range(lineno, lineno + num_lines)) + elif token.token_type == TokenType.BLOCK: + tag = token.contents.split()[0] + if not tag.startswith("end"): + exclude_depth += 1 + else: + exclude_depth -= 1 + if exclude_depth == 0: + excluding = False + continue + if token.token_type == TokenType.BLOCK: if token.contents == "endcomment": comment = False @@ -328,6 +364,7 @@ def lines(self): elif token.contents.startswith("extends"): extends = True + last_block_opener = token source_lines.add(token.lineno) elif token.token_type == TokenType.VAR: @@ -351,10 +388,37 @@ def lines(self): num_lines -= 1 source_lines.update(range(lineno, lineno + num_lines)) + # {# pragma: no cover #} — exclude this line, and if the + # comment sits on the same line as a block-opening tag, + # exclude the entire block through its matching end tag. + elif ( + self.exclude_re is not None + and token.token_type == TokenType.COMMENT + and self.exclude_re.search("{# " + token.contents + " #}") + ): + excluded.add(token.lineno) + if last_block_opener is not None and last_block_opener.lineno == token.lineno: + # Look ahead for a matching end tag to confirm + # this is a block opener, not a self-closing tag. + end_tag = "end" + last_block_opener.contents.split()[0] + if any( + t.token_type == TokenType.BLOCK and t.contents.split()[0] == end_tag + for t in tokens[token_idx + 1 :] + ): + excluding = True + exclude_depth = 1 + if SHOW_PARSING: print(f"\t\t\tNow source_lines is: {source_lines!r}") - return source_lines + self._excluded = excluded + return source_lines - excluded + + def excluded_lines(self): + """Return lines excluded via {# pragma: no cover #} comments.""" + if self._excluded is None: + self.lines() + return self._excluded def running_sum(seq): diff --git a/tests/test_pragma.py b/tests/test_pragma.py new file mode 100644 index 0000000..4e0db1c --- /dev/null +++ b/tests/test_pragma.py @@ -0,0 +1,147 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt + +"""Tests for {# pragma: no cover #} support in Django templates.""" + +import coverage +import pytest +from django.template.loader import get_template + +from .plugin_test import DjangoPluginTestCase + + +class PragmaTest(DjangoPluginTestCase): + def test_pragma_on_block_tag_excludes_entire_block(self): + for condition in (True, False): + with self.subTest(condition=condition): + self.make_template("""\ + Before + {% if condition %}{# pragma: no cover #} + {{ content }} + {% endif %} + After + """) + self.run_django_coverage(context={"condition": condition, "content": "hi"}) + self.assert_analysis([1, 5]) + + def test_pragma_on_plain_text_line(self): + self.make_template("""\ + Before + excluded line {# pragma: no cover #} + After + """) + self.run_django_coverage() + self.assert_analysis([1, 3]) + + def test_pragma_on_non_closing_tag(self): + """Test that pragma does not treat tags like {% cycle %} as blocks.""" + self.make_template("""\ +
+ {% cycle 'a' 'b' as values %}{# pragma: no cover #} + Covered + Not covered {{ values|last }}{# pragma: no cover #} +
+ """) + self.run_django_coverage() + self.assert_analysis([1, 3, 5]) + + def test_pragma_with_nested_blocks(self): + self.make_template("""\ + Before + {% if condition %}{# pragma: no cover #} + {% for item in items %} + {{ item }} + {% endfor %} + {% endif %} + After + """) + self.run_django_coverage( + context={"condition": True, "items": ["a", "b"]}, + ) + self.assert_analysis([1, 7]) + + def test_nested_if_blocks(self): + """Test that nested block of same type are handled.""" + self.make_template("""\ + Before + {% if 1 %}{# pragma: no cover #} + {% if 0 %} + Not covered + {% endif %} + Also not covered, due to parent block. + {% endif %} + After + """) + self.run_django_coverage() + self.assert_analysis([1, 8]) + + def test_whitespace_around_pragma(self): + """Test that whitespace characters are stripped.""" + self.make_template("""\ + Before + {% load static %}{# pragma: no cover #} + """) + self.run_django_coverage() + self.assert_analysis([1]) + + def test_custom_exclude_patterns(self): + """Test that coverage.py config for report:exclude_lines is used.""" + self.make_template("""\ + Before + {% if condition %}{# noqa: no-cover #} + {{ content }} + {% endif %} + {% if not condition %} {# this block won't execute #} + {% now "SHORT_DATETIME_FORMAT" %} {# !SKIP ME! #} + {% lorem %}{# pragma: no cover #}{# I'm not covered because of custom exclude #} + {% endif %} + {% lorem %}{# I'm covered! #} + After + """) + self.make_file( + ".coveragerc", + """\ + [run] + plugins = django_coverage_plugin + [report] + exclude_lines = + noqa: no-cover + !SKIP ME! + """, + ) + tem = get_template(self.template_file) + self.cov = coverage.Coverage(source=["."]) + self.cov.start() + tem.render({"condition": True, "content": "hi"}) + self.cov.stop() + self.cov.save() + self.assert_analysis([1, 5, 7, 9, 10], missing=[7]) # Expecting 1 missing line + + @pytest.mark.skipif( + coverage.version_info < (7, 2), reason="exclude_also requires coverage 7.2+" + ) + def test_exclude_also(self): + """Test that report:exclude_also patterns are picked up.""" + self.make_template("""\ + Before + {% if condition %}{# custom-exclude #} + {{ content }} + {% endif %} + After + """) + self.make_file( + ".coveragerc", + """\ + [run] + plugins = django_coverage_plugin + [report] + exclude_also = custom-exclude + """, + ) + tem = get_template(self.template_file) + self.cov = coverage.Coverage(source=["."]) + self.cov.start() + tem.render({"condition": True, "content": "hi"}) + self.cov.stop() + self.cov.save() + self.assert_analysis([1, 5])