Skip to content

Commit 9a78a80

Browse files
Bachmann1234nedbat
authored andcommitted
Create a JSON report
1 parent 790f0b3 commit 9a78a80

17 files changed

+496
-63
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ Unreleased
3333
- `debug=plugin` didn't properly support configuration or dynamic context
3434
plugins, but now it does, closing `issue 834`_.
3535

36+
- Added a JSON report `issue 720`_.
37+
38+
.. _issue 720: https://github.com/nedbat/coveragepy/issues/720
3639
.. _issue 822: https://github.com/nedbat/coveragepy/issues/822
3740
.. _issue 834: https://github.com/nedbat/coveragepy/issues/834
3841
.. _issue 829: https://github.com/nedbat/coveragepy/issues/829

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Marc Abramowitz
7979
Marcus Cobden
8080
Mark van der Wal
8181
Martin Fuzzey
82+
Matt Bachmann
8283
Matthew Boehm
8384
Matthew Desmarais
8485
Max Linke

coverage/cmdline.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ class Opts(object):
118118
metavar="OUTFILE",
119119
help="Write the XML report to this file. Defaults to 'coverage.xml'",
120120
)
121+
output_json = optparse.make_option(
122+
'-o', '', action='store', dest="outfile",
123+
metavar="OUTFILE",
124+
help="Write the JSON report to this file. Defaults to 'coverage.json'",
125+
)
126+
json_pretty_print = optparse.make_option(
127+
'', '--pretty-print', action='store_true',
128+
help="Print the json formatted for human readers",
129+
)
121130
parallel_mode = optparse.make_option(
122131
'-p', '--parallel-mode', action='store_true',
123132
help=(
@@ -402,6 +411,22 @@ def get_prog_name(self):
402411
usage="[options] [modules]",
403412
description="Generate an XML report of coverage results."
404413
),
414+
415+
'json': CmdOptionParser(
416+
"json",
417+
[
418+
Opts.fail_under,
419+
Opts.ignore_errors,
420+
Opts.include,
421+
Opts.omit,
422+
Opts.output_json,
423+
Opts.json_pretty_print,
424+
Opts.show_contexts,
425+
Opts.contexts,
426+
] + GLOBAL_ARGS,
427+
usage="[options] [modules]",
428+
description="Generate a JSON report of coverage results."
429+
),
405430
}
406431

407432

@@ -565,6 +590,14 @@ def command_line(self, argv):
565590
elif options.action == "xml":
566591
outfile = options.outfile
567592
total = self.coverage.xml_report(outfile=outfile, **report_args)
593+
elif options.action == "json":
594+
outfile = options.outfile
595+
total = self.coverage.json_report(
596+
outfile=outfile,
597+
pretty_print=options.pretty_print,
598+
show_contexts=options.show_contexts,
599+
**report_args
600+
)
568601

569602
if total is not None:
570603
# Apply the command line fail-under options, and then use the config
@@ -752,6 +785,7 @@ def unglob_args(args):
752785
erase Erase previously collected coverage data.
753786
help Get help on using coverage.py.
754787
html Create an HTML report.
788+
json Create a JSON report of coverage results.
755789
report Report coverage stats on modules.
756790
run Run a Python program and measure code execution.
757791
xml Create an XML report of coverage results.

coverage/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,11 @@ def __init__(self):
215215
self.xml_output = "coverage.xml"
216216
self.xml_package_depth = 99
217217

218+
# Defaults for [JSON]
219+
self.json_output = "coverage.json"
220+
self.json_pretty_print = False
221+
self.json_show_contexts = False
222+
218223
# Defaults for [paths]
219224
self.paths = {}
220225

@@ -363,6 +368,11 @@ def from_file(self, filename, our_file):
363368
# [xml]
364369
('xml_output', 'xml:output'),
365370
('xml_package_depth', 'xml:package_depth', 'int'),
371+
372+
# [json]
373+
('json_output', 'json:output'),
374+
('json_pretty_print', 'json:pretty_print', 'boolean'),
375+
('json_show_contexts', 'json:show_contexts', 'boolean'),
366376
]
367377

368378
def _set_attr_from_config_option(self, cp, attr, where, type_=''):

coverage/control.py

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
from coverage.files import PathAliases, set_relative_directory, abs_file
2323
from coverage.html import HtmlReporter
2424
from coverage.inorout import InOrOut
25+
from coverage.jsonreport import JsonReporter
2526
from coverage.misc import CoverageException, bool_or_none, join_regex
26-
from coverage.misc import ensure_dir_for_file, file_be_gone, isolate_module
27+
from coverage.misc import ensure_dir_for_file, isolate_module
2728
from coverage.plugin import FileReporter
2829
from coverage.plugin_support import Plugins
2930
from coverage.python import PythonFileReporter
31+
from coverage.report import render_report
3032
from coverage.results import Analysis, Numbers
3133
from coverage.summary import SummaryReporter
3234
from coverage.xmlreport import XmlReporter
@@ -862,33 +864,29 @@ def xml_report(
862864
ignore_errors=ignore_errors, report_omit=omit, report_include=include,
863865
xml_output=outfile, report_contexts=contexts,
864866
)
865-
file_to_close = None
866-
delete_file = False
867-
if self.config.xml_output:
868-
if self.config.xml_output == '-':
869-
outfile = sys.stdout
870-
else:
871-
# Ensure that the output directory is created; done here
872-
# because this report pre-opens the output file.
873-
# HTMLReport does this using the Report plumbing because
874-
# its task is more complex, being multiple files.
875-
ensure_dir_for_file(self.config.xml_output)
876-
open_kwargs = {}
877-
if env.PY3:
878-
open_kwargs['encoding'] = 'utf8'
879-
outfile = open(self.config.xml_output, "w", **open_kwargs)
880-
file_to_close = outfile
881-
try:
882-
reporter = XmlReporter(self)
883-
return reporter.report(morfs, outfile=outfile)
884-
except CoverageException:
885-
delete_file = True
886-
raise
887-
finally:
888-
if file_to_close:
889-
file_to_close.close()
890-
if delete_file:
891-
file_be_gone(self.config.xml_output)
867+
return render_report(self.config.xml_output, XmlReporter(self), morfs)
868+
869+
def json_report(
870+
self, morfs=None, outfile=None, ignore_errors=None,
871+
omit=None, include=None, contexts=None, pretty_print=None,
872+
show_contexts=None
873+
):
874+
"""Generate a JSON report of coverage results.
875+
876+
Each module in `morfs` is included in the report. `outfile` is the
877+
path to write the file to, "-" will write to stdout.
878+
879+
See :meth:`report` for other arguments.
880+
881+
Returns a float, the total percentage covered.
882+
883+
"""
884+
self.config.from_args(
885+
ignore_errors=ignore_errors, report_omit=omit, report_include=include,
886+
json_output=outfile, report_contexts=contexts, json_pretty_print=pretty_print,
887+
json_show_contexts=show_contexts
888+
)
889+
return render_report(self.config.json_output, JsonReporter(self), morfs)
892890

893891
def sys_info(self):
894892
"""Return a list of (key, value) pairs showing internal information."""

coverage/jsonreport.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# coding: utf-8
2+
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
3+
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
4+
5+
"""Json reporting for coverage.py"""
6+
import datetime
7+
import json
8+
import sys
9+
10+
from coverage import __version__
11+
from coverage.report import get_analysis_to_report
12+
from coverage.results import Numbers
13+
14+
15+
class JsonReporter(object):
16+
"""A reporter for writing JSON coverage results."""
17+
18+
def __init__(self, coverage):
19+
self.coverage = coverage
20+
self.config = self.coverage.config
21+
self.total = Numbers()
22+
self.report_data = {}
23+
24+
def report(self, morfs, outfile=None):
25+
"""Generate a json report for `morfs`.
26+
27+
`morfs` is a list of modules or file names.
28+
29+
`outfile` is a file object to write the json to
30+
31+
"""
32+
outfile = outfile or sys.stdout
33+
coverage_data = self.coverage.get_data()
34+
coverage_data.set_query_contexts(self.config.report_contexts)
35+
self.report_data["meta"] = {
36+
"version": __version__,
37+
"timestamp": datetime.datetime.now().isoformat(),
38+
"branch_coverage": coverage_data.has_arcs(),
39+
"show_contexts": self.config.json_show_contexts,
40+
}
41+
42+
measured_files = {}
43+
for file_reporter, analysis in get_analysis_to_report(self.coverage, morfs):
44+
measured_files[file_reporter.relative_filename()] = self.report_one_file(
45+
coverage_data,
46+
file_reporter,
47+
analysis
48+
)
49+
50+
self.report_data["files"] = measured_files
51+
52+
self.report_data["totals"] = {
53+
'covered_lines': self.total.n_executed,
54+
'num_statements': self.total.n_statements,
55+
'percent_covered': self.total.pc_covered,
56+
'missing_lines': self.total.n_missing,
57+
'excluded_lines': self.total.n_excluded,
58+
}
59+
60+
if coverage_data.has_arcs():
61+
self.report_data["totals"].update({
62+
'num_branches': self.total.n_branches,
63+
'num_partial_branches': self.total.n_partial_branches,
64+
})
65+
66+
json.dump(
67+
self.report_data,
68+
outfile,
69+
indent=4 if self.config.json_pretty_print else None
70+
)
71+
72+
return self.total.n_statements and self.total.pc_covered
73+
74+
def report_one_file(self, coverage_data, file_reporter, analysis):
75+
"""Extract the relevant report data for a single file"""
76+
nums = analysis.numbers
77+
self.total += nums
78+
summary = {
79+
'covered_lines': nums.n_executed,
80+
'num_statements': nums.n_statements,
81+
'percent_covered': nums.pc_covered,
82+
'missing_lines': nums.n_missing,
83+
'excluded_lines': nums.n_excluded,
84+
}
85+
reported_file = {
86+
'executed_lines': sorted(analysis.executed),
87+
'summary': summary,
88+
'missing_lines': sorted(analysis.missing),
89+
'excluded_lines': sorted(analysis.excluded)
90+
}
91+
if self.config.json_show_contexts:
92+
reported_file['contexts'] = analysis.data.contexts_by_lineno(
93+
file_reporter.filename
94+
)
95+
if coverage_data.has_arcs():
96+
reported_file['summary'].update({
97+
'num_branches': nums.n_branches,
98+
'num_partial_branches': nums.n_partial_branches,
99+
})
100+
return reported_file

coverage/report.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,45 @@
22
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
33

44
"""Reporter foundation for coverage.py."""
5+
import sys
56

7+
from coverage import env
68
from coverage.files import prep_patterns, FnmatchMatcher
7-
from coverage.misc import CoverageException, NoSource, NotPython
9+
from coverage.misc import CoverageException, NoSource, NotPython, ensure_dir_for_file, file_be_gone
10+
11+
12+
def render_report(output_path, reporter, morfs):
13+
"""Run the provided reporter ensuring any required setup and cleanup is done
14+
15+
At a high level this method ensures the output file is ready to be written to. Then writes the
16+
report to it. Then closes the file and deletes any garbage created if necessary.
17+
"""
18+
file_to_close = None
19+
delete_file = False
20+
if output_path:
21+
if output_path == '-':
22+
outfile = sys.stdout
23+
else:
24+
# Ensure that the output directory is created; done here
25+
# because this report pre-opens the output file.
26+
# HTMLReport does this using the Report plumbing because
27+
# its task is more complex, being multiple files.
28+
ensure_dir_for_file(output_path)
29+
open_kwargs = {}
30+
if env.PY3:
31+
open_kwargs['encoding'] = 'utf8'
32+
outfile = open(output_path, "w", **open_kwargs)
33+
file_to_close = outfile
34+
try:
35+
return reporter.report(morfs, outfile=outfile)
36+
except CoverageException:
37+
delete_file = True
38+
raise
39+
finally:
40+
if file_to_close:
41+
file_to_close.close()
42+
if delete_file:
43+
file_be_gone(output_path)
844

945

1046
def get_analysis_to_report(coverage, morfs):

coverage/results.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ def __init__(self, data, file_reporter):
2323
# Identify missing statements.
2424
executed = self.data.lines(self.filename) or []
2525
executed = self.file_reporter.translate_lines(executed)
26-
self.missing = self.statements - executed
26+
self.executed = executed
27+
self.missing = self.statements - self.executed
2728

2829
if self.data.has_arcs():
2930
self._arc_possibilities = sorted(self.file_reporter.arcs())

0 commit comments

Comments
 (0)