Skip to content

Commit f170c64

Browse files
Add HTML coverage reporting (--format=html)
Closes #27. Generates a coverage.py-style 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: 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 that was deleted by hand is regenerated rather than skipped. Adapted from coverage.py (Apache-2.0) -- templite.py, phystokens.py and the htmlfiles/ assets are ports; see NOTICE. Departures from it: - Line categories are pln/run/mis/par only. Excluded lines are removed from the coverage data before reporting, so there is nothing for an "excluded" category or 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 report -- this is the common case after --merge of coverage produced elsewhere. - 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 index footer keeps the report's own totals when no row is filtered out, so --skip-covered cannot show a footer that disagrees with the header percentage. Under --format=html a forked child returns from the atexit handler without writing, so two processes cannot interleave into one directory. Other formats are untouched. No new dependencies: highlighting uses stdlib tokenize. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 06c5ea7 commit f170c64

18 files changed

Lines changed: 3241 additions & 9 deletions

.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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
SlipCover
2+
Copyright the SlipCover authors.
3+
4+
This product includes software developed as part of coverage.py
5+
(https://github.com/nedbat/coveragepy), copyright Ned Batchelder and
6+
contributors, licensed under the Apache License, Version 2.0.
7+
8+
The following files are derived from coverage.py:
9+
10+
src/slipcover/templite.py (from coverage/templite.py)
11+
src/slipcover/phystokens.py (from coverage/phystokens.py)
12+
src/slipcover/htmlreport.py (from coverage/html.py)
13+
src/slipcover/htmlfiles/index.html (from coverage/htmlfiles/index.html)
14+
src/slipcover/htmlfiles/pyfile.html (from coverage/htmlfiles/pyfile.html)
15+
src/slipcover/htmlfiles/style.css (from coverage/htmlfiles/style.css)
16+
src/slipcover/htmlfiles/coverage_html.js
17+
(from coverage/htmlfiles/coverage_html.js)
18+
src/slipcover/htmlfiles/keybd_closed.png
19+
src/slipcover/htmlfiles/favicon_32.png
20+
21+
You may obtain a copy of the Apache License, Version 2.0 at
22+
23+
http://www.apache.org/licenses/LICENSE-2.0

README.md

Lines changed: 4 additions & 4 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.

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)