xref: /aosp_15_r20/build/bazel/scripts/incremental_build/util.py (revision 7594170e27e0732bc44b93d1440d87a54b6ffe7c)
1*7594170eSAndroid Build Coastguard Worker# Copyright (C) 2022 The Android Open Source Project
2*7594170eSAndroid Build Coastguard Worker#
3*7594170eSAndroid Build Coastguard Worker# Licensed under the Apache License, Version 2.0 (the "License");
4*7594170eSAndroid Build Coastguard Worker# you may not use this file except in compliance with the License.
5*7594170eSAndroid Build Coastguard Worker# You may obtain a copy of the License at
6*7594170eSAndroid Build Coastguard Worker#
7*7594170eSAndroid Build Coastguard Worker#      http://www.apache.org/licenses/LICENSE-2.0
8*7594170eSAndroid Build Coastguard Worker#
9*7594170eSAndroid Build Coastguard Worker# Unless required by applicable law or agreed to in writing, software
10*7594170eSAndroid Build Coastguard Worker# distributed under the License is distributed on an "AS IS" BASIS,
11*7594170eSAndroid Build Coastguard Worker# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12*7594170eSAndroid Build Coastguard Worker# See the License for the specific language governing permissions and
13*7594170eSAndroid Build Coastguard Worker# limitations under the License.
14*7594170eSAndroid Build Coastguard Workerimport csv
15*7594170eSAndroid Build Coastguard Workerimport dataclasses
16*7594170eSAndroid Build Coastguard Workerimport datetime
17*7594170eSAndroid Build Coastguard Workerimport enum
18*7594170eSAndroid Build Coastguard Workerimport functools
19*7594170eSAndroid Build Coastguard Workerimport json
20*7594170eSAndroid Build Coastguard Workerimport logging
21*7594170eSAndroid Build Coastguard Workerimport os
22*7594170eSAndroid Build Coastguard Workerimport re
23*7594170eSAndroid Build Coastguard Workerimport subprocess
24*7594170eSAndroid Build Coastguard Workerimport sys
25*7594170eSAndroid Build Coastguard Workerfrom datetime import date
26*7594170eSAndroid Build Coastguard Workerfrom pathlib import Path
27*7594170eSAndroid Build Coastguard Workerimport textwrap
28*7594170eSAndroid Build Coastguard Workerfrom typing import Callable
29*7594170eSAndroid Build Coastguard Workerfrom typing import Final
30*7594170eSAndroid Build Coastguard Workerfrom typing import Generator
31*7594170eSAndroid Build Coastguard Workerfrom typing import TypeVar
32*7594170eSAndroid Build Coastguard Worker
33*7594170eSAndroid Build Coastguard WorkerINDICATOR_FILE: Final[str] = "build/soong/soong_ui.bash"
34*7594170eSAndroid Build Coastguard Worker# metrics.csv is written to but not read by this tool.
35*7594170eSAndroid Build Coastguard Worker# It's supposed to be viewed as a spreadsheet that compiles data from multiple
36*7594170eSAndroid Build Coastguard Worker# builds to be analyzed by other external tools.
37*7594170eSAndroid Build Coastguard WorkerMETRICS_TABLE: Final[str] = "metrics.csv"
38*7594170eSAndroid Build Coastguard WorkerRUN_DIR_PREFIX: Final[str] = "run"
39*7594170eSAndroid Build Coastguard WorkerBUILD_INFO_JSON: Final[str] = "build_info.json"
40*7594170eSAndroid Build Coastguard Worker
41*7594170eSAndroid Build Coastguard Worker
42*7594170eSAndroid Build Coastguard Worker@functools.cache
43*7594170eSAndroid Build Coastguard Workerdef _is_important(column) -> bool:
44*7594170eSAndroid Build Coastguard Worker    patterns = {
45*7594170eSAndroid Build Coastguard Worker        "actions",
46*7594170eSAndroid Build Coastguard Worker        r"build_ninja_(?:hash|size)",
47*7594170eSAndroid Build Coastguard Worker        "build_type",
48*7594170eSAndroid Build Coastguard Worker        "cquery_out_size",
49*7594170eSAndroid Build Coastguard Worker        "description",
50*7594170eSAndroid Build Coastguard Worker        "log",
51*7594170eSAndroid Build Coastguard Worker        r"mixed\.enabled",
52*7594170eSAndroid Build Coastguard Worker        "targets",
53*7594170eSAndroid Build Coastguard Worker        # the following are time-based values
54*7594170eSAndroid Build Coastguard Worker        "bp2build",
55*7594170eSAndroid Build Coastguard Worker        r"kati/kati (?:build|package)",
56*7594170eSAndroid Build Coastguard Worker        "ninja/ninja",
57*7594170eSAndroid Build Coastguard Worker        "soong/soong",
58*7594170eSAndroid Build Coastguard Worker        r"soong_build/\*(?:\.bazel)?",
59*7594170eSAndroid Build Coastguard Worker        "symlink_forest",
60*7594170eSAndroid Build Coastguard Worker        "time",
61*7594170eSAndroid Build Coastguard Worker    }
62*7594170eSAndroid Build Coastguard Worker    for pattern in patterns:
63*7594170eSAndroid Build Coastguard Worker        if re.fullmatch(pattern, column):
64*7594170eSAndroid Build Coastguard Worker            return True
65*7594170eSAndroid Build Coastguard Worker    return False
66*7594170eSAndroid Build Coastguard Worker
67*7594170eSAndroid Build Coastguard Worker
68*7594170eSAndroid Build Coastguard Workerclass BuildResult(enum.Enum):
69*7594170eSAndroid Build Coastguard Worker    SUCCESS = enum.auto()
70*7594170eSAndroid Build Coastguard Worker    FAILED = enum.auto()
71*7594170eSAndroid Build Coastguard Worker    TEST_FAILURE = enum.auto()
72*7594170eSAndroid Build Coastguard Worker
73*7594170eSAndroid Build Coastguard Worker
74*7594170eSAndroid Build Coastguard Workerclass BuildType(enum.Enum):
75*7594170eSAndroid Build Coastguard Worker    # see https://docs.python.org/3/library/enum.html#enum.Enum._ignore_
76*7594170eSAndroid Build Coastguard Worker    _ignore_ = "_soong_cmd"
77*7594170eSAndroid Build Coastguard Worker    # _soong_cmd_ will not be listed as an enum constant because of `_ignore_`
78*7594170eSAndroid Build Coastguard Worker    _soong_cmd = ["build/soong/soong_ui.bash", "--make-mode", "--skip-soong-tests"]
79*7594170eSAndroid Build Coastguard Worker
80*7594170eSAndroid Build Coastguard Worker    SOONG_ONLY = [*_soong_cmd, "BUILD_BROKEN_DISABLE_BAZEL=true"]
81*7594170eSAndroid Build Coastguard Worker    MIXED_PROD = [*_soong_cmd, "--bazel-mode"]
82*7594170eSAndroid Build Coastguard Worker    MIXED_STAGING = [*_soong_cmd, "--bazel-mode-staging"]
83*7594170eSAndroid Build Coastguard Worker    B_BUILD = ["build/bazel/bin/b", "build"]
84*7594170eSAndroid Build Coastguard Worker    B_ANDROID = [*B_BUILD, "--config=android"]
85*7594170eSAndroid Build Coastguard Worker
86*7594170eSAndroid Build Coastguard Worker    @staticmethod
87*7594170eSAndroid Build Coastguard Worker    def from_flag(s: str) -> list["BuildType"]:
88*7594170eSAndroid Build Coastguard Worker        chosen: list[BuildType] = []
89*7594170eSAndroid Build Coastguard Worker        for e in BuildType:
90*7594170eSAndroid Build Coastguard Worker            if s.lower() in e.name.lower():
91*7594170eSAndroid Build Coastguard Worker                chosen.append(e)
92*7594170eSAndroid Build Coastguard Worker        if len(chosen) == 0:
93*7594170eSAndroid Build Coastguard Worker            raise RuntimeError(f"no such build type: {s}")
94*7594170eSAndroid Build Coastguard Worker        return chosen
95*7594170eSAndroid Build Coastguard Worker
96*7594170eSAndroid Build Coastguard Worker    def to_flag(self):
97*7594170eSAndroid Build Coastguard Worker        return self.name.lower()
98*7594170eSAndroid Build Coastguard Worker
99*7594170eSAndroid Build Coastguard Worker
100*7594170eSAndroid Build Coastguard WorkerCURRENT_BUILD_TYPE: BuildType
101*7594170eSAndroid Build Coastguard Worker"""global state capturing what the current build type is"""
102*7594170eSAndroid Build Coastguard Worker
103*7594170eSAndroid Build Coastguard Worker
104*7594170eSAndroid Build Coastguard Worker@dataclasses.dataclass
105*7594170eSAndroid Build Coastguard Workerclass BuildInfo:
106*7594170eSAndroid Build Coastguard Worker    actions: int
107*7594170eSAndroid Build Coastguard Worker    bp_size_total: int
108*7594170eSAndroid Build Coastguard Worker    build_ninja_hash: str  # hash
109*7594170eSAndroid Build Coastguard Worker    build_ninja_size: int
110*7594170eSAndroid Build Coastguard Worker    build_result: BuildResult
111*7594170eSAndroid Build Coastguard Worker    build_type: BuildType
112*7594170eSAndroid Build Coastguard Worker    bz_size_total: int
113*7594170eSAndroid Build Coastguard Worker    cquery_out_size: int
114*7594170eSAndroid Build Coastguard Worker    description: str
115*7594170eSAndroid Build Coastguard Worker    product: str
116*7594170eSAndroid Build Coastguard Worker    targets: tuple[str, ...]
117*7594170eSAndroid Build Coastguard Worker    time: datetime.timedelta
118*7594170eSAndroid Build Coastguard Worker    tag: str = None
119*7594170eSAndroid Build Coastguard Worker    rebuild: bool = False
120*7594170eSAndroid Build Coastguard Worker    warmup: bool = False
121*7594170eSAndroid Build Coastguard Worker
122*7594170eSAndroid Build Coastguard Worker
123*7594170eSAndroid Build Coastguard Workerclass CustomEncoder(json.JSONEncoder):
124*7594170eSAndroid Build Coastguard Worker    def default(self, obj):
125*7594170eSAndroid Build Coastguard Worker        if isinstance(obj, BuildInfo):
126*7594170eSAndroid Build Coastguard Worker            return self.default(dataclasses.asdict(obj))
127*7594170eSAndroid Build Coastguard Worker        if isinstance(obj, dict):
128*7594170eSAndroid Build Coastguard Worker            return {k: v for k, v in obj.items() if v is not None}
129*7594170eSAndroid Build Coastguard Worker        if isinstance(obj, datetime.timedelta):
130*7594170eSAndroid Build Coastguard Worker            return hhmmss(obj, decimal_precision=True)
131*7594170eSAndroid Build Coastguard Worker        if isinstance(obj, enum.Enum):
132*7594170eSAndroid Build Coastguard Worker            return obj.name
133*7594170eSAndroid Build Coastguard Worker        return json.JSONEncoder.default(self, obj)
134*7594170eSAndroid Build Coastguard Worker
135*7594170eSAndroid Build Coastguard Worker
136*7594170eSAndroid Build Coastguard Workerdef get_csv_columns_cmd(d: Path) -> str:
137*7594170eSAndroid Build Coastguard Worker    """
138*7594170eSAndroid Build Coastguard Worker    :param d: the log directory
139*7594170eSAndroid Build Coastguard Worker    :return: a quick shell command to view columns in metrics.csv
140*7594170eSAndroid Build Coastguard Worker    """
141*7594170eSAndroid Build Coastguard Worker    csv_file = d.joinpath(METRICS_TABLE).resolve()
142*7594170eSAndroid Build Coastguard Worker    return f'head -n 1 "{csv_file}" | sed "s/,/\\n/g" | less -N'
143*7594170eSAndroid Build Coastguard Worker
144*7594170eSAndroid Build Coastguard Worker
145*7594170eSAndroid Build Coastguard Workerdef get_cmd_to_display_tabulated_metrics(d: Path, ci_mode: bool) -> str:
146*7594170eSAndroid Build Coastguard Worker    """
147*7594170eSAndroid Build Coastguard Worker    :param d: the log directory
148*7594170eSAndroid Build Coastguard Worker    :param ci_mode: if true all top-level events are displayed
149*7594170eSAndroid Build Coastguard Worker    :return: a quick shell command to view some collected metrics
150*7594170eSAndroid Build Coastguard Worker    """
151*7594170eSAndroid Build Coastguard Worker    csv_file = d.joinpath(METRICS_TABLE)
152*7594170eSAndroid Build Coastguard Worker    headers: list[str] = []
153*7594170eSAndroid Build Coastguard Worker    if csv_file.exists():
154*7594170eSAndroid Build Coastguard Worker        with open(csv_file) as r:
155*7594170eSAndroid Build Coastguard Worker            reader = csv.DictReader(r)
156*7594170eSAndroid Build Coastguard Worker            headers = reader.fieldnames or []
157*7594170eSAndroid Build Coastguard Worker
158*7594170eSAndroid Build Coastguard Worker    cols: list[int] = [i + 1 for i, h in enumerate(headers) if _is_important(h)]
159*7594170eSAndroid Build Coastguard Worker    if ci_mode:
160*7594170eSAndroid Build Coastguard Worker        # ci mode contains all information about the top level events
161*7594170eSAndroid Build Coastguard Worker        for i, h in enumerate(headers):
162*7594170eSAndroid Build Coastguard Worker            if re.match(r"^\w+/[^.]+$", h) and i not in cols:
163*7594170eSAndroid Build Coastguard Worker                cols.append(i)
164*7594170eSAndroid Build Coastguard Worker
165*7594170eSAndroid Build Coastguard Worker    if len(cols) == 0:
166*7594170eSAndroid Build Coastguard Worker        # syntactically correct command even if the file doesn't exist
167*7594170eSAndroid Build Coastguard Worker        cols.append(1)
168*7594170eSAndroid Build Coastguard Worker
169*7594170eSAndroid Build Coastguard Worker    f = ",".join(str(i) for i in cols)
170*7594170eSAndroid Build Coastguard Worker    # the sed invocations are to account for
171*7594170eSAndroid Build Coastguard Worker    # https://man7.org/linux/man-pages/man1/column.1.html#BUGS
172*7594170eSAndroid Build Coastguard Worker    # example: if a row were `,,,hi,,,,`
173*7594170eSAndroid Build Coastguard Worker    # the successive sed conversions would be
174*7594170eSAndroid Build Coastguard Worker    #    `,,,hi,,,,` =>
175*7594170eSAndroid Build Coastguard Worker    #    `,--,,hi,--,,--,` =>
176*7594170eSAndroid Build Coastguard Worker    #    `,--,--,hi,--,--,--,` =>
177*7594170eSAndroid Build Coastguard Worker    #    `--,--,--,hi,--,--,--,` =>
178*7594170eSAndroid Build Coastguard Worker    #    `--,--,--,hi,--,--,--,--`
179*7594170eSAndroid Build Coastguard Worker    # Note sed doesn't support lookahead or lookbehinds
180*7594170eSAndroid Build Coastguard Worker    return textwrap.dedent(
181*7594170eSAndroid Build Coastguard Worker        f"""\
182*7594170eSAndroid Build Coastguard Worker        grep -v "WARMUP\\|rebuild-" "{csv_file}" | \\
183*7594170eSAndroid Build Coastguard Worker          sed "s/,,/,--,/g" | \\
184*7594170eSAndroid Build Coastguard Worker          sed "s/,,/,--,/g" | \\
185*7594170eSAndroid Build Coastguard Worker          sed "s/^,/--,/" | \\
186*7594170eSAndroid Build Coastguard Worker          sed "s/,$/,--/" | \\
187*7594170eSAndroid Build Coastguard Worker          cut -d, -f{f} | column -t -s,"""
188*7594170eSAndroid Build Coastguard Worker    )
189*7594170eSAndroid Build Coastguard Worker
190*7594170eSAndroid Build Coastguard Worker
191*7594170eSAndroid Build Coastguard Worker@functools.cache
192*7594170eSAndroid Build Coastguard Workerdef get_top_dir(d: Path = Path(".").resolve()) -> Path:
193*7594170eSAndroid Build Coastguard Worker    """Get the path to the root of the Android source tree"""
194*7594170eSAndroid Build Coastguard Worker    top_dir = os.environ.get("ANDROID_BUILD_TOP")
195*7594170eSAndroid Build Coastguard Worker    if top_dir:
196*7594170eSAndroid Build Coastguard Worker        logging.info("ANDROID BUILD TOP = %s", d)
197*7594170eSAndroid Build Coastguard Worker        return Path(top_dir).resolve()
198*7594170eSAndroid Build Coastguard Worker    logging.debug("Checking if Android source tree root is %s", d)
199*7594170eSAndroid Build Coastguard Worker    if d.parent == d:
200*7594170eSAndroid Build Coastguard Worker        sys.exit(
201*7594170eSAndroid Build Coastguard Worker            "Unable to find ROOT source directory, specifically,"
202*7594170eSAndroid Build Coastguard Worker            f"{INDICATOR_FILE} not found anywhere. "
203*7594170eSAndroid Build Coastguard Worker            "Try `m nothing` and `repo sync`"
204*7594170eSAndroid Build Coastguard Worker        )
205*7594170eSAndroid Build Coastguard Worker    if d.joinpath(INDICATOR_FILE).is_file():
206*7594170eSAndroid Build Coastguard Worker        logging.info("ANDROID BUILD TOP assumed to be %s", d)
207*7594170eSAndroid Build Coastguard Worker        return d
208*7594170eSAndroid Build Coastguard Worker    return get_top_dir(d.parent)
209*7594170eSAndroid Build Coastguard Worker
210*7594170eSAndroid Build Coastguard Worker
211*7594170eSAndroid Build Coastguard Worker@functools.cache
212*7594170eSAndroid Build Coastguard Workerdef get_out_dir() -> Path:
213*7594170eSAndroid Build Coastguard Worker    out_dir = os.environ.get("OUT_DIR")
214*7594170eSAndroid Build Coastguard Worker    return Path(out_dir).resolve() if out_dir else get_top_dir().joinpath("out")
215*7594170eSAndroid Build Coastguard Worker
216*7594170eSAndroid Build Coastguard Worker
217*7594170eSAndroid Build Coastguard Worker@functools.cache
218*7594170eSAndroid Build Coastguard Workerdef get_default_log_dir() -> Path:
219*7594170eSAndroid Build Coastguard Worker    return get_top_dir().parent.joinpath(f'timing-{date.today().strftime("%b%d")}')
220*7594170eSAndroid Build Coastguard Worker
221*7594170eSAndroid Build Coastguard Worker
222*7594170eSAndroid Build Coastguard Workerdef is_interactive_shell() -> bool:
223*7594170eSAndroid Build Coastguard Worker    return (
224*7594170eSAndroid Build Coastguard Worker        sys.__stdin__.isatty() and sys.__stdout__.isatty() and sys.__stderr__.isatty()
225*7594170eSAndroid Build Coastguard Worker    )
226*7594170eSAndroid Build Coastguard Worker
227*7594170eSAndroid Build Coastguard Worker
228*7594170eSAndroid Build Coastguard Worker# see test_next_path_helper() for examples
229*7594170eSAndroid Build Coastguard Workerdef _next_path_helper(basename: str) -> str:
230*7594170eSAndroid Build Coastguard Worker    def padded_suffix(i: int) -> str:
231*7594170eSAndroid Build Coastguard Worker        return f"{i:03d}"
232*7594170eSAndroid Build Coastguard Worker
233*7594170eSAndroid Build Coastguard Worker    # find the sequence digits following "-" and preceding the file extension
234*7594170eSAndroid Build Coastguard Worker    name = re.sub(
235*7594170eSAndroid Build Coastguard Worker        r"(?<=-)(?P<suffix>\d+)$",
236*7594170eSAndroid Build Coastguard Worker        lambda d: padded_suffix(int(d.group("suffix")) + 1),
237*7594170eSAndroid Build Coastguard Worker        basename,
238*7594170eSAndroid Build Coastguard Worker    )
239*7594170eSAndroid Build Coastguard Worker
240*7594170eSAndroid Build Coastguard Worker    if name == basename:
241*7594170eSAndroid Build Coastguard Worker        # basename didn't have any numeric suffix
242*7594170eSAndroid Build Coastguard Worker        name = f"{name}-{padded_suffix(1)}"
243*7594170eSAndroid Build Coastguard Worker    return name
244*7594170eSAndroid Build Coastguard Worker
245*7594170eSAndroid Build Coastguard Worker
246*7594170eSAndroid Build Coastguard Workerdef next_path(path: Path) -> Generator[Path, None, None]:
247*7594170eSAndroid Build Coastguard Worker    """
248*7594170eSAndroid Build Coastguard Worker    Generator for indexed paths
249*7594170eSAndroid Build Coastguard Worker    :returns a new Path with an increasing number suffix to the name
250*7594170eSAndroid Build Coastguard Worker    e.g. _to_file('a.txt') = a-5.txt (if a-4.txt already exists)
251*7594170eSAndroid Build Coastguard Worker    """
252*7594170eSAndroid Build Coastguard Worker    while True:
253*7594170eSAndroid Build Coastguard Worker        name = _next_path_helper(path.name)
254*7594170eSAndroid Build Coastguard Worker        path = path.parent.joinpath(name)
255*7594170eSAndroid Build Coastguard Worker        if not path.exists():
256*7594170eSAndroid Build Coastguard Worker            yield path
257*7594170eSAndroid Build Coastguard Worker
258*7594170eSAndroid Build Coastguard Worker
259*7594170eSAndroid Build Coastguard Workerdef has_uncommitted_changes() -> bool:
260*7594170eSAndroid Build Coastguard Worker    """
261*7594170eSAndroid Build Coastguard Worker    effectively a quick 'repo status' that fails fast
262*7594170eSAndroid Build Coastguard Worker    if any project has uncommitted changes
263*7594170eSAndroid Build Coastguard Worker    """
264*7594170eSAndroid Build Coastguard Worker    for cmd in ["diff", "diff --staged"]:
265*7594170eSAndroid Build Coastguard Worker        diff = subprocess.run(
266*7594170eSAndroid Build Coastguard Worker            f"repo forall -c git {cmd} --quiet --exit-code",
267*7594170eSAndroid Build Coastguard Worker            cwd=get_top_dir(),
268*7594170eSAndroid Build Coastguard Worker            shell=True,
269*7594170eSAndroid Build Coastguard Worker            text=True,
270*7594170eSAndroid Build Coastguard Worker            capture_output=True,
271*7594170eSAndroid Build Coastguard Worker        )
272*7594170eSAndroid Build Coastguard Worker        if diff.returncode != 0:
273*7594170eSAndroid Build Coastguard Worker            logging.error(diff.stderr)
274*7594170eSAndroid Build Coastguard Worker            return True
275*7594170eSAndroid Build Coastguard Worker    return False
276*7594170eSAndroid Build Coastguard Worker
277*7594170eSAndroid Build Coastguard Worker
278*7594170eSAndroid Build Coastguard Workerdef hhmmss(t: datetime.timedelta, decimal_precision: bool = False) -> str:
279*7594170eSAndroid Build Coastguard Worker    """pretty prints time periods, prefers mm:ss.sss and resorts to hh:mm:ss.sss
280*7594170eSAndroid Build Coastguard Worker    only if t >= 1 hour.
281*7594170eSAndroid Build Coastguard Worker    Examples(non_decimal_precision): 02:12, 1:12:13
282*7594170eSAndroid Build Coastguard Worker    Examples(decimal_precision): 02:12.231, 00:00.512, 00:01:11.321, 1:12:13.121
283*7594170eSAndroid Build Coastguard Worker    See unit test for more examples."""
284*7594170eSAndroid Build Coastguard Worker    h, f = divmod(t.seconds, 60 * 60)
285*7594170eSAndroid Build Coastguard Worker    m, f = divmod(f, 60)
286*7594170eSAndroid Build Coastguard Worker    s = f + t.microseconds / 1000_000
287*7594170eSAndroid Build Coastguard Worker    if decimal_precision:
288*7594170eSAndroid Build Coastguard Worker        return f"{h}:{m:02d}:{s:06.3f}" if h else f"{m:02d}:{s:06.3f}"
289*7594170eSAndroid Build Coastguard Worker    else:
290*7594170eSAndroid Build Coastguard Worker        return f"{h}:{m:02}:{s:02.0f}" if h else f"{m:02}:{s:02.0f}"
291*7594170eSAndroid Build Coastguard Worker
292*7594170eSAndroid Build Coastguard Worker
293*7594170eSAndroid Build Coastguard Workerdef period_to_seconds(s: str) -> float:
294*7594170eSAndroid Build Coastguard Worker    """converts a time period into seconds. The input is expected to be in the
295*7594170eSAndroid Build Coastguard Worker    format used by hhmmss().
296*7594170eSAndroid Build Coastguard Worker    Example: 02:04 -> 125
297*7594170eSAndroid Build Coastguard Worker    See unit test for more examples."""
298*7594170eSAndroid Build Coastguard Worker    if s == "":
299*7594170eSAndroid Build Coastguard Worker        return 0.0
300*7594170eSAndroid Build Coastguard Worker    acc = 0.0
301*7594170eSAndroid Build Coastguard Worker    while True:
302*7594170eSAndroid Build Coastguard Worker        [left, *right] = s.split(":", 1)
303*7594170eSAndroid Build Coastguard Worker        acc = acc * 60 + float(left)
304*7594170eSAndroid Build Coastguard Worker        if right:
305*7594170eSAndroid Build Coastguard Worker            s = right[0]
306*7594170eSAndroid Build Coastguard Worker        else:
307*7594170eSAndroid Build Coastguard Worker            return acc
308*7594170eSAndroid Build Coastguard Worker
309*7594170eSAndroid Build Coastguard Worker
310*7594170eSAndroid Build Coastguard WorkerR = TypeVar("R")
311*7594170eSAndroid Build Coastguard Worker
312*7594170eSAndroid Build Coastguard Worker
313*7594170eSAndroid Build Coastguard Workerdef groupby(xs: list[R], key: Callable[[R], str]) -> dict[str, list[R]]:
314*7594170eSAndroid Build Coastguard Worker    grouped: dict[str, list[R]] = {}
315*7594170eSAndroid Build Coastguard Worker    for x in xs:
316*7594170eSAndroid Build Coastguard Worker        grouped.setdefault(key(x), []).append(x)
317*7594170eSAndroid Build Coastguard Worker    return grouped
318