Skip to content

Commit e06eb34

Browse files
authored
fix: escape filenames in markdown report #2141 (#2142)
* fix: escape pipe character in filename in markdown report #2141 Both underscore and pipe characters are valid in filenames and meaningful in markdown tables. Underscores were already escaped by the markdown report, using a simple string replacement, but pipe characters were not. * markdown report: escape most of string.punctuation. * feedback: text would be a better (and shorter!) name than input_value * Amend the get_report() helper function to have an explicit output_format argument No longer produce forward slash escaped underscore in test helper * test: all string.punctuation characters are escaped in markdown reports * I don't understand the slash replace behavior of get_report() --------- Co-authored-by: ellieayla <1447600+me@users.noreply.github.com>
1 parent 07debaa commit e06eb34

3 files changed

Lines changed: 76 additions & 9 deletions

File tree

coverage/report.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import sys
99
from collections.abc import Iterable
10+
from string import punctuation
1011
from typing import IO, TYPE_CHECKING, Any
1112

1213
from coverage.exceptions import ConfigError, NoDataError
@@ -19,6 +20,15 @@
1920
if TYPE_CHECKING:
2021
from coverage import Coverage
2122

23+
ESCAPE_PUNCTUATION_TABLE = "".maketrans(
24+
{char: f"\\{char}" for char in punctuation if char not in ".,/-"}
25+
)
26+
27+
28+
def escape_markdown(text: str) -> str:
29+
"""Prefix all characters meaningful in markdown tables with backslashes."""
30+
return text.translate(ESCAPE_PUNCTUATION_TABLE)
31+
2232

2333
class SummaryReporter:
2434
"""A reporter for writing the summary report."""
@@ -126,8 +136,9 @@ def report_markdown(
126136
`end_lines` is a list of ending lines with information about skipped files.
127137
128138
"""
139+
129140
# Prepare the formatting strings, header, and column sorting.
130-
max_name = max((len(line[0].replace("_", "\\_")) for line in lines_values), default=0)
141+
max_name = max((len(escape_markdown(line[0])) for line in lines_values), default=0)
131142
max_name = max(max_name, len("**TOTAL**")) + 1
132143
formats = dict(
133144
Name="| {:{name_len}}|",
@@ -160,7 +171,7 @@ def report_markdown(
160171
self.write_items(
161172
(
162173
formats[item].format(
163-
str(value).replace("_", "\\_"), name_len=max_name, n=max_n - 1
174+
escape_markdown(str(value)), name_len=max_name, n=max_n - 1
164175
)
165176
for item, value in zip(header, values)
166177
)

tests/coveragetest.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,17 @@ def start_import_stop(
125125
cov.stop()
126126
return mod
127127

128-
def get_report(self, cov: Coverage, squeeze: bool = True, **kwargs: Any) -> str:
128+
def get_report(
129+
self, cov: Coverage, squeeze: bool = True, output_format: str | None = None, **kwargs: Any
130+
) -> str:
129131
"""Get the report from `cov`, and canonicalize it."""
130132
repout = io.StringIO()
131133
kwargs.setdefault("show_missing", False)
132-
cov.report(file=repout, **kwargs)
133-
report = repout.getvalue().replace("\\", "/")
134+
cov.report(file=repout, output_format=output_format, **kwargs)
135+
if output_format == "markdown":
136+
report = repout.getvalue().replace("\\\\", "/")
137+
else:
138+
report = repout.getvalue().replace("\\", "/")
134139
print(report) # When tests fail, it's helpful to see the output
135140
if squeeze:
136141
report = re.sub(r" +", " ", report)

tests/test_report.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os.path
1313
import py_compile
1414
import re
15+
import string
1516

1617

1718
import pytest
@@ -891,6 +892,58 @@ def test_report_with_chdir(self) -> None:
891892
report = self.report_from_command("coverage report --format=markdown")
892893
assert self.last_line_squeezed(report) == "| **TOTAL** | **5** | **0** | **100%** |"
893894

895+
DISALLOWED_WINDOWS_FILENAME_CHARACTERS = r'\/:*?"<>|'
896+
897+
@pytest.mark.parametrize(
898+
("punctuation", "expected_characters", "escaped_punctuation"),
899+
[
900+
pytest.param(
901+
# everything that's not covered by the other items
902+
"!#$%&'()+,-.;=@[]^_`{}~",
903+
set(string.punctuation) - set(DISALLOWED_WINDOWS_FILENAME_CHARACTERS),
904+
r"\!\#\$\%\&\'\(\)\+,-.\;\=\@\[\]\^\_\`\{\}\~",
905+
id="cross-platform-punctuation",
906+
),
907+
pytest.param(
908+
"\\", # seperate only to make test failure messages easy to read
909+
set("\\"),
910+
"/", # munged to forward slash by get_report
911+
id="backslash-untouched",
912+
),
913+
pytest.param(
914+
"/",
915+
set("/"),
916+
"/",
917+
id="forwardslash-untouched-posix-only",
918+
marks=pytest.mark.skipif(env.WINDOWS, reason="Disallowed in Windows filenames."),
919+
),
920+
pytest.param(
921+
':*?"<>|',
922+
set(DISALLOWED_WINDOWS_FILENAME_CHARACTERS) - set("\\/"),
923+
r"\:\*\?\"\<\>\|",
924+
id="posix-only-punctuation",
925+
marks=pytest.mark.skipif(env.WINDOWS, reason="Disallowed in Windows filenames."),
926+
),
927+
],
928+
)
929+
def test_markdown_escape_filename(
930+
self, punctuation: str, expected_characters: set[str], escaped_punctuation: str
931+
) -> None:
932+
"""Make a horrible filename of punctuation, and ensure coverage report is escaped."""
933+
self.make_file(f"horrible-{punctuation}-filename.py", "print(1)")
934+
self.make_data_file(lines={f"horrible-{punctuation}-filename.py": [1]})
935+
936+
# make sure everything in the expected set of punctuation is here,
937+
# and none have been dropped. Full diff on mismatch is readable.
938+
assert expected_characters == set(punctuation)
939+
940+
cov = coverage.Coverage()
941+
cov.load()
942+
report = self.get_report(cov, output_format="markdown")
943+
944+
squeezed = self.squeezed_lines(report)
945+
assert squeezed[2] == f"| horrible-{escaped_punctuation}-filename.py | 1 | 1 | 0% |"
946+
894947
def test_bug_156_file_not_run_should_be_zero(self) -> None:
895948
# https://github.com/coveragepy/coveragepy/issues/156
896949
self.make_file(
@@ -1016,11 +1069,9 @@ def test_empty_files(self) -> None:
10161069
assert "tests/modules/pkg1/__init__.py 1 0 0 0 100%" in report
10171070
assert "tests/modules/pkg2/__init__.py 0 0 0 0 100%" in report
10181071
report = self.get_report(cov, squeeze=False, output_format="markdown")
1019-
# get_report() escapes backslash so we expect forward slash escaped
1020-
# underscore
1021-
assert "tests/modules/pkg1//_/_init/_/_.py " in report
1072+
assert r"tests/modules/pkg1/\_\_init\_\_.py " in report
10221073
assert "| 1 | 0 | 0 | 0 | 100% |" in report
1023-
assert "tests/modules/pkg2//_/_init/_/_.py " in report
1074+
assert r"tests/modules/pkg2/\_\_init\_\_.py " in report
10241075
assert "| 0 | 0 | 0 | 0 | 100% |" in report
10251076

10261077
def test_markdown_with_missing(self) -> None:

0 commit comments

Comments
 (0)