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=["."])