aboutsummaryrefslogtreecommitdiff
path: root/pw_build/py/pw_build/generate_report.py
blob: 8703adb65217a93226abd1e1d953f546c4f45b43 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# Copyright 2023 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Generate a coverage report using llvm-cov."""

import argparse
import json
import logging
import sys
import subprocess
from pathlib import Path
from typing import List, Dict, Any

_LOG = logging.getLogger(__name__)


def _parser_args() -> Dict[str, Any]:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        '--llvm-cov-path',
        type=Path,
        required=True,
        help='Path to the llvm-cov binary to use to generate coverage reports.',
    )
    parser.add_argument(
        '--format',
        dest='format_type',
        type=str,
        choices=['text', 'html', 'lcov', 'json'],
        required=True,
        help='Desired output format of the code coverage report.',
    )
    parser.add_argument(
        '--test-metadata-path',
        type=Path,
        required=True,
        help='Path to the *.test_metadata.json file that describes all of the '
        'tests being used to generate a coverage report.',
    )
    parser.add_argument(
        '--profdata-path',
        type=Path,
        required=True,
        help='Path for the output merged profdata file to use with generating a'
        ' coverage report for the tests described in --test-metadata.',
    )
    parser.add_argument(
        '--root-dir',
        type=Path,
        required=True,
        help='Path to the project\'s root directory.',
    )
    parser.add_argument(
        '--build-dir',
        type=Path,
        required=True,
        help='Path to the ninja build directory.',
    )
    parser.add_argument(
        '--output-dir',
        type=Path,
        required=True,
        help='Path to where the output report should be placed. This must be a '
        'relative path (from the current working directory) to ensure the '
        'depfiles are generated correctly.',
    )
    parser.add_argument(
        '--depfile-path',
        type=Path,
        required=True,
        help='Path for the output depfile to convey the extra input '
        'requirements from parsing --test-metadata.',
    )
    parser.add_argument(
        '--filter-path',
        dest='filter_paths',
        type=str,
        action='append',
        default=[],
        help='Only these folder paths or files will be included in the output. '
        'To work properly, these must be aboslute paths or relative paths from '
        'the current working directory. No globs or regular expression features'
        ' are supported.',
    )
    parser.add_argument(
        '--ignore-filename-pattern',
        dest='ignore_filename_patterns',
        type=str,
        action='append',
        default=[],
        help='Any file path that matches one of these regular expression '
        'patterns will be excluded from the output report (possibly even if '
        'that path was included in --filter-paths). The regular expression '
        'engine for these is somewhat primitive and does not support things '
        'like look-ahead or look-behind.',
    )
    return vars(parser.parse_args())


def generate_report(
    llvm_cov_path: Path,
    format_type: str,
    test_metadata_path: Path,
    profdata_path: Path,
    root_dir: Path,
    build_dir: Path,
    output_dir: Path,
    depfile_path: Path,
    filter_paths: List[str],
    ignore_filename_patterns: List[str],
) -> int:
    """Generate a coverage report using llvm-cov."""

    # Ensure directories that need to be absolute are.
    root_dir = root_dir.resolve()
    build_dir = build_dir.resolve()

    # Open the test_metadata_path, parse it to JSON, and extract out the
    # test binaries.
    test_metadata = json.loads(test_metadata_path.read_text())
    test_binaries = [
        Path(obj['test_directory']) / obj['test_name']
        for obj in test_metadata
        if 'test_type' in obj and obj['test_type'] == 'unit_test'
    ]

    # llvm-cov export does not create an output file, so we mimic it by creating
    # the directory structure and writing to file outself after we run the
    # command.
    if format_type in ['lcov', 'json']:
        export_output_path = (
            output_dir / 'report.lcov'
            if format_type == 'lcov'
            else output_dir / 'report.json'
        )
        output_dir.mkdir(parents=True, exist_ok=True)

    # Build the command to the llvm-cov subtool based on provided arguments.
    command = [str(llvm_cov_path)]
    if format_type in ['html', 'text']:
        command += [
            'show',
            '--format',
            format_type,
            '--output-dir',
            str(output_dir),
        ]
    else:  # format_type in ['lcov', 'json']
        command += [
            'export',
            '--format',
            # `text` is JSON format when using `llvm-cov`.
            format_type if format_type == 'lcov' else 'text',
        ]
    # We really need two `--path-equivalence` options to be able to map both the
    # root directory for coverage files to the absolute path of the project
    # root_dir and to be able to map "out/" prefix to the provided build_dir.
    #
    # llvm-cov does not currently support two `--path-equivalence` options, so
    # we use `--compilation-dir` and `--path-equivalence` together. This has the
    # unfortunate consequence of showing file paths as absolute in the JSON,
    # LCOV, and text reports.
    #
    # An unwritten assumption here is that root_dir must be an
    # absolute path to enable file-path-based filtering.
    #
    # This is due to turning all file paths into absolute files here:
    # https://github.com/llvm-mirror/llvm/blob/2c4ca6832fa6b306ee6a7010bfb80a3f2596f824/tools/llvm-cov/CodeCoverage.cpp#L188.
    command += [
        '--compilation-dir',
        str(root_dir),
    ]
    # Pigweed maps any build directory to out, which causes generated files to
    # be reported to exist under the out directory, which may not exist if the
    # build directory is not exactly out. This maps out back to the build
    # directory so generated files can be found.
    command += [
        '--path-equivalence',
        f'{str(root_dir)}/out,{str(build_dir)}',
    ]
    command += [
        '--instr-profile',
        str(profdata_path),
    ]
    command += [
        f'--ignore-filename-regex={path}' for path in ignore_filename_patterns
    ]
    # The test binary positional argument MUST appear before the filter path
    # positional arguments. llvm-cov is a horrible interface.
    command += [str(test_binaries[0])]
    command += [f'--object={binary}' for binary in test_binaries[1:]]
    command += [
        str(Path(filter_path).resolve()) for filter_path in filter_paths
    ]

    _LOG.info('')
    _LOG.info(' '.join(command))
    _LOG.info('')

    # Generate the coverage report by invoking the command.
    if format_type in ['html', 'text']:
        output = subprocess.run(command)
        if output.returncode != 0:
            return output.returncode
    else:  # format_type in ['lcov', 'json']
        output = subprocess.run(command, capture_output=True)
        if output.returncode != 0:
            _LOG.error(output.stderr)
            return output.returncode
        export_output_path.write_bytes(output.stdout)

    # Generate the depfile that describes the dependency on the test binaries
    # used to create the report output.
    depfile_target = Path('.')
    if format_type in ['lcov', 'json']:
        depfile_target = export_output_path
    elif format_type == 'text':
        depfile_target = output_dir / 'index.txt'
    else:  # format_type == 'html'
        depfile_target = output_dir / 'index.html'
    depfile_path.write_text(
        ''.join(
            [
                str(depfile_target),
                ': \\\n',
                *[str(binary) + ' \\\n' for binary in test_binaries],
            ]
        )
    )

    return 0


def main() -> int:
    return generate_report(**_parser_args())


if __name__ == "__main__":
    sys.exit(main())