Skip to content

Commit 08565d7

Browse files
Add HTML coverage reporting (--format=html)
Closes #27. Generates a report directory: an index page plus one annotated source page per file, with syntax highlighting and per-line run/missing/partial marking. --out names the output directory (default htmlcov/); HTML is never written to stdout. Repeat runs are incremental. A status.json records a hash of each file's source, its coverage data and its prev/next navigation, so unchanged pages are not rewritten. A page deleted by hand is regenerated rather than skipped. The index sorts by any column, filters by file name, and can hide fully covered files. While a filter is active the footer subtotals the visible rows and relabels itself; with no filter it shows the report's own totals verbatim. The pages are usable with JavaScript disabled. No new dependencies: highlighting uses stdlib tokenize. The pages, stylesheet and script are slipcover's own; the stylesheet uses the palette from the project logo. Line status is carried by a left border as well as a tint, so the four categories stay distinguishable in greyscale, in print, and for colour-blind readers. Derived from coverage.py (Apache-2.0, as is this project), following the precedent set by xmlreport.py: phystokens.py is a port of its tokenizer, and htmlreport.py takes the incremental status file and the shape of the report loop. Both retain coverage.py's original licence notices, and NOTICE reproduces the attributions from its NOTICE.txt. Differences from coverage.py worth noting: - Line categories are pln/run/mis/par only. _filter_excluded_lines strips excluded lines from the coverage data before reporting and the schema has no excluded field, so there is nothing for an "exc" category or an "excluded" column to show. - A source file missing at report time renders a notice and keeps its index row, rather than omitting the file or aborting -- the ordinary case after --merge of coverage produced on another machine. - The per-file hash includes prev/next navigation, so adding a file refreshes its neighbours' links instead of leaving them stale. - status.json is written atomically (tmp + rename); a truncated one still self-heals into a full regeneration. - The footer is only recomputed while a filter is actually applied, and says so when it is. Recomputing unconditionally makes the footer disagree with the heading under --skip-covered, which omits rows that still count. Under --format=html a forked child returns from the atexit handler without writing, so two processes cannot interleave into one output directory. Other formats are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 06c5ea7 commit 08565d7

13 files changed

Lines changed: 2126 additions & 13 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ __pycache__/
55
build/
66
dist/
77
.coverage
8+
htmlcov/
89
.tox/
910
*.swp

NOTICE

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
SlipCover
2+
Copyright the SlipCover authors.
3+
4+
This product includes software developed as part of coverage.py
5+
(https://github.com/coveragepy/coveragepy).
6+
7+
These files are derived from coverage.py and have been modified:
8+
9+
src/slipcover/xmlreport.py from coverage/xmlreport.py
10+
src/slipcover/phystokens.py from coverage/phystokens.py
11+
src/slipcover/htmlreport.py from coverage/html.py, in part: the
12+
data_filename, read_data and write_html
13+
helpers, and the design of the incremental
14+
status file and the report loop
15+
16+
Everything else in slipcover's HTML report -- the pages, the stylesheet and
17+
the script -- is slipcover's own.
18+
19+
The attribution notices from coverage.py's own NOTICE.txt follow.
20+
21+
Copyright 2001 Gareth Rees. All rights reserved.
22+
Copyright 2004-2026 Ned Batchelder. All rights reserved.
23+
24+
Except where noted otherwise, this software is licensed under the Apache
25+
License, Version 2.0 (the "License"); you may not use this work except in
26+
compliance with the License. You may obtain a copy of the License at
27+
28+
http://www.apache.org/licenses/LICENSE-2.0
29+
30+
Unless required by applicable law or agreed to in writing, software
31+
distributed under the License is distributed on an "AS IS" BASIS,
32+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
33+
See the License for the specific language governing permissions and
34+
limitations under the License.

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ branch = true
111111
source = "src" # or ["src", "lib"]
112112
omit = "tests/*" # or ["tests/*", "*.pyc"]
113113
fail-under = 80.0
114-
format = "json" # "text" (default), "json", "xml", or "lcov"
114+
format = "json" # "text" (default), "json", "xml", "lcov", or "html"
115115
pretty-print = true
116116
skip-covered = true
117117
immediate = false
@@ -123,9 +123,9 @@ xml-package-depth = 3
123123

124124
Most command-line flags have a matching key (use hyphens, as shown above);
125125
`source` and `omit` also accept a TOML array instead of a single
126-
comma-separated string. `--json`/`--xml`/`--lcov` map to the single `format`
127-
key shown above rather than one key per flag. `--merge`, `-m`/module, the
128-
script argument, `--version`, and `--help` are per-invocation choices rather
126+
comma-separated string. `--json`/`--xml`/`--lcov`/`--html` map to the single
127+
`format` key shown above rather than one key per flag. `--merge`, `-m`/module,
128+
the script argument, `--version`, and `--help` are per-invocation choices rather
129129
than settings, so they aren't configurable this way. Command-line arguments
130130
always take precedence over values in `pyproject.toml`, so you can override
131131
any setting on a per-run basis.
@@ -178,9 +178,9 @@ its `box.py` and `image.py` components.
178178
[//]: # (help-output)
179179
```console
180180
$ python3 -m slipcover --help
181-
usage: SlipCover [-h] [--branch] [--format {text,json,xml,lcov}] [--json]
181+
usage: SlipCover [-h] [--branch] [--format {text,json,xml,lcov,html}] [--json]
182182
[--pretty-print] [--xml]
183-
[--xml-package-depth XML_PACKAGE_DEPTH] [--lcov]
183+
[--xml-package-depth XML_PACKAGE_DEPTH] [--lcov] [--html]
184184
[--lcov-test-name LCOV_TEST_NAME]
185185
[--lcov-comment LCOV_COMMENTS] [--out OUT]
186186
[--source SRC1,SRC2,...] [--omit PAT1,PAT2,...] [--immediate]
@@ -196,7 +196,7 @@ positional arguments:
196196
options:
197197
-h, --help show this help message and exit
198198
--branch measure both branch and line coverage
199-
--format {text,json,xml,lcov}
199+
--format {text,json,xml,lcov,html}
200200
select output format
201201
--json select JSON output (shortcut for --format=json)
202202
--pretty-print pretty-print JSON output
@@ -207,12 +207,14 @@ options:
207207
not reported as packages. The default is that all
208208
directories are reported as packages.
209209
--lcov select LCOV output (shortcut for --format=lcov)
210+
--html select HTML output (shortcut for --format=html)
210211
--lcov-test-name LCOV_TEST_NAME
211212
test name for LCOV TN: entries
212213
--lcov-comment LCOV_COMMENTS
213214
add comment lines at the beginning of LCOV output (can
214215
be used multiple times)
215-
--out OUT specify output file name
216+
--out OUT specify output file name (output directory if
217+
--format=html)
216218
--source SRC1,SRC2,...
217219
specify directories to cover; comma-separated for
218220
multiple

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ build-backend = "setuptools.build_meta"
4848
[tool.setuptools.package-dir]
4949
"" = "src"
5050

51+
[tool.setuptools.package-data]
52+
slipcover = ["htmlfiles/*"]
53+
5154
[project.optional-dependencies]
5255
test = [
5356
'pytest',

src/slipcover/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .version import __version__
2-
from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml, print_lcov
2+
from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml, print_lcov, print_html
33
from .importer import FileMatcher, ImportManager, wrap_pytest, wrap_spec_from_file_location
44
from .fuzz import wrap_function

src/slipcover/__main__.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ def wrapper(*pargs, **kwargs):
109109
return wrapper
110110

111111

112+
DEFAULT_HTML_DIR = "htmlcov"
113+
114+
115+
def html_directory(args):
116+
"""The output directory for --format=html.
117+
118+
For HTML, --out names a directory rather than a file; HTML is never
119+
written to stdout, so an unset --out means the default directory.
120+
"""
121+
return str(args.out) if args.out else DEFAULT_HTML_DIR
122+
123+
112124
def merge_files(args, base_path):
113125
"""Merges coverage files."""
114126

@@ -129,6 +141,20 @@ def merge_files(args, base_path):
129141
return 1
130142

131143
try:
144+
if args.format == 'html':
145+
sc.print_html(merged, directory=html_directory(args),
146+
with_branches=args.branch, skip_covered=args.skip_covered)
147+
148+
# print human-readable table for merge results
149+
if not args.silent:
150+
sc.print_coverage(merged, outfile=sys.stdout, skip_covered=args.skip_covered,
151+
missing_width=args.missing_width)
152+
153+
if args.fail_under and merged['summary']['percent_covered'] < args.fail_under:
154+
return 2
155+
156+
return 0
157+
132158
with args.out.open("w", encoding='utf-8') as jf:
133159
if args.format == 'xml':
134160
sc.print_xml(merged, source_paths=[str(base_path)], with_branches=args.branch,
@@ -201,7 +227,7 @@ def build_parser():
201227
ap = argparse.ArgumentParser(prog='SlipCover')
202228
ap.add_argument('--branch', action='store_true', help="measure both branch and line coverage")
203229
fmt = ap.add_mutually_exclusive_group()
204-
fmt.add_argument('--format', choices=['text', 'json', 'xml', 'lcov'], default='text',
230+
fmt.add_argument('--format', choices=['text', 'json', 'xml', 'lcov', 'html'], default='text',
205231
help="select output format")
206232
fmt.add_argument('--json', dest='format', action='store_const', const='json',
207233
help="select JSON output (shortcut for --format=json)")
@@ -214,9 +240,12 @@ def build_parser():
214240
"The default is that all directories are reported as packages."))
215241
fmt.add_argument('--lcov', dest='format', action='store_const', const='lcov',
216242
help="select LCOV output (shortcut for --format=lcov)")
243+
fmt.add_argument('--html', dest='format', action='store_const', const='html',
244+
help="select HTML output (shortcut for --format=html)")
217245
ap.add_argument('--lcov-test-name', type=str, help="test name for LCOV TN: entries")
218246
ap.add_argument('--lcov-comment', action='append', dest='lcov_comments', help="add comment lines at the beginning of LCOV output (can be used multiple times)")
219-
ap.add_argument('--out', type=Path, help="specify output file name")
247+
ap.add_argument('--out', type=Path,
248+
help="specify output file name (output directory if --format=html)")
220249
ap.add_argument('--source', metavar='SRC1,SRC2,...',
221250
help="specify directories to cover; comma-separated for multiple")
222251
ap.add_argument('--omit', metavar='PAT1,PAT2,...',
@@ -284,8 +313,20 @@ def main():
284313
else Path('.').resolve()
285314

286315

316+
# For HTML, --out names a directory; a path that's already something else
317+
# can only fail later, when the report is written from an atexit callback
318+
# where the traceback is swallowed and the exit status stays 0. lexists()
319+
# rather than exists() so a dangling symlink, which os.makedirs() also
320+
# refuses, is caught here too.
321+
if args.format == 'html' and args.out and os.path.lexists(args.out) \
322+
and not args.out.is_dir():
323+
ap.error(f"--out must name a directory with --format=html: {args.out}")
324+
287325
if args.merge:
288-
if not args.out: ap.error("--out is required with --merge")
326+
# HTML falls back to its default directory rather than stdout, so it
327+
# doesn't need --out the way the single-file formats do.
328+
if not args.out and args.format != 'html':
329+
ap.error("--out is required with --merge")
289330
return merge_files(args, base_path=base_path)
290331

291332

@@ -362,6 +403,20 @@ def printit(coverage, outfile):
362403
missing_width=args.missing_width)
363404

364405
if not args.silent:
406+
if args.format == 'html':
407+
# A forked child reaching normal interpreter shutdown runs this
408+
# too; letting it write would interleave a partial report (and
409+
# a mutable status.json) with the parent's. Such a child's
410+
# coverage is lost -- output_tmpfile is only ever written by
411+
# the os._exit() shim, which atexit doesn't run behind -- but
412+
# it is lost the same way for every other format, so this only
413+
# keeps the report intact, it doesn't cost anything extra.
414+
if output_tmpfile:
415+
return
416+
sc.print_html(merged_coverage(sci), directory=html_directory(args),
417+
with_branches=args.branch, skip_covered=args.skip_covered)
418+
return
419+
365420
coverage = merged_coverage(sci)
366421
if args.out:
367422
with open(args.out, "w") as outfile:

src/slipcover/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def _coerce_comments(value):
126126

127127

128128
def _coerce_format(value):
129-
choices = ("text", "json", "xml", "lcov")
129+
choices = ("text", "json", "xml", "lcov", "html")
130130
if value not in choices:
131131
raise ValueError(f"must be one of {choices}, got {value!r}")
132132
return value

0 commit comments

Comments
 (0)