• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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