blob: 05adbe579a8f25ac60a54ef146443b6f695cf58a [file] [log] [blame]
Joe Onorato88ede352023-12-19 02:56:38 +00001#!/usr/bin/env python3
2# Copyright (C) 2023 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16import sys
17if __name__ == "__main__":
18 sys.dont_write_bytecode = True
19
20import argparse
21import dataclasses
22import datetime
23import json
24import os
25import pathlib
Joe Onorato01277d42023-12-20 04:00:12 +000026import random
27import re
Joe Onorato88ede352023-12-19 02:56:38 +000028import shutil
29import subprocess
30import time
Joe Onorato01277d42023-12-20 04:00:12 +000031import uuid
Joe Onorato88ede352023-12-19 02:56:38 +000032
33import pretty
34import utils
35
36
37class FatalError(Exception):
38 def __init__(self):
39 pass
40
41
42class OptionsError(Exception):
43 def __init__(self, message):
44 self.message = message
45
46
47@dataclasses.dataclass(frozen=True)
48class Lunch:
49 "Lunch combination"
50
51 target_product: str
52 "TARGET_PRODUCT"
53
54 target_release: str
55 "TARGET_RELEASE"
56
57 target_build_variant: str
58 "TARGET_BUILD_VARIANT"
59
60 def ToDict(self):
61 return {
62 "TARGET_PRODUCT": self.target_product,
63 "TARGET_RELEASE": self.target_release,
64 "TARGET_BUILD_VARIANT": self.target_build_variant,
65 }
66
67 def Combine(self):
68 return f"{self.target_product}-{self.target_release}-{self.target_build_variant}"
69
70
71@dataclasses.dataclass(frozen=True)
72class Change:
73 "A change that we make to the tree, and how to undo it"
74 label: str
75 "String to print in the log when the change is made"
76
77 change: callable
78 "Function to change the source tree"
79
80 undo: callable
81 "Function to revert the source tree to its previous condition in the most minimal way possible."
82
83
84@dataclasses.dataclass(frozen=True)
85class Benchmark:
86 "Something we measure"
87
88 id: str
89 "Short ID for the benchmark, for the command line"
90
91 title: str
92 "Title for reports"
93
94 change: Change
95 "Source tree modification for the benchmark that will be measured"
96
97 modules: list[str]
98 "Build modules to build on soong command line"
99
100 preroll: int
101 "Number of times to run the build command to stabilize"
102
103 postroll: int
104 "Number of times to run the build command after reverting the action to stabilize"
105
106
107@dataclasses.dataclass(frozen=True)
108class FileSnapshot:
109 "Snapshot of a file's contents."
110
111 filename: str
112 "The file that was snapshottened"
113
114 contents: str
115 "The contents of the file"
116
117 def write(self):
118 "Write the contents back to the file"
119 with open(self.filename, "w") as f:
120 f.write(self.contents)
121
122
123def Snapshot(filename):
124 """Return a FileSnapshot with the file's current contents."""
125 with open(filename) as f:
126 contents = f.read()
127 return FileSnapshot(filename, contents)
128
129
130def Clean():
131 """Remove the out directory."""
132 def remove_out():
133 if os.path.exists("out"):
134 shutil.rmtree("out")
135 return Change(label="Remove out", change=remove_out, undo=lambda: None)
136
137
138def NoChange():
139 """No change to the source tree."""
140 return Change(label="No change", change=lambda: None, undo=lambda: None)
141
142
Joe Onorato01277d42023-12-20 04:00:12 +0000143def Create(filename):
144 "Create an action to create `filename`. The parent directory must exist."
145 def create():
146 with open(filename, "w") as f:
147 pass
148 def delete():
149 os.remove(filename)
150 return Change(
151 label=f"Create {filename}",
152 change=create,
153 undo=delete,
154 )
155
156
Joe Onorato88ede352023-12-19 02:56:38 +0000157def Modify(filename, contents, before=None):
Joe Onorato01277d42023-12-20 04:00:12 +0000158 """Create an action to modify `filename` by appending the result of `contents`
159 before the last instances of `before` in the file.
Joe Onorato88ede352023-12-19 02:56:38 +0000160
161 Raises an error if `before` doesn't appear in the file.
162 """
163 orig = Snapshot(filename)
164 if before:
165 index = orig.contents.rfind(before)
166 if index < 0:
167 report_error(f"{filename}: Unable to find string '{before}' for modify operation.")
168 raise FatalError()
169 else:
170 index = len(orig.contents)
Joe Onorato01277d42023-12-20 04:00:12 +0000171 modified = FileSnapshot(filename, orig.contents[:index] + contents() + orig.contents[index:])
172 if False:
173 print(f"Modify: {filename}")
174 x = orig.contents.replace("\n", "\n ORIG")
175 print(f" ORIG {x}")
176 x = modified.contents.replace("\n", "\n MODIFIED")
177 print(f" MODIFIED {x}")
178
Joe Onorato88ede352023-12-19 02:56:38 +0000179 return Change(
180 label="Modify " + filename,
181 change=lambda: modified.write(),
182 undo=lambda: orig.write()
183 )
184
Joe Onorato01277d42023-12-20 04:00:12 +0000185def AddJavaField(filename, prefix):
186 return Modify(filename,
187 lambda: f"{prefix} static final int BENCHMARK = {random.randint(0, 1000000)};\n",
188 before="}")
189
190
191def Comment(prefix, suffix=""):
192 return lambda: prefix + " " + str(uuid.uuid4()) + suffix
193
Joe Onorato88ede352023-12-19 02:56:38 +0000194
195class BenchmarkReport():
196 "Information about a run of the benchmark"
197
198 lunch: Lunch
199 "lunch combo"
200
201 benchmark: Benchmark
202 "The benchmark object."
203
204 iteration: int
205 "Which iteration of the benchmark"
206
207 log_dir: str
208 "Path the the log directory, relative to the root of the reports directory"
209
210 preroll_duration_ns: [int]
211 "Durations of the in nanoseconds."
212
213 duration_ns: int
214 "Duration of the measured portion of the benchmark in nanoseconds."
215
216 postroll_duration_ns: [int]
217 "Durations of the postrolls in nanoseconds."
218
219 complete: bool
220 "Whether the benchmark made it all the way through the postrolls."
221
222 def __init__(self, lunch, benchmark, iteration, log_dir):
223 self.lunch = lunch
224 self.benchmark = benchmark
225 self.iteration = iteration
226 self.log_dir = log_dir
227 self.preroll_duration_ns = []
228 self.duration_ns = -1
229 self.postroll_duration_ns = []
230 self.complete = False
231
232 def ToDict(self):
233 return {
234 "lunch": self.lunch.ToDict(),
235 "id": self.benchmark.id,
236 "title": self.benchmark.title,
237 "modules": self.benchmark.modules,
238 "change": self.benchmark.change.label,
239 "iteration": self.iteration,
240 "log_dir": self.log_dir,
241 "preroll_duration_ns": self.preroll_duration_ns,
242 "duration_ns": self.duration_ns,
243 "postroll_duration_ns": self.postroll_duration_ns,
244 "complete": self.complete,
245 }
246
247class Runner():
248 """Runs the benchmarks."""
249
250 def __init__(self, options):
251 self._options = options
252 self._reports = []
253 self._complete = False
254
255 def Run(self):
256 """Run all of the user-selected benchmarks."""
257 # Clean out the log dir or create it if necessary
258 prepare_log_dir(self._options.LogDir())
259
260 try:
261 for lunch in self._options.Lunches():
262 print(lunch)
263 for benchmark in self._options.Benchmarks():
264 for iteration in range(self._options.Iterations()):
265 self._run_benchmark(lunch, benchmark, iteration)
266 self._complete = True
267 finally:
268 self._write_summary()
269
270
271 def _run_benchmark(self, lunch, benchmark, iteration):
272 """Run a single benchmark."""
273 benchmark_log_subdir = self._log_dir(lunch, benchmark, iteration)
274 benchmark_log_dir = self._options.LogDir().joinpath(benchmark_log_subdir)
275
276 sys.stderr.write(f"STARTING BENCHMARK: {benchmark.id}\n")
277 sys.stderr.write(f" lunch: {lunch.Combine()}\n")
278 sys.stderr.write(f" iteration: {iteration}\n")
279 sys.stderr.write(f" benchmark_log_dir: {benchmark_log_dir}\n")
280
281 report = BenchmarkReport(lunch, benchmark, iteration, benchmark_log_subdir)
282 self._reports.append(report)
283
284 # Preroll builds
285 for i in range(benchmark.preroll):
286 ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"pre_{i}"), benchmark.modules)
287 report.preroll_duration_ns.append(ns)
288
289 sys.stderr.write(f"PERFORMING CHANGE: {benchmark.change.label}\n")
290 if not self._options.DryRun():
291 benchmark.change.change()
292 try:
293
294 # Measured build
295 ns = self._run_build(lunch, benchmark_log_dir.joinpath("measured"), benchmark.modules)
296 report.duration_ns = ns
297
Joe Onorato05434642023-12-20 05:00:04 +0000298 dist_one = self._options.DistOne()
299 if dist_one:
300 # If we're disting just one benchmark, save the logs and we can stop here.
301 self._dist(dist_one)
302 else:
303 # Postroll builds
304 for i in range(benchmark.preroll):
305 ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"post_{i}"),
306 benchmark.modules)
307 report.postroll_duration_ns.append(ns)
Joe Onorato88ede352023-12-19 02:56:38 +0000308
309 finally:
310 # Always undo, even if we crashed or the build failed and we stopped.
311 sys.stderr.write(f"UNDOING CHANGE: {benchmark.change.label}\n")
312 if not self._options.DryRun():
313 benchmark.change.undo()
314
315 self._write_summary()
316 sys.stderr.write(f"FINISHED BENCHMARK: {benchmark.id}\n")
317
318 def _log_dir(self, lunch, benchmark, iteration):
319 """Construct the log directory fir a benchmark run."""
320 path = f"{lunch.Combine()}/{benchmark.id}"
321 # Zero pad to the correct length for correct alpha sorting
322 path += ("/%0" + str(len(str(self._options.Iterations()))) + "d") % iteration
323 return path
324
325 def _run_build(self, lunch, build_log_dir, modules):
326 """Builds the modules. Saves interesting log files to log_dir. Raises FatalError
327 if the build fails.
328 """
329 sys.stderr.write(f"STARTING BUILD {modules}\n")
330
331 before_ns = time.perf_counter_ns()
332 if not self._options.DryRun():
333 cmd = [
334 "build/soong/soong_ui.bash",
335 "--build-mode",
336 "--all-modules",
337 f"--dir={self._options.root}",
Joe Onorato7eba1582024-01-03 10:28:41 -0800338 "--skip-metrics-upload",
Joe Onorato88ede352023-12-19 02:56:38 +0000339 ] + modules
340 env = dict(os.environ)
341 env["TARGET_PRODUCT"] = lunch.target_product
342 env["TARGET_RELEASE"] = lunch.target_release
343 env["TARGET_BUILD_VARIANT"] = lunch.target_build_variant
344 returncode = subprocess.call(cmd, env=env)
345 if returncode != 0:
346 report_error(f"Build failed: {' '.join(cmd)}")
347 raise FatalError()
348
349 after_ns = time.perf_counter_ns()
350
351 # TODO: Copy some log files.
352
353 sys.stderr.write(f"FINISHED BUILD {modules}\n")
354
355 return after_ns - before_ns
356
Joe Onorato05434642023-12-20 05:00:04 +0000357 def _dist(self, dist_dir):
358 out_dir = pathlib.Path("out")
359 dest_dir = pathlib.Path(dist_dir).joinpath("logs")
360 os.makedirs(dest_dir, exist_ok=True)
361 basenames = [
362 "build.trace.gz",
363 "soong.log",
364 "soong_build_metrics.pb",
365 "soong_metrics",
366 ]
367 for base in basenames:
368 src = out_dir.joinpath(base)
369 if src.exists():
370 sys.stderr.write(f"DIST: copied {src} to {dest_dir}\n")
371 shutil.copy(src, dest_dir)
372
Joe Onorato88ede352023-12-19 02:56:38 +0000373 def _write_summary(self):
374 # Write the results, even if the build failed or we crashed, including
375 # whether we finished all of the benchmarks.
376 data = {
377 "start_time": self._options.Timestamp().isoformat(),
378 "branch": self._options.Branch(),
379 "tag": self._options.Tag(),
380 "benchmarks": [report.ToDict() for report in self._reports],
381 "complete": self._complete,
382 }
383 with open(self._options.LogDir().joinpath("summary.json"), "w", encoding="utf-8") as f:
384 json.dump(data, f, indent=2, sort_keys=True)
385
386
387def benchmark_table(benchmarks):
388 rows = [("ID", "DESCRIPTION", "REBUILD"),]
389 rows += [(benchmark.id, benchmark.title, " ".join(benchmark.modules)) for benchmark in
390 benchmarks]
391 return rows
392
393
394def prepare_log_dir(directory):
395 if os.path.exists(directory):
396 # If it exists and isn't a directory, fail.
397 if not os.path.isdir(directory):
398 report_error(f"Log directory already exists but isn't a directory: {directory}")
399 raise FatalError()
400 # Make sure the directory is empty. Do this rather than deleting it to handle
401 # symlinks cleanly.
402 for filename in os.listdir(directory):
403 entry = os.path.join(directory, filename)
404 if os.path.isdir(entry):
405 shutil.rmtree(entry)
406 else:
407 os.unlink(entry)
408 else:
409 # Create it
410 os.makedirs(directory)
411
412
413class Options():
414 def __init__(self):
415 self._had_error = False
416
417 # Wall time clock when we started
418 self._timestamp = datetime.datetime.now(datetime.timezone.utc)
419
420 # Move to the root of the tree right away. Everything must happen from there.
421 self.root = utils.get_root()
422 if not self.root:
423 report_error("Unable to find root of tree from cwd.")
424 raise FatalError()
425 os.chdir(self.root)
426
427 # Initialize the Benchmarks. Note that this pre-loads all of the files, etc.
428 # Doing all that here forces us to fail fast if one of them can't load a required
429 # file, at the cost of a small startup speed. Don't make this do something slow
430 # like scan the whole tree.
431 self._init_benchmarks()
432
433 # Argument parsing
434 epilog = f"""
435benchmarks:
436{pretty.FormatTable(benchmark_table(self._benchmarks), prefix=" ")}
437"""
438
439 parser = argparse.ArgumentParser(
440 prog="benchmarks",
441 allow_abbrev=False, # Don't let people write unsupportable scripts.
442 formatter_class=argparse.RawDescriptionHelpFormatter,
443 epilog=epilog,
444 description="Run build system performance benchmarks.")
445 self.parser = parser
446
447 parser.add_argument("--log-dir",
448 help="Directory for logs. Default is $TOP/../benchmarks/.")
449 parser.add_argument("--dated-logs", action="store_true",
450 help="Append timestamp to log dir.")
451 parser.add_argument("-n", action="store_true", dest="dry_run",
452 help="Dry run. Don't run the build commands but do everything else.")
453 parser.add_argument("--tag",
454 help="Variant of the run, for when there are multiple perf runs.")
455 parser.add_argument("--lunch", nargs="*",
456 help="Lunch combos to test")
457 parser.add_argument("--iterations", type=int, default=1,
458 help="Number of iterations of each test to run.")
459 parser.add_argument("--branch", type=str,
460 help="Specify branch. Otherwise a guess will be made based on repo.")
461 parser.add_argument("--benchmark", nargs="*", default=[b.id for b in self._benchmarks],
462 metavar="BENCHMARKS",
463 help="Benchmarks to run. Default suite will be run if omitted.")
Joe Onorato60c36ad2024-01-02 07:32:54 +0000464 parser.add_argument("--dist-one", action="store_true",
Joe Onorato05434642023-12-20 05:00:04 +0000465 help="Copy logs and metrics to the given dist dir. Requires that only"
466 + " one benchmark be supplied. Postroll steps will be skipped.")
Joe Onorato88ede352023-12-19 02:56:38 +0000467
468 self._args = parser.parse_args()
469
470 self._branch = self._branch()
471 self._log_dir = self._log_dir()
472 self._lunches = self._lunches()
473
474 # Validate the benchmark ids
475 all_ids = [benchmark.id for benchmark in self._benchmarks]
476 bad_ids = [id for id in self._args.benchmark if id not in all_ids]
477 if bad_ids:
478 for id in bad_ids:
479 self._error(f"Invalid benchmark: {id}")
480
Joe Onorato05434642023-12-20 05:00:04 +0000481 # --dist-one requires that only one benchmark be supplied
Joe Onorato60c36ad2024-01-02 07:32:54 +0000482 if self._args.dist_one and len(self.Benchmarks()) != 1:
Joe Onorato05434642023-12-20 05:00:04 +0000483 self._error("--dist-one requires that exactly one --benchmark.")
484
Joe Onorato88ede352023-12-19 02:56:38 +0000485 if self._had_error:
486 raise FatalError()
487
488 def Timestamp(self):
489 return self._timestamp
490
491 def _branch(self):
492 """Return the branch, either from the command line or by guessing from repo."""
493 if self._args.branch:
494 return self._args.branch
495 try:
496 branch = subprocess.check_output(f"cd {self.root}/.repo/manifests"
497 + " && git rev-parse --abbrev-ref --symbolic-full-name @{u}",
498 shell=True, encoding="utf-8")
499 return branch.strip().split("/")[-1]
500 except subprocess.CalledProcessError as ex:
501 report_error("Can't get branch from .repo dir. Specify --branch argument")
502 report_error(str(ex))
503 raise FatalError()
504
505 def Branch(self):
506 return self._branch
507
508 def _log_dir(self):
509 "The log directory to use, based on the current options"
510 if self._args.log_dir:
511 d = pathlib.Path(self._args.log_dir).resolve().absolute()
512 else:
513 d = self.root.joinpath("..", utils.DEFAULT_REPORT_DIR)
514 if self._args.dated_logs:
515 d = d.joinpath(self._timestamp.strftime('%Y-%m-%d'))
516 d = d.joinpath(self._branch)
517 if self._args.tag:
518 d = d.joinpath(self._args.tag)
519 return d.resolve().absolute()
520
521 def LogDir(self):
522 return self._log_dir
523
524 def Benchmarks(self):
525 return [b for b in self._benchmarks if b.id in self._args.benchmark]
526
527 def Tag(self):
528 return self._args.tag
529
530 def DryRun(self):
531 return self._args.dry_run
532
533 def _lunches(self):
534 def parse_lunch(lunch):
535 parts = lunch.split("-")
536 if len(parts) != 3:
537 raise OptionsError(f"Invalid lunch combo: {lunch}")
538 return Lunch(parts[0], parts[1], parts[2])
539 # If they gave lunch targets on the command line use that
540 if self._args.lunch:
541 result = []
542 # Split into Lunch objects
543 for lunch in self._args.lunch:
544 try:
545 result.append(parse_lunch(lunch))
546 except OptionsError as ex:
547 self._error(ex.message)
548 return result
549 # Use whats in the environment
550 product = os.getenv("TARGET_PRODUCT")
551 release = os.getenv("TARGET_RELEASE")
552 variant = os.getenv("TARGET_BUILD_VARIANT")
553 if (not product) or (not release) or (not variant):
554 # If they didn't give us anything, fail rather than guessing. There's no good
555 # default for AOSP.
556 self._error("No lunch combo specified. Either pass --lunch argument or run lunch.")
557 return []
558 return [Lunch(product, release, variant),]
559
560 def Lunches(self):
561 return self._lunches
562
563 def Iterations(self):
564 return self._args.iterations
565
Joe Onorato05434642023-12-20 05:00:04 +0000566 def DistOne(self):
567 return self._args.dist_one
568
Joe Onorato88ede352023-12-19 02:56:38 +0000569 def _init_benchmarks(self):
570 """Initialize the list of benchmarks."""
571 # Assumes that we've already chdired to the root of the tree.
572 self._benchmarks = [
573 Benchmark(id="full",
Joe Onorato01277d42023-12-20 04:00:12 +0000574 title="Full build",
575 change=Clean(),
576 modules=["droid"],
577 preroll=0,
578 postroll=3,
579 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000580 Benchmark(id="nochange",
Joe Onorato01277d42023-12-20 04:00:12 +0000581 title="No change",
582 change=NoChange(),
583 modules=["droid"],
584 preroll=2,
585 postroll=3,
586 ),
587 Benchmark(id="unreferenced",
588 title="Create unreferenced file",
589 change=Create("bionic/unreferenced.txt"),
590 modules=["droid"],
591 preroll=1,
592 postroll=2,
593 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000594 Benchmark(id="modify_bp",
Joe Onorato01277d42023-12-20 04:00:12 +0000595 title="Modify Android.bp",
596 change=Modify("bionic/libc/Android.bp", Comment("//")),
597 modules=["droid"],
598 preroll=1,
599 postroll=3,
600 ),
601 Benchmark(id="modify_stdio",
602 title="Modify stdio.cpp",
603 change=Modify("bionic/libc/stdio/stdio.cpp", Comment("//")),
604 modules=["libc"],
605 preroll=1,
606 postroll=2,
607 ),
608 Benchmark(id="modify_adbd",
609 title="Modify adbd",
610 change=Modify("packages/modules/adb/daemon/main.cpp", Comment("//")),
611 modules=["adbd"],
612 preroll=1,
613 postroll=2,
614 ),
615 Benchmark(id="services_private_field",
616 title="Add private field to ActivityManagerService.java",
617 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
618 "private"),
619 modules=["services"],
620 preroll=1,
621 postroll=2,
622 ),
623 Benchmark(id="services_public_field",
624 title="Add public field to ActivityManagerService.java",
625 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
626 "/** @hide */ public"),
627 modules=["services"],
628 preroll=1,
629 postroll=2,
630 ),
631 Benchmark(id="services_api",
632 title="Add API to ActivityManagerService.javaa",
633 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
634 "@android.annotation.SuppressLint(\"UnflaggedApi\") public"),
635 modules=["services"],
636 preroll=1,
637 postroll=2,
638 ),
639 Benchmark(id="framework_private_field",
640 title="Add private field to Settings.java",
641 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
642 "private"),
643 modules=["framework-minus-apex"],
644 preroll=1,
645 postroll=2,
646 ),
647 Benchmark(id="framework_public_field",
648 title="Add public field to Settings.java",
649 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
650 "/** @hide */ public"),
651 modules=["framework-minus-apex"],
652 preroll=1,
653 postroll=2,
654 ),
655 Benchmark(id="framework_api",
656 title="Add API to Settings.java",
657 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
658 "@android.annotation.SuppressLint(\"UnflaggedApi\") public"),
659 modules=["framework-minus-apex"],
660 preroll=1,
661 postroll=2,
662 ),
663 Benchmark(id="modify_framework_resource",
664 title="Modify framework resource",
665 change=Modify("frameworks/base/core/res/res/values/config.xml",
666 lambda: str(uuid.uuid4()),
667 before="</string>"),
668 modules=["framework-minus-apex"],
669 preroll=1,
670 postroll=2,
671 ),
672 Benchmark(id="add_framework_resource",
673 title="Add framework resource",
674 change=Modify("frameworks/base/core/res/res/values/config.xml",
675 lambda: f"<string name=\"BENCHMARK\">{uuid.uuid4()}</string>",
676 before="</resources>"),
677 modules=["framework-minus-apex"],
678 preroll=1,
679 postroll=2,
680 ),
681 Benchmark(id="add_systemui_field",
682 title="Add SystemUI field",
683 change=AddJavaField("frameworks/base/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java",
684 "public"),
685 modules=["SystemUI"],
686 preroll=1,
687 postroll=2,
688 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000689 ]
690
691 def _error(self, message):
692 report_error(message)
693 self._had_error = True
694
695
696def report_error(message):
697 sys.stderr.write(f"error: {message}\n")
698
699
700def main(argv):
701 try:
702 options = Options()
703 runner = Runner(options)
704 runner.Run()
705 except FatalError:
706 sys.stderr.write(f"FAILED\n")
Yu Liuc6576ad2024-01-16 19:27:45 +0000707 sys.exit(1)
Joe Onorato88ede352023-12-19 02:56:38 +0000708
709
710if __name__ == "__main__":
711 main(sys.argv)