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])