Skip to content

Commit 7cfa4d6

Browse files
authored
Add coverage reporting tool (#272)
1 parent 16ff9de commit 7cfa4d6

File tree

4 files changed

+201
-0
lines changed

4 files changed

+201
-0
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
- **Experimental:** `glean_parser` has a new subcommand `coverage` to convert raw coverage reports
6+
into something consumable by coverage tools, such as codecov.io
7+
- The path to the file that each metric is defined in is now stored on the
8+
`Metric` object in `defined_in["filepath"]`.
9+
510
## 2.3.0 (2021-02-17)
611

712
- Leverage the `glean_namespace` to provide correct import when building for Javascript.

glean_parser/__main__.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import glean_parser
1717

1818

19+
from . import coverage as mod_coverage
1920
from . import lint
2021
from . import translate as mod_translate
2122
from . import validate_ping
@@ -140,6 +141,54 @@ def glinter(input, allow_reserved, allow_missing_files):
140141
)
141142

142143

144+
@click.command()
145+
@click.option(
146+
"-c",
147+
"--coverage_file",
148+
type=click.Path(exists=True, dir_okay=False, file_okay=True, readable=True),
149+
required=True,
150+
multiple=True,
151+
)
152+
@click.argument(
153+
"metrics_files",
154+
type=click.Path(exists=True, dir_okay=False, file_okay=True, readable=True),
155+
nargs=-1,
156+
)
157+
@click.option(
158+
"-o",
159+
"--output",
160+
type=click.Path(exists=False, dir_okay=False, file_okay=True, writable=True),
161+
required=True,
162+
)
163+
@click.option(
164+
"--format", "-f", type=click.Choice(mod_coverage.OUTPUTTERS.keys()), required=True
165+
)
166+
@click.option(
167+
"--allow-reserved",
168+
is_flag=True,
169+
help=(
170+
"If provided, allow the use of reserved fields. "
171+
"Should only be set when building the Glean library itself."
172+
),
173+
)
174+
def coverage(coverage_file, metrics_files, format, output, allow_reserved):
175+
"""
176+
Produce a coverage analysis file given raw coverage output and a set of
177+
metrics.yaml files.
178+
"""
179+
sys.exit(
180+
mod_coverage.coverage(
181+
[Path(x) for x in coverage_file],
182+
[Path(x) for x in metrics_files],
183+
format,
184+
Path(output),
185+
{
186+
"allow_reserved": allow_reserved,
187+
},
188+
)
189+
)
190+
191+
143192
@click.group()
144193
@click.version_option(glean_parser.__version__, prog_name="glean_parser")
145194
def main(args=None):
@@ -150,6 +199,7 @@ def main(args=None):
150199
main.add_command(translate)
151200
main.add_command(check)
152201
main.add_command(glinter)
202+
main.add_command(coverage)
153203

154204

155205
def main_wrapper(args=None):

glean_parser/coverage.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# -*- coding: utf-8 -*-
2+
3+
# This Source Code Form is subject to the terms of the Mozilla Public
4+
# License, v. 2.0. If a copy of the MPL was not distributed with this
5+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
6+
7+
"""
8+
Produce coverage reports from the raw information produced by the
9+
`GLEAN_TEST_COVERAGE` feature.
10+
"""
11+
12+
import json
13+
from .metrics import ObjectTree
14+
from pathlib import Path
15+
import sys
16+
from typing import Any, Dict, List, Optional, Sequence, Set
17+
18+
19+
from . import parser
20+
from . import util
21+
22+
23+
def _outputter_codecovio(metrics: ObjectTree, output_path: Path):
24+
"""
25+
Output coverage in codecov.io format as defined here:
26+
27+
https://docs.codecov.io/docs/codecov-custom-coverage-format
28+
29+
:param metrics: The tree of metrics, already annotated with coverage by
30+
`_annotate_coverage`.
31+
:param output_path: The file to output to.
32+
"""
33+
coverage: Dict[str, List] = {}
34+
for category in metrics.values():
35+
for metric in category.values():
36+
defined_in = metric.defined_in
37+
if defined_in is not None:
38+
path = defined_in["filepath"]
39+
if path not in coverage:
40+
with open(path) as fd:
41+
nlines = len(list(fd.readlines()))
42+
lines = [None] * nlines
43+
coverage[path] = lines
44+
file_section = coverage[path]
45+
file_section[int(defined_in["line"])] = getattr(metric, "covered", 0)
46+
47+
with open(output_path, "w") as fd:
48+
json.dump({"coverage": coverage}, fd)
49+
50+
51+
OUTPUTTERS = {"codecovio": _outputter_codecovio}
52+
53+
54+
def _annotate_coverage(metrics, coverage_entries):
55+
"""
56+
Annotate each metric with whether it is covered. Sets the attribute
57+
`covered` to 1 on each metric that is covered.
58+
"""
59+
mapping = {}
60+
for category in metrics.values():
61+
for metric in category.values():
62+
mapping[metric.identifier()] = metric
63+
64+
for entry in coverage_entries:
65+
metric_id = _coverage_entry_to_metric_id(entry)
66+
if metric_id in mapping:
67+
mapping[metric_id].covered = 1
68+
69+
70+
def _coverage_entry_to_metric_id(entry: str) -> str:
71+
"""
72+
Convert a coverage entry to a metric id.
73+
74+
Technically, the coverage entries are rkv database keys, so are not just
75+
the metric identifier. This extracts the metric identifier part out.
76+
"""
77+
# If getting a glean error count, report it as covering the metric the
78+
# error occurred in, not the `glean.error.*` metric itself.
79+
if entry.startswith("glean.error."):
80+
entry = entry.split("/")[-1]
81+
# If a labeled metric, strip off the label part
82+
return entry.split("/")[0]
83+
84+
85+
def _read_coverage_entries(coverage_reports: List[Path]) -> Set[str]:
86+
"""
87+
Read coverage entries from one or more files, and deduplicates them.
88+
"""
89+
entries = set()
90+
91+
for coverage_report in coverage_reports:
92+
with open(coverage_report) as fd:
93+
for line in fd.readlines():
94+
entries.add(line.strip())
95+
96+
return entries
97+
98+
99+
def coverage(
100+
coverage_reports: List[Path],
101+
metrics_files: Sequence[Path],
102+
output_format: str,
103+
output_file: Path,
104+
parser_config: Optional[Dict[str, Any]] = None,
105+
file=sys.stderr,
106+
) -> int:
107+
"""
108+
Commandline helper for coverage.
109+
110+
:param coverage_reports: List of coverage report files, output from the
111+
Glean SDK when the `GLEAN_TEST_COVERAGE` environment variable is set.
112+
:param metrics_files: List of Path objects to load metrics from.
113+
:param output_format: The coverage output format to produce. Must be one of
114+
`OUTPUTTERS.keys()`.
115+
:param output_file: Path to output coverage report to.
116+
:param parser_config: Parser configuration object, passed to
117+
`parser.parse_objects`.
118+
:return: Non-zero if there were any errors.
119+
"""
120+
121+
if parser_config is None:
122+
parser_config = {}
123+
124+
if output_format not in OUTPUTTERS:
125+
raise ValueError(f"Unknown outputter {output_format}")
126+
127+
metrics_files = util.ensure_list(metrics_files)
128+
129+
all_objects = parser.parse_objects(metrics_files, parser_config)
130+
131+
if util.report_validation_errors(all_objects):
132+
return 1
133+
134+
entries = _read_coverage_entries(coverage_reports)
135+
136+
_annotate_coverage(all_objects.value, entries)
137+
138+
OUTPUTTERS[output_format](all_objects.value, output_file)
139+
140+
return 0

glean_parser/parser.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ def _instantiate_metrics(
240240
if metric_obj is not None:
241241
metric_obj.no_lint = list(set(metric_obj.no_lint + global_no_lint))
242242

243+
if isinstance(filepath, Path):
244+
metric_obj.defined_in["filepath"] = str(filepath)
245+
243246
already_seen = sources.get((category_key, metric_key))
244247
if already_seen is not None:
245248
# We've seen this metric name already
@@ -299,6 +302,9 @@ def _instantiate_pings(
299302
if ping_obj is not None:
300303
ping_obj.no_lint = list(set(ping_obj.no_lint + global_no_lint))
301304

305+
if isinstance(filepath, Path) and ping_obj.defined_in is not None:
306+
ping_obj.defined_in["filepath"] = str(filepath)
307+
302308
already_seen = sources.get(ping_key)
303309
if already_seen is not None:
304310
# We've seen this ping name already

0 commit comments

Comments
 (0)