Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 #}
<div>{{ debug_info }}</div>
{% 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
~~~~~~~

Expand Down
74 changes: 69 additions & 5 deletions django_coverage_plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__))
Expand All @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -269,21 +276,26 @@ 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
# Is this a template that extends another template?
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"
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
147 changes: 147 additions & 0 deletions tests/test_pragma.py
Original file line number Diff line number Diff line change
@@ -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("""\
<div>
{% cycle 'a' 'b' as values %}{# pragma: no cover #}
Covered
Not covered {{ values|last }}{# pragma: no cover #}
</div>
""")
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])