Skip to content

Commit a086c59

Browse files
committed
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.
1 parent b8ea473 commit a086c59

3 files changed

Lines changed: 87 additions & 12 deletions

File tree

README.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ If you use ``pyproject.toml`` for tool configuration use::
107107
[tool.coverage.django_coverage_plugin]
108108
template_extensions = 'html, txt, tex, email'
109109

110+
You can exclude template lines from coverage with ``{# pragma: no cover #}``.
111+
On a block tag, it excludes the entire block through its closing tag::
112+
113+
{% if debug %}{# pragma: no cover #}
114+
<div>{{ debug_info }}</div>
115+
{% endif %}
116+
117+
On a plain text or variable line, it excludes that line only. Custom
118+
``exclude_lines`` patterns from your coverage configuration are supported
119+
automatically.
120+
110121
Caveats
111122
~~~~~~~
112123

django_coverage_plugin/plugin.py

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def __init__(self, options):
115115
self.extensions = [e.strip() for e in extensions.split(",")]
116116

117117
self.debug_checked = False
118+
self.exclude_lines = []
118119

119120
self.django_template_dir = os.path.normcase(
120121
os.path.realpath(os.path.dirname(django.template.__file__))
@@ -135,6 +136,7 @@ def sys_info(self):
135136

136137
def configure(self, config):
137138
self.html_report_dir = os.path.abspath(config.get_option("html:directory"))
139+
self.exclude_lines = config.get_option("report:exclude_lines")
138140

139141
def file_tracer(self, filename):
140142
if os.path.normcase(filename).startswith(self.django_template_dir):
@@ -147,7 +149,7 @@ def file_tracer(self, filename):
147149
return None
148150

149151
def file_reporter(self, filename):
150-
return FileReporter(filename)
152+
return FileReporter(filename, self.exclude_lines)
151153

152154
def find_executable_files(self, src_dir):
153155
# We're only interested in files that look like reasonable HTML
@@ -253,11 +255,16 @@ def get_line_map(self, filename):
253255

254256

255257
class FileReporter(coverage.plugin.FileReporter):
256-
def __init__(self, filename):
258+
exclude_re = None
259+
260+
def __init__(self, filename, exclude_patterns=None):
257261
super().__init__(filename)
258262
# TODO: html filenames are absolute.
259263

260264
self._source = None
265+
self._excluded = None
266+
if exclude_patterns:
267+
self.exclude_re = re.compile("|".join(f"(?:{p})" for p in exclude_patterns))
261268

262269
def source(self):
263270
if self._source is None:
@@ -269,21 +276,26 @@ def source(self):
269276

270277
def lines(self):
271278
source_lines = set()
279+
excluded = set()
272280

273281
if SHOW_PARSING:
274282
print(f"-------------- {self.filename}")
275283

276284
lexer = Lexer(self.source())
277-
tokens = lexer.tokenize()
285+
tokens = list(lexer.tokenize())
278286

279287
# Are we inside a comment?
280288
comment = False
281289
# Is this a template that extends another template?
282290
extends = False
283291
# Are we inside a block?
284292
inblock = False
293+
# Pragma exclusion state
294+
excluding = False
295+
exclude_depth = 0 # tracks nesting so we find the correct closing tag
296+
last_block_opener = None
285297

286-
for token in tokens:
298+
for token_idx, token in enumerate(tokens):
287299
if SHOW_PARSING:
288300
print(
289301
"%10s %2d: %r"
@@ -293,6 +305,30 @@ def lines(self):
293305
token.contents,
294306
)
295307
)
308+
309+
# While inside an excluded block, add all lines to the
310+
# excluded set and track nesting depth to find the matching
311+
# closing tag.
312+
if excluding:
313+
excluded.add(token.lineno)
314+
if token.token_type == TokenType.TEXT:
315+
lineno = token.lineno
316+
lines = token.contents.splitlines(True)
317+
num_lines = len(lines)
318+
if lines[0].isspace():
319+
lineno += 1
320+
num_lines -= 1
321+
excluded.update(range(lineno, lineno + num_lines))
322+
elif token.token_type == TokenType.BLOCK:
323+
tag = token.contents.split()[0]
324+
if not tag.startswith("end"):
325+
exclude_depth += 1
326+
else:
327+
exclude_depth -= 1
328+
if exclude_depth == 0:
329+
excluding = False
330+
continue
331+
296332
if token.token_type == TokenType.BLOCK:
297333
if token.contents == "endcomment":
298334
comment = False
@@ -328,6 +364,7 @@ def lines(self):
328364
elif token.contents.startswith("extends"):
329365
extends = True
330366

367+
last_block_opener = token
331368
source_lines.add(token.lineno)
332369

333370
elif token.token_type == TokenType.VAR:
@@ -351,10 +388,37 @@ def lines(self):
351388
num_lines -= 1
352389
source_lines.update(range(lineno, lineno + num_lines))
353390

391+
# {# pragma: no cover #} — exclude this line, and if the
392+
# comment sits on the same line as a block-opening tag,
393+
# exclude the entire block through its matching end tag.
394+
elif (
395+
self.exclude_re is not None
396+
and token.token_type == TokenType.COMMENT
397+
and self.exclude_re.search("{# " + token.contents + " #}")
398+
):
399+
excluded.add(token.lineno)
400+
if last_block_opener is not None and last_block_opener.lineno == token.lineno:
401+
# Look ahead for a matching end tag to confirm
402+
# this is a block opener, not a self-closing tag.
403+
end_tag = "end" + last_block_opener.contents.split()[0]
404+
if any(
405+
t.token_type == TokenType.BLOCK and t.contents.split()[0] == end_tag
406+
for t in tokens[token_idx + 1 :]
407+
):
408+
excluding = True
409+
exclude_depth = 1
410+
354411
if SHOW_PARSING:
355412
print(f"\t\t\tNow source_lines is: {source_lines!r}")
356413

357-
return source_lines
414+
self._excluded = excluded
415+
return source_lines - excluded
416+
417+
def excluded_lines(self):
418+
"""Return lines excluded via {# pragma: no cover #} comments."""
419+
if self._excluded is None:
420+
self.lines()
421+
return self._excluded
358422

359423

360424
def running_sum(seq):

tests/test_pragma.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111

1212
class PragmaTest(DjangoPluginTestCase):
13-
1413
def test_pragma_on_block_tag_excludes_entire_block(self):
1514
for condition in (True, False):
1615
with self.subTest(condition=condition):
@@ -21,7 +20,7 @@ def test_pragma_on_block_tag_excludes_entire_block(self):
2120
{% endif %}
2221
After
2322
""")
24-
self.run_django_coverage(context={'condition': condition, 'content': 'hi'})
23+
self.run_django_coverage(context={"condition": condition, "content": "hi"})
2524
self.assert_analysis([1, 5])
2625

2726
def test_pragma_on_plain_text_line(self):
@@ -34,7 +33,7 @@ def test_pragma_on_plain_text_line(self):
3433
self.assert_analysis([1, 3])
3534

3635
def test_pragma_on_non_closing_tag(self):
37-
"""Test that pragma does not treat tags like {% cycle $} as blocks."""
36+
"""Test that pragma does not treat tags like {% cycle %} as blocks."""
3837
self.make_template("""\
3938
<div>
4039
{% cycle 'a' 'b' as values %}{# pragma: no cover #}
@@ -56,7 +55,7 @@ def test_pragma_with_nested_blocks(self):
5655
After
5756
""")
5857
self.run_django_coverage(
59-
context={'condition': True, 'items': ['a', 'b']},
58+
context={"condition": True, "items": ["a", "b"]},
6059
)
6160
self.assert_analysis([1, 7])
6261

@@ -75,14 +74,15 @@ def test_custom_exclude_patterns(self):
7574
After
7675
""")
7776
tem = get_template(self.template_file)
78-
self.cov = coverage.Coverage(source=['.'])
77+
self.cov = coverage.Coverage(source=["."])
7978
self.append_config("run:plugins", "django_coverage_plugin")
8079
# Set the exclude_lines with own patterns.
8180
self.cov.config.set_option(
82-
"report:exclude_lines", ["noqa: no-cover", "!SKIP ME!"],
81+
"report:exclude_lines",
82+
["noqa: no-cover", "!SKIP ME!"],
8383
)
8484
self.cov.start()
85-
tem.render({'condition': True, 'content': 'hi'})
85+
tem.render({"condition": True, "content": "hi"})
8686
self.cov.stop()
8787
self.cov.save()
8888
self.assert_analysis([1, 5, 7, 9, 10], missing=[7]) # Expecting 1 missing line

0 commit comments

Comments
 (0)