Skip to content

Commit cbe2e20

Browse files
authored
feat: add "lcov" command for generating LCOV reports
* Add LCOV functionality into coverage.py * Add testing for the LCOV reporter * Add documentation for the LCOV reporter
1 parent 2cc2254 commit cbe2e20

12 files changed

+624
-22
lines changed

coverage/cmdline.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ class Opts:
127127
'', '--pretty-print', action='store_true',
128128
help="Format the JSON for human readers.",
129129
)
130+
lcov = optparse.make_option(
131+
'-o', '', action='store', dest='outfile',
132+
metavar="OUTFILE",
133+
help="Write the LCOV report to this file. Defaults to 'coverage.lcov'"
134+
)
130135
parallel_mode = optparse.make_option(
131136
'-p', '--parallel-mode', action='store_true',
132137
help=(
@@ -473,6 +478,20 @@ def get_prog_name(self):
473478
usage="[options] [modules]",
474479
description="Generate an XML report of coverage results."
475480
),
481+
482+
'lcov': CmdOptionParser(
483+
"lcov",
484+
[
485+
Opts.fail_under,
486+
Opts.ignore_errors,
487+
Opts.include,
488+
Opts.lcov,
489+
Opts.omit,
490+
Opts.quiet,
491+
] + GLOBAL_ARGS,
492+
usage="[options] [modules]",
493+
description="Generate an LCOV report of coverage results."
494+
)
476495
}
477496

478497

@@ -657,6 +676,12 @@ def command_line(self, argv):
657676
show_contexts=options.show_contexts,
658677
**report_args
659678
)
679+
elif options.action == "lcov":
680+
total = self.coverage.lcov_report(
681+
outfile=options.outfile,
682+
**report_args
683+
)
684+
660685
else:
661686
# There are no other possible actions.
662687
raise AssertionError
@@ -854,6 +879,7 @@ def unglob_args(args):
854879
report Report coverage stats on modules.
855880
run Run a Python program and measure code execution.
856881
xml Create an XML report of coverage results.
882+
lcov Create an LCOV report of coverage results.
857883
858884
Use "{program_name} help <command>" for detailed help on any command.
859885
""",

coverage/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ def __init__(self):
227227
self.json_pretty_print = False
228228
self.json_show_contexts = False
229229

230+
# Default output filename for lcov_reporter
231+
self.lcov_output = "coverage.lcov"
232+
230233
# Defaults for [paths]
231234
self.paths = collections.OrderedDict()
232235

coverage/control.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from coverage.html import HtmlReporter
2727
from coverage.inorout import InOrOut
2828
from coverage.jsonreport import JsonReporter
29+
from coverage.lcovreport import LcovReporter
2930
from coverage.misc import bool_or_none, join_regex, human_sorted, human_sorted_items
3031
from coverage.misc import DefaultValue, ensure_dir_for_file, isolate_module
3132
from coverage.plugin import FileReporter
@@ -1049,6 +1050,25 @@ def json_report(
10491050
):
10501051
return render_report(self.config.json_output, JsonReporter(self), morfs, self._message)
10511052

1053+
def lcov_report(
1054+
self, morfs=None, outfile=None, ignore_errors=None,
1055+
omit=None, include=None, contexts=None,
1056+
):
1057+
"""Generate an LCOV report of coverage results.
1058+
1059+
Each module in 'morfs' is included in the report. 'outfile' is the
1060+
path to write the file to, "-" will write to stdout.
1061+
1062+
See :meth 'report' for other arguments.
1063+
1064+
.. versionadded:: 6.3
1065+
"""
1066+
with override_config(self,
1067+
ignore_errors=ignore_errors, report_omit=omit, report_include=include,
1068+
lcov_output=outfile, report_contexts=contexts,
1069+
):
1070+
return render_report(self.config.lcov_output, LcovReporter(self), morfs, self._message)
1071+
10521072
def sys_info(self):
10531073
"""Return a list of (key, value) pairs showing internal information."""
10541074

coverage/lcovreport.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
2+
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
3+
4+
"""LCOV reporting for coverage.py."""
5+
6+
import sys
7+
import base64
8+
from hashlib import md5
9+
10+
from coverage.report import get_analysis_to_report
11+
12+
13+
class LcovReporter:
14+
"""A reporter for writing LCOV coverage reports."""
15+
16+
report_type = "LCOV report"
17+
18+
def __init__(self, coverage):
19+
self.coverage = coverage
20+
self.config = self.coverage.config
21+
22+
def report(self, morfs, outfile=None):
23+
"""Renders the full lcov report
24+
25+
'morfs' is a list of modules or filenames
26+
27+
outfile is the file object to write the file into.
28+
"""
29+
30+
self.coverage.get_data()
31+
outfile = outfile or sys.stdout
32+
33+
for fr, analysis in get_analysis_to_report(self.coverage, morfs):
34+
self.get_lcov(fr, analysis, outfile)
35+
36+
def get_lcov(self, fr, analysis, outfile=None):
37+
"""Produces the lcov data for a single file
38+
39+
get_lcov currently supports both line and branch coverage,
40+
however function coverage is not supported.
41+
42+
"""
43+
44+
outfile.write("TN:\n")
45+
outfile.write(f"SF:{fr.relative_filename()}\n")
46+
source_lines = fr.source().splitlines()
47+
for covered in sorted(analysis.executed):
48+
# Note: Coveragepy currently only supports checking *if* a line has
49+
# been executed, not how many times, so we set this to 1 for nice
50+
# output even if it's technically incorrect
51+
52+
# The lines below calculate a 64 bit encoded md5 hash of the line
53+
# corresponding to the DA lines in the lcov file,
54+
# for either case of the line being covered or missed in Coveragepy
55+
# The final two characters of the encoding ("==") are removed from
56+
# the hash to allow genhtml to run on the resulting lcov file
57+
if source_lines:
58+
line = source_lines[covered - 1].encode("utf-8")
59+
else:
60+
line = b""
61+
hashed = str(base64.b64encode(md5(line).digest())[:-2], encoding="utf-8")
62+
outfile.write(f"DA:{covered},1,{hashed}\n")
63+
for missed in sorted(analysis.missing):
64+
if source_lines:
65+
line = source_lines[missed - 1].encode("utf-8")
66+
else:
67+
line = b""
68+
hashed = str(base64.b64encode(md5(line).digest())[:-2], encoding="utf-8")
69+
outfile.write(f"DA:{missed},0,{hashed}\n")
70+
outfile.write(f"LF:{len(analysis.statements)}\n")
71+
outfile.write(f"LH:{len(analysis.executed)}\n")
72+
73+
# More information dense branch coverage data
74+
missing_arcs = analysis.missing_branch_arcs()
75+
executed_arcs = analysis.executed_branch_arcs()
76+
for block_number, block_line_number in enumerate(
77+
sorted(analysis.branch_stats().keys())
78+
):
79+
for branch_number, line_number in enumerate(
80+
sorted(missing_arcs[block_line_number])
81+
):
82+
# The exit branches have a negative line number,
83+
# this will not produce valid lcov, and so setting
84+
# the line number of the exit branch to 0 will allow
85+
# for valid lcov, while preserving the data
86+
line_number = max(line_number, 0)
87+
outfile.write(f"BRDA:{line_number},{block_number},{branch_number},-\n")
88+
# The start value below allows for the block number to be
89+
# preserved between these two for loops (stopping the loop from
90+
# resetting the value of the block number to 0)
91+
for branch_number, line_number in enumerate(
92+
sorted(executed_arcs[block_line_number]),
93+
start=len(missing_arcs[block_line_number]),
94+
):
95+
line_number = max(line_number, 0)
96+
outfile.write(f"BRDA:{line_number},{block_number},{branch_number},1\n")
97+
98+
# Summary of the branch coverage
99+
if analysis.has_arcs():
100+
branch_stats = analysis.branch_stats()
101+
brf = sum(t for t, k in branch_stats.values())
102+
brh = brf - sum(t - k for t, k in branch_stats.values())
103+
outfile.write(f"BRF:{brf}\n")
104+
outfile.write(f"BRH:{brh}\n")
105+
106+
outfile.write("end_of_record\n")

coverage/results.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,21 @@ def missing_branch_arcs(self):
136136
mba[l1].append(l2)
137137
return mba
138138

139+
@contract(returns='dict(int: list(int))')
140+
def executed_branch_arcs(self):
141+
"""Return arcs that were executed from branch lines.
142+
143+
Returns {l1:[l2a,l2b,...], ...}
144+
145+
"""
146+
executed = self.arcs_executed()
147+
branch_lines = set(self._branch_lines())
148+
eba = collections.defaultdict(list)
149+
for l1, l2 in executed:
150+
if l1 in branch_lines:
151+
eba[l1].append(l2)
152+
return eba
153+
139154
@contract(returns='dict(int: tuple(int, int))')
140155
def branch_stats(self):
141156
"""Get stats about branches.

doc/cmd.rst

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Coverage.py has a number of commands:
5858

5959
* **json** -- :ref:`Produce a JSON report with coverage results <cmd_json>`.
6060

61+
* **lcov** -- :ref:`Produce an LCOV report with coverage results <cmd_lcov>`.
62+
6163
* **annotate** --
6264
:ref:`Annotate source files with coverage results <cmd_annotate>`.
6365

@@ -430,8 +432,8 @@ Reporting
430432
---------
431433

432434
Coverage.py provides a few styles of reporting, with the **report**, **html**,
433-
**annotate**, **json**, and **xml** commands. They share a number of common
434-
options.
435+
**annotate**, **json**, **lcov**, and **xml** commands. They share a number
436+
of common options.
435437

436438
The command-line arguments are module or file names to report on, if you'd like
437439
to report on a subset of the data collected.
@@ -785,6 +787,42 @@ The **json** command writes coverage data to a "coverage.json" file.
785787
You can specify the name of the output file with the ``-o`` switch. The JSON
786788
can be nicely formatted by specifying the ``--pretty-print`` switch.
787789

790+
.. _cmd_lcov:
791+
792+
LCOV reporting: ``coverage lcov``
793+
---------------------------------
794+
795+
The **json** command writes coverage data to a "coverage.lcov" file.
796+
797+
.. [[[cog show_help("lcov") ]]]
798+
.. code::
799+
800+
$ coverage lcov --help
801+
Usage: coverage lcov [options] [modules]
802+
803+
Generate an LCOV report of coverage results.
804+
805+
Options:
806+
--fail-under=MIN Exit with a status of 2 if the total coverage is less
807+
than MIN.
808+
-i, --ignore-errors Ignore errors while reading source files.
809+
--include=PAT1,PAT2,...
810+
Include only files whose paths match one of these
811+
patterns. Accepts shell-style wildcards, which must be
812+
quoted.
813+
-o OUTFILE Write the LCOV report to this file. Defaults to
814+
'coverage.lcov'
815+
--omit=PAT1,PAT2,... Omit files whose paths match one of these patterns.
816+
Accepts shell-style wildcards, which must be quoted.
817+
-q, --quiet Don't print messages about what is happening.
818+
--debug=OPTS Debug options, separated by commas. [env:
819+
COVERAGE_DEBUG]
820+
-h, --help Get help on this command.
821+
--rcfile=RCFILE Specify configuration file. By default '.coveragerc',
822+
'setup.cfg', 'tox.ini', and 'pyproject.toml' are
823+
tried. [env: COVERAGE_RCFILE]
824+
.. [[[end]]] (checksum: 4d078e4637e5b507cbb997803a0d4758)
825+
788826
Other common reporting options are described above in :ref:`cmd_reporting`.
789827

790828

doc/dict.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ jquery
107107
json
108108
jython
109109
kwargs
110+
lcov
110111
Mako
111112
matcher
112113
matchers

doc/index.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ Coverage.py can do a number of things:
152152
- It can tell you :ref:`what tests ran which lines <dynamic_contexts>`.
153153

154154
- It can produce reports in a number of formats: :ref:`text <cmd_report>`,
155-
:ref:`HTML <cmd_html>`, :ref:`XML <cmd_xml>`, and :ref:`JSON <cmd_json>`.
155+
:ref:`HTML <cmd_html>`, :ref:`XML <cmd_xml>`, :ref:`LCOV <cmd_lcov>`,
156+
and :ref:`JSON <cmd_json>`.
156157

157158
- For advanced uses, there's an :ref:`API <api>`, and the result data is
158159
available in a :ref:`SQLite database <dbschema>`.

doc/python-coverage.1.txt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ COMMAND OVERVIEW
6767
|command| **xml**
6868
Create an XML report of coverage results.
6969

70+
|command| **lcov**
71+
Create an LCOV report of coverage results.
72+
7073

7174
GLOBAL OPTIONS
7275
==============
@@ -229,6 +232,31 @@ COMMAND REFERENCE
229232
\--show-contexts
230233
Include information about the contexts that executed each line.
231234

235+
**lcov** [ `option` ... ] [ `MODULE` ... ]
236+
237+
Create an LCOV report of the coverage results.
238+
239+
Options:
240+
241+
\--fail-under `MIN`
242+
Exit with a status of 2 if the total coverage is less than `MIN`.
243+
244+
\-i, --ignore-errors
245+
Ignore errors while reading source files.
246+
247+
\-o `OUTFILE`
248+
Write the LCOV report to `OUTFILE`. Defaults to ``coverage.lcov``.
249+
250+
\--include `PATTERN` [ , ... ]
251+
Include only files whose paths match one of these
252+
PATTERNs. Accepts shell-style wildcards, which must be quoted.
253+
254+
\--omit `PATTERN` [ , ... ]
255+
Omit files when their file name matches one of these PATTERNs.
256+
Usually needs quoting on the command line.
257+
258+
\-q, --quiet
259+
Don't print messages about what is happening.
232260

233261
**report** [ `option` ... ] [ `MODULE` ... ]
234262

doc/source.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ reported. Usually you want to see all the code that was measured, but if you
8888
are measuring a large project, you may want to get reports for just certain
8989
parts.
9090

91-
The report commands (``report``, ``html``, ``json``, ``annotate``, and ``xml``)
91+
The report commands (``report``, ``html``, ``json``, ``lcov``, ``annotate``,
92+
and ``xml``)
9293
all take optional ``modules`` arguments, and ``--include`` and ``--omit``
9394
switches. The ``modules`` arguments specify particular modules to report on.
9495
The ``include`` and ``omit`` values are lists of file name patterns, just as

0 commit comments

Comments
 (0)