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. 14import csv 15import datetime 16import functools 17import glob 18import logging 19import os 20import re 21import subprocess 22import sys 23from datetime import date 24from pathlib import Path 25from typing import Final 26from typing import Generator 27 28INDICATOR_FILE: Final[str] = 'build/soong/soong_ui.bash' 29METRICS_TABLE: Final[str] = 'metrics.csv' 30SUMMARY_TABLE: Final[str] = 'summary.csv' 31RUN_DIR_PREFIX: Final[str] = 'run' 32BUILD_INFO_JSON: Final[str] = 'build_info.json' 33 34 35@functools.cache 36def _is_important(column) -> bool: 37 patterns = { 38 'description', 'build_type', r'build\.ninja(\.size)?', 'targets', 39 'log', 'actions', 'time', 40 'soong/soong', 'bp2build/', 'symlink_forest/', r'soong_build/\*', 41 r'soong_build/\*\.bazel', 'bp2build/', 'kati/kati build', 'ninja/ninja' 42 } 43 for pattern in patterns: 44 if re.fullmatch(pattern, column): 45 return True 46 return False 47 48 49def get_csv_columns_cmd(d: Path) -> str: 50 """ 51 :param d: the log directory 52 :return: a quick shell command to view columns in metrics.csv 53 """ 54 csv_file = d.joinpath(METRICS_TABLE) 55 return f'head -n 1 "{csv_file.absolute()}" | sed "s/,/\\n/g" | nl' 56 57 58def get_cmd_to_display_tabulated_metrics(d: Path) -> str: 59 """ 60 :param d: the log directory 61 :return: a quick shell command to view some collected metrics 62 """ 63 csv_file = d.joinpath(METRICS_TABLE) 64 headers: list[str] = [] 65 if csv_file.exists(): 66 with open(csv_file) as r: 67 reader = csv.DictReader(r) 68 headers = reader.fieldnames or [] 69 70 columns: list[int] = [i for i, h in enumerate(headers) if _is_important(h)] 71 f = ','.join(str(i + 1) for i in columns) 72 return f'grep -v rebuild- "{csv_file}" | grep -v FAILED | ' \ 73 f'cut -d, -f{f} | column -t -s,' 74 75 76@functools.cache 77def get_top_dir(d: Path = Path('.').absolute()) -> Path: 78 """Get the path to the root of the Android source tree""" 79 top_dir = os.environ.get('ANDROID_BUILD_TOP') 80 if top_dir: 81 logging.info('ANDROID BUILD TOP = %s', d) 82 return Path(top_dir) 83 logging.debug('Checking if Android source tree root is %s', d) 84 if d.parent == d: 85 sys.exit('Unable to find ROOT source directory, specifically,' 86 f'{INDICATOR_FILE} not found anywhere. ' 87 'Try `m nothing` and `repo sync`') 88 if d.joinpath(INDICATOR_FILE).is_file(): 89 logging.info('ANDROID BUILD TOP assumed to be %s', d) 90 return d 91 return get_top_dir(d.parent) 92 93 94@functools.cache 95def get_out_dir() -> Path: 96 out_dir = os.environ.get('OUT_DIR') 97 return Path(out_dir) if out_dir else get_top_dir().joinpath('out') 98 99 100@functools.cache 101def get_default_log_dir() -> Path: 102 return get_top_dir().parent.joinpath( 103 f'timing-{date.today().strftime("%b%d")}') 104 105 106def is_interactive_shell() -> bool: 107 return sys.__stdin__.isatty() and sys.__stdout__.isatty() \ 108 and sys.__stderr__.isatty() 109 110 111# see test_next_path_helper() for examples 112def _next_path_helper(basename: str) -> str: 113 name = re.sub(r'(?<=-)\d+(?=(\..*)?$)', lambda d: str(int(d.group(0)) + 1), 114 basename) 115 if name == basename: 116 name = re.sub(r'(\..*)$', r'-1\1', name, 1) 117 if name == basename: 118 name = f'{name}-1' 119 return name 120 121 122def next_path(path: Path) -> Generator[Path, None, None]: 123 """ 124 :returns a new Path with an increasing number suffix to the name 125 e.g. _to_file('a.txt') = a-5.txt (if a-4.txt already exists) 126 """ 127 path.parent.mkdir(parents=True, exist_ok=True) 128 while True: 129 name = _next_path_helper(path.name) 130 path = path.parent.joinpath(name) 131 if not path.exists(): 132 yield path 133 134 135def has_uncommitted_changes() -> bool: 136 """ 137 effectively a quick 'repo status' that fails fast 138 if any project has uncommitted changes 139 """ 140 for cmd in ['diff', 'diff --staged']: 141 diff = subprocess.run( 142 args=f'repo forall -c git {cmd} --quiet --exit-code'.split(), 143 cwd=get_top_dir(), text=True, 144 stdout=subprocess.DEVNULL, 145 stderr=subprocess.DEVNULL) 146 if diff.returncode != 0: 147 return True 148 return False 149 150 151@functools.cache 152def is_ninja_dry_run(ninja_args: str = None) -> bool: 153 if ninja_args is None: 154 ninja_args = os.environ.get('NINJA_ARGS') or '' 155 ninja_dry_run = re.compile(r'(?:^|\s)-n\b') 156 return ninja_dry_run.search(ninja_args) is not None 157 158 159def count_explanations(process_log_file: Path) -> int: 160 """ 161 Builds are run with '-d explain' flag and ninja's explanations for running 162 build statements (except for phony outputs) are counted. The explanations 163 help debugging. The count is an over-approximation of actions run, but it 164 will be ZERO for a no-op build. 165 """ 166 explanations = 0 167 pattern = re.compile( 168 r'^ninja explain:(?! edge with output .* is a phony output,' 169 r' so is always dirty$)') 170 with open(process_log_file) as f: 171 for line in f: 172 if pattern.match(line): 173 explanations += 1 174 return explanations 175 176 177def is_git_repo(p: Path) -> bool: 178 """checks if p is in a directory that's under git version control""" 179 git = subprocess.run(args=f'git remote'.split(), cwd=p, 180 stdout=subprocess.DEVNULL, 181 stderr=subprocess.DEVNULL) 182 return git.returncode == 0 183 184 185def any_file(pattern: str) -> Path: 186 return any_file_under(get_top_dir(), pattern) 187 188 189def any_file_under(root: Path, pattern: str) -> Path: 190 if pattern.startswith('!'): 191 raise RuntimeError(f'provide a filename instead of {pattern}') 192 d, files = any_match_under(get_top_dir() if root is None else root, pattern) 193 files = [d.joinpath(f) for f in files] 194 try: 195 file = next(f for f in files if f.is_file()) 196 return file 197 except StopIteration: 198 raise RuntimeError(f'no file matched {pattern}') 199 200 201def any_dir_under(root: Path, *patterns: str) -> Path: 202 d, _ = any_match_under(root, *patterns) 203 return d 204 205 206def any_match(*patterns: str) -> (Path, list[str]): 207 return any_match_under(get_top_dir(), *patterns) 208 209 210@functools.cache 211def any_match_under(root: Path, *patterns: str) -> (Path, list[str]): 212 """ 213 :param patterns glob pattern to match or unmatch if starting with "!" 214 :param root the first directory to start searching from 215 :returns the dir and sub-paths matching the pattern 216 """ 217 bfs: list[Path] = [root] 218 while len(bfs) > 0: 219 first = bfs.pop(0) 220 if is_git_repo(first): 221 matches: list[str] = [] 222 for pattern in patterns: 223 negate = pattern.startswith('!') 224 if negate: 225 pattern = pattern.removeprefix('!') 226 try: 227 found_match = next( 228 glob.iglob(pattern, root_dir=first, recursive=True)) 229 except StopIteration: 230 found_match = None 231 if negate and found_match is not None: 232 break 233 if not negate: 234 if found_match is None: 235 break 236 else: 237 matches.append(found_match) 238 else: 239 return Path(first), matches 240 241 def should_visit(c: os.DirEntry) -> bool: 242 return c.is_dir() and not (c.is_symlink() or 243 '.' in c.name or 244 'test' in c.name or 245 Path(c.path) == get_out_dir()) 246 247 children = [Path(c.path) for c in os.scandir(first) if should_visit(c)] 248 children.sort() 249 bfs.extend(children) 250 raise RuntimeError(f'No suitable directory for {patterns}') 251 252 253def hhmmss(t: datetime.timedelta) -> str: 254 """pretty prints time periods, prefers mm:ss.sss and resorts to hh:mm:ss.sss 255 only if t >= 1 hour. 256 Examples: 02:12.231, 00:00.512, 00:01:11.321, 1:12:13.121 257 See unit test for more examples.""" 258 h, f = divmod(t.seconds, 60 * 60) 259 m, f = divmod(f, 60) 260 s = f + t.microseconds / 1000_000 261 return f'{h}:{m:02d}:{s:06.3f}' if h else f'{m:02d}:{s:06.3f}' 262 263 264def period_to_seconds(s: str) -> float: 265 """converts a time period into seconds. The input is expected to be in the 266 format used by hhmmss(). 267 Example: 02:04.000 -> 125.0 268 See unit test for more examples.""" 269 if s == '': 270 return 0.0 271 acc = 0.0 272 while True: 273 [left, *right] = s.split(':', 1) 274 acc = acc * 60 + float(left) 275 if right: 276 s = right[0] 277 else: 278 return acc 279