From b8ea473394963ae5b2289f349952e164a83a9d91 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:13:13 -0400 Subject: [PATCH 1/5] test: add tests for {# pragma: no cover #} in Django templates Tests cover block-tag exclusion (with both true/false conditions), plain text line exclusion, nested block exclusion, custom exclude patterns via coverage config, and self-closing tags like {% cycle %} that should not be treated as block openers. --- tests/test_pragma.py | 88 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_pragma.py diff --git a/tests/test_pragma.py b/tests/test_pragma.py new file mode 100644 index 0000000..40b2f0d --- /dev/null +++ b/tests/test_pragma.py @@ -0,0 +1,88 @@ +# 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 +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_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 + """) + tem = get_template(self.template_file) + self.cov = coverage.Coverage(source=['.']) + self.append_config("run:plugins", "django_coverage_plugin") + # Set the exclude_lines with own patterns. + self.cov.config.set_option( + "report:exclude_lines", ["noqa: no-cover", "!SKIP ME!"], + ) + 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 From a086c596a7df5f232ac20b76bded46e57f631471 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:29:31 -0400 Subject: [PATCH 2/5] feat: support {# pragma: no cover #} in Django templates Allow users to exclude template lines from coverage using Django comment syntax. A pragma on a block-opening tag excludes the entire block (including nested blocks) through its matching closing tag. A pragma on a plain text or variable line excludes that line only. Uses coverage.py's report:exclude_lines patterns, so the default pragma: no cover works with zero configuration and custom patterns defined in .coveragerc / pyproject.toml are picked up automatically. Block openers are identified by a forward look-ahead for a matching end tag, so self-closing tags like cycle, load, and url are never mistakenly treated as block openers. --- README.rst | 11 +++++ django_coverage_plugin/plugin.py | 74 +++++++++++++++++++++++++++++--- tests/test_pragma.py | 14 +++--- 3 files changed, 87 insertions(+), 12 deletions(-) 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 index 40b2f0d..2994b6f 100644 --- a/tests/test_pragma.py +++ b/tests/test_pragma.py @@ -10,7 +10,6 @@ class PragmaTest(DjangoPluginTestCase): - def test_pragma_on_block_tag_excludes_entire_block(self): for condition in (True, False): with self.subTest(condition=condition): @@ -21,7 +20,7 @@ def test_pragma_on_block_tag_excludes_entire_block(self): {% endif %} After """) - self.run_django_coverage(context={'condition': condition, 'content': 'hi'}) + self.run_django_coverage(context={"condition": condition, "content": "hi"}) self.assert_analysis([1, 5]) def test_pragma_on_plain_text_line(self): @@ -34,7 +33,7 @@ def test_pragma_on_plain_text_line(self): self.assert_analysis([1, 3]) def test_pragma_on_non_closing_tag(self): - """Test that pragma does not treat tags like {% cycle $} as blocks.""" + """Test that pragma does not treat tags like {% cycle %} as blocks.""" self.make_template("""\
{% cycle 'a' 'b' as values %}{# pragma: no cover #} @@ -56,7 +55,7 @@ def test_pragma_with_nested_blocks(self): After """) self.run_django_coverage( - context={'condition': True, 'items': ['a', 'b']}, + context={"condition": True, "items": ["a", "b"]}, ) self.assert_analysis([1, 7]) @@ -75,14 +74,15 @@ def test_custom_exclude_patterns(self): After """) tem = get_template(self.template_file) - self.cov = coverage.Coverage(source=['.']) + self.cov = coverage.Coverage(source=["."]) self.append_config("run:plugins", "django_coverage_plugin") # Set the exclude_lines with own patterns. self.cov.config.set_option( - "report:exclude_lines", ["noqa: no-cover", "!SKIP ME!"], + "report:exclude_lines", + ["noqa: no-cover", "!SKIP ME!"], ) self.cov.start() - tem.render({'condition': True, 'content': 'hi'}) + 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 From 843a60b05f039e214982567e3cae96f3ffb7dd16 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:49:48 -0400 Subject: [PATCH 3/5] test: add defensive checks for same kind nested blocks, whitespace around comment --- tests/test_pragma.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_pragma.py b/tests/test_pragma.py index 2994b6f..efb12bd 100644 --- a/tests/test_pragma.py +++ b/tests/test_pragma.py @@ -59,6 +59,30 @@ def test_pragma_with_nested_blocks(self): ) 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("""\ From e9317c47f82e88187c12804ee45fe40f89c403de Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 7 Apr 2026 07:30:35 -0400 Subject: [PATCH 4/5] Use .coveragerc config to set exclusion patterns --- tests/test_pragma.py | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/test_pragma.py b/tests/test_pragma.py index efb12bd..bd78648 100644 --- a/tests/test_pragma.py +++ b/tests/test_pragma.py @@ -97,16 +97,47 @@ def test_custom_exclude_patterns(self): {% 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.append_config("run:plugins", "django_coverage_plugin") - # Set the exclude_lines with own patterns. - self.cov.config.set_option( - "report:exclude_lines", - ["noqa: no-cover", "!SKIP ME!"], - ) 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 + + 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]) From cff82b6f853f317c8e2a1cd2950e04a4bd648da6 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:21:06 -0400 Subject: [PATCH 5/5] test: skip exclude_also test if coverage < 7.2 --- tests/test_pragma.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_pragma.py b/tests/test_pragma.py index bd78648..4e0db1c 100644 --- a/tests/test_pragma.py +++ b/tests/test_pragma.py @@ -4,6 +4,7 @@ """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 @@ -116,6 +117,9 @@ def test_custom_exclude_patterns(self): 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("""\ @@ -132,7 +136,7 @@ def test_exclude_also(self): plugins = django_coverage_plugin [report] exclude_also = custom-exclude - """ + """, ) tem = get_template(self.template_file) self.cov = coverage.Coverage(source=["."])