1# Copyright (C) 2022 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://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,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import argparse
16import dataclasses
17import functools
18import logging
19import os
20import re
21import sys
22import textwrap
23from pathlib import Path
24from typing import Optional
25
26import cuj_catalog
27import util
28from util import BuildType
29
30
31@dataclasses.dataclass(frozen=True)
32class UserInput:
33    build_types: tuple[BuildType, ...]
34    chosen_cujgroups: tuple[int, ...]
35    tag: Optional[str]
36    log_dir: Path
37    no_warmup: bool
38    targets: tuple[str, ...]
39    ci_mode: bool
40
41
42@functools.cache
43def get_user_input() -> UserInput:
44    cujgroups = cuj_catalog.get_cujgroups()
45
46    def validate_cujgroups(input_str: str) -> list[int]:
47        if input_str.isnumeric():
48            i = int(input_str)
49            if 0 <= i < len(cujgroups):
50                return [i]
51            logging.critical(
52                f"Invalid input: {input_str}. "
53                f"Expected an index between 1 and {len(cujgroups)}. "
54                "Try --help to view the list of available CUJs"
55            )
56            raise argparse.ArgumentTypeError()
57        else:
58            pattern = re.compile(input_str)
59            matching_cuj_groups = [
60                i
61                for i, cujgroup in enumerate(cujgroups)
62                if pattern.search(cujgroup.description)
63            ]
64            if len(matching_cuj_groups):
65                return matching_cuj_groups
66            logging.critical(
67                f'Invalid input: "{input_str}" does not match any CUJ. '
68                "Try --help to view the list of available CUJs"
69            )
70            raise argparse.ArgumentTypeError()
71
72    # importing locally here to avoid chances of cyclic import
73    import incremental_build
74
75    p = argparse.ArgumentParser(
76        formatter_class=argparse.RawTextHelpFormatter,
77        description=""
78        + textwrap.dedent(incremental_build.__doc__)
79        + textwrap.dedent(incremental_build.main.__doc__),
80    )
81
82    cuj_list = "\n".join(
83        [f"{i:2}: {cujgroup.description}" for i, cujgroup in enumerate(cujgroups)]
84    )
85    p.add_argument(
86        "-c",
87        "--cujs",
88        nargs="*",
89        type=validate_cujgroups,
90        help="Index number(s) for the CUJ(s) from the following list. "
91        "Or substring matches for the CUJ description."
92        f"Note the ordering will be respected:\n{cuj_list}",
93    )
94    p.add_argument(
95        "--no-warmup",
96        default=False,
97        action="store_true",
98        help="skip warmup builds; this can skew your results for the first CUJ you run.",
99    )
100    p.add_argument(
101        "-t",
102        "--tag",
103        type=str,
104        default="",
105        help="Any additional tag for this set of builds, this helps "
106        "distinguish the new data from previously collected data, "
107        "useful for comparative analysis",
108    )
109
110    log_levels = dict(getattr(logging, "_levelToName")).values()
111    p.add_argument(
112        "-v",
113        "--verbosity",
114        choices=log_levels,
115        default="INFO",
116        help="Log level. Defaults to %(default)s",
117    )
118    default_log_dir = util.get_default_log_dir()
119    p.add_argument(
120        "-l",
121        "--log-dir",
122        type=Path,
123        default=default_log_dir,
124        help=textwrap.dedent(
125            """\
126                Directory for timing logs. Defaults to %(default)s
127                TIPS:
128                  1 Specify a directory outside of the source tree
129                  2 To view key metrics in metrics.csv:
130                {}
131                  3 To view column headers:
132                {}
133            """
134        ).format(
135            textwrap.indent(
136                util.get_cmd_to_display_tabulated_metrics(default_log_dir, False),
137                " " * 4,
138            ),
139            textwrap.indent(util.get_csv_columns_cmd(default_log_dir), " " * 4),
140        ),
141    )
142    def_build_types = [
143        BuildType.SOONG_ONLY,
144    ]
145    p.add_argument(
146        "-b",
147        "--build-types",
148        nargs="+",
149        type=BuildType.from_flag,
150        default=[def_build_types],
151        help=f"Defaults to {[b.to_flag() for b in def_build_types]}. "
152        f"Choose from {[e.name.lower() for e in BuildType]}",
153    )
154    p.add_argument(
155        "--ignore-repo-diff",
156        default=False,
157        action="store_true",
158        help='Skip "repo status" check',
159    )
160    p.add_argument(
161        "--append-csv",
162        default=False,
163        action="store_true",
164        help="Add results to existing spreadsheet",
165    )
166    p.add_argument(
167        "targets",
168        nargs="*",
169        default=["nothing"],
170        help='Targets to run, e.g. "libc adbd". ' "Defaults to %(default)s",
171    )
172    p.add_argument(
173        "--ci-mode",
174        default=False,
175        action="store_true",
176        help="Only use it for CI runs.It will copy the "
177        "first metrics after warmup to the logs directory in CI",
178    )
179
180    options = p.parse_args()
181
182    if options.verbosity:
183        logging.root.setLevel(options.verbosity)
184
185    chosen_cujgroups: tuple[int, ...] = (
186        tuple(int(i) for sublist in options.cujs for i in sublist)
187        if options.cujs
188        else tuple()
189    )
190
191    bazel_labels: list[str] = [
192        target for target in options.targets if target.startswith("//")
193    ]
194    if 0 < len(bazel_labels) < len(options.targets):
195        logging.critical(
196            f"Don't mix bazel labels {bazel_labels} with soong targets "
197            f"{[t for t in options.targets if t not in bazel_labels]}"
198        )
199        sys.exit(1)
200    if os.getenv("BUILD_BROKEN_DISABLE_BAZEL") is not None:
201        raise RuntimeError(
202            f"use -b {BuildType.SOONG_ONLY.to_flag()} "
203            f"instead of BUILD_BROKEN_DISABLE_BAZEL"
204        )
205    build_types: tuple[BuildType, ...] = tuple(
206        BuildType(i) for sublist in options.build_types for i in sublist
207    )
208    if len(bazel_labels) > 0:
209        non_b = [
210            b.name for b in build_types if b != BuildType.B_BUILD and b != BuildType.B_ANDROID
211        ]
212        if len(non_b):
213            raise RuntimeError(f"bazel labels can not be used with {non_b}")
214
215    pretty_str = "\n".join(
216        [f"{i:2}: {cujgroups[i].description}" for i in chosen_cujgroups]
217    )
218    logging.info(f"%d CUJs chosen:\n%s", len(chosen_cujgroups), pretty_str)
219
220    if not options.ignore_repo_diff and util.has_uncommitted_changes():
221        error_message = (
222            "THERE ARE UNCOMMITTED CHANGES (TIP: repo status). "
223            "Use --ignore-repo-diff to skip this check."
224        )
225        if not util.is_interactive_shell():
226            logging.critical(error_message)
227            sys.exit(1)
228        logging.error(error_message)
229        response = input("Continue?[Y/n]")
230        if response.upper() != "Y":
231            sys.exit(1)
232
233    log_dir = Path(options.log_dir).resolve()
234    if not options.append_csv and log_dir.exists():
235        error_message = (
236            f"{log_dir} already exists. "
237            "Use --append-csv to skip this check."
238            "Consider --tag to your new runs"
239        )
240        if not util.is_interactive_shell():
241            logging.critical(error_message)
242            sys.exit(1)
243        logging.error(error_message)
244        response = input("Continue?[Y/n]")
245        if response.upper() != "Y":
246            sys.exit(1)
247
248    if log_dir.is_relative_to(util.get_top_dir()):
249        logging.critical(
250            f"choose a log_dir outside the source tree; "
251            f"'{options.log_dir}' resolves to {log_dir}"
252        )
253        sys.exit(1)
254
255    if options.ci_mode:
256        if len(chosen_cujgroups) > 1:
257            logging.critical(
258                "CI mode can only allow one cuj group. "
259                "Remove --ci-mode flag to skip this check."
260            )
261            sys.exit(1)
262        if len(build_types) > 1:
263            logging.critical(
264                "CI mode can only allow one build type. "
265                "Remove --ci-mode flag to skip this check."
266            )
267            sys.exit(1)
268
269    if options.no_warmup:
270        logging.warning(
271            "WARMUP runs will be skipped. Note this is not advised "
272            "as it gives unreliable results."
273        )
274    return UserInput(
275        build_types=build_types,
276        chosen_cujgroups=chosen_cujgroups,
277        tag=options.tag,
278        log_dir=Path(options.log_dir).resolve(),
279        no_warmup=options.no_warmup,
280        targets=options.targets,
281        ci_mode=options.ci_mode,
282    )
283