1# Copyright 2019 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""
15bloat is a script which generates a size report card for binary files.
16"""
17
18import argparse
19import logging
20import os
21import subprocess
22import sys
23from typing import List, Iterable, Optional
24
25import pw_cli.log
26
27from pw_bloat.binary_diff import BinaryDiff
28from pw_bloat import bloat_output
29
30_LOG = logging.getLogger(__name__)
31
32
33def parse_args() -> argparse.Namespace:
34    """Parses the script's arguments."""
35    def delimited_list(delimiter: str, items: Optional[int] = None):
36        def _parser(arg: str):
37            args = arg.split(delimiter)
38
39            if items and len(args) != items:
40                raise argparse.ArgumentTypeError(
41                    'Argument must be a '
42                    f'{delimiter}-delimited list with {items} items: "{arg}"')
43
44            return args
45
46        return _parser
47
48    parser = argparse.ArgumentParser(
49        'Generate a size report card for binaries')
50    parser.add_argument('--bloaty-config',
51                        type=delimited_list(';'),
52                        required=True,
53                        help='Data source configuration for Bloaty')
54    parser.add_argument('--full',
55                        action='store_true',
56                        help='Display full bloat breakdown by symbol')
57    parser.add_argument('--labels',
58                        type=delimited_list(';'),
59                        default='',
60                        help='Labels for output binaries')
61    parser.add_argument('--out-dir',
62                        type=str,
63                        required=True,
64                        help='Directory in which to write output files')
65    parser.add_argument('--target',
66                        type=str,
67                        required=True,
68                        help='Build target name')
69    parser.add_argument('--title',
70                        type=str,
71                        default='pw_bloat',
72                        help='Report title')
73    parser.add_argument('--source-filter',
74                        type=str,
75                        help='Bloaty data source filter')
76    parser.add_argument('diff_targets',
77                        type=delimited_list(';', 2),
78                        nargs='+',
79                        metavar='DIFF_TARGET',
80                        help='Binary;base pairs to process')
81
82    return parser.parse_args()
83
84
85def run_bloaty(
86    filename: str,
87    config: str,
88    base_file: Optional[str] = None,
89    data_sources: Iterable[str] = (),
90    extra_args: Iterable[str] = ()
91) -> bytes:
92    """Executes a Bloaty size report on some binary file(s).
93
94    Args:
95        filename: Path to the binary.
96        config: Path to Bloaty config file.
97        base_file: Path to a base binary. If provided, a size diff is performed.
98        data_sources: List of Bloaty data sources for the report.
99        extra_args: Additional command-line arguments to pass to Bloaty.
100
101    Returns:
102        Binary output of the Bloaty invocation.
103
104    Raises:
105        subprocess.CalledProcessError: The Bloaty invocation failed.
106    """
107
108    # TODO(frolv): Point the default bloaty path to a prebuilt in Pigweed.
109    default_bloaty = 'bloaty'
110    bloaty_path = os.getenv('BLOATY_PATH', default_bloaty)
111
112    # yapf: disable
113    cmd = [
114        bloaty_path,
115        '-c', config,
116        '-d', ','.join(data_sources),
117        '--domain', 'vm',
118        filename,
119        *extra_args
120    ]
121    # yapf: enable
122
123    if base_file is not None:
124        cmd.extend(['--', base_file])
125
126    return subprocess.check_output(cmd)
127
128
129def main() -> int:
130    """Program entry point."""
131
132    args = parse_args()
133
134    base_binaries: List[str] = []
135    diff_binaries: List[str] = []
136
137    try:
138        for binary, base in args.diff_targets:
139            diff_binaries.append(binary)
140            base_binaries.append(base)
141    except RuntimeError as err:
142        _LOG.error('%s: %s', sys.argv[0], err)
143        return 1
144
145    data_sources = ['segment_names']
146    if args.full:
147        data_sources.append('fullsymbols')
148
149    # TODO(frolv): CSV output is disabled for full reports as the default Bloaty
150    # breakdown is printed. This script should be modified to print a custom
151    # symbol breakdown in full reports.
152    extra_args = [] if args.full else ['--csv']
153    if args.source_filter:
154        extra_args.extend(['--source-filter', args.source_filter])
155
156    diffs: List[BinaryDiff] = []
157    report = []
158
159    for i, binary in enumerate(diff_binaries):
160        binary_name = (args.labels[i]
161                       if i < len(args.labels) else os.path.basename(binary))
162        try:
163            output = run_bloaty(binary, args.bloaty_config[i],
164                                base_binaries[i], data_sources, extra_args)
165            if not output:
166                continue
167
168            # TODO(frolv): Remove when custom output for full mode is added.
169            if args.full:
170                report.append(binary_name)
171                report.append('-' * len(binary_name))
172                report.append(output.decode())
173                continue
174
175            # Ignore the first row as it displays column names.
176            bloaty_csv = output.decode().splitlines()[1:]
177            diffs.append(BinaryDiff.from_csv(binary_name, bloaty_csv))
178        except subprocess.CalledProcessError:
179            _LOG.error('%s: failed to run diff on %s', sys.argv[0], binary)
180            return 1
181
182    def write_file(filename: str, contents: str) -> None:
183        path = os.path.join(args.out_dir, filename)
184        with open(path, 'w') as output_file:
185            output_file.write(contents)
186        _LOG.debug('Output written to %s', path)
187
188    # TODO(frolv): Remove when custom output for full mode is added.
189    if not args.full:
190        out = bloat_output.TableOutput(args.title,
191                                       diffs,
192                                       charset=bloat_output.LineCharset)
193        report.append(out.diff())
194
195        rst = bloat_output.RstOutput(diffs)
196        write_file(f'{args.target}', rst.diff())
197
198    complete_output = '\n'.join(report) + '\n'
199    write_file(f'{args.target}.txt', complete_output)
200    print(complete_output)
201
202    return 0
203
204
205if __name__ == '__main__':
206    pw_cli.log.install()
207    sys.exit(main())
208