blob: df4c87b3b2e47bb2325f933712b27317393a8860 [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}",
338 ] + modules
339 env = dict(os.environ)
340 env["TARGET_PRODUCT"] = lunch.target_product
341 env["TARGET_RELEASE"] = lunch.target_release
342 env["TARGET_BUILD_VARIANT"] = lunch.target_build_variant
343 returncode = subprocess.call(cmd, env=env)
344 if returncode != 0:
345 report_error(f"Build failed: {' '.join(cmd)}")
346 raise FatalError()
347
348 after_ns = time.perf_counter_ns()
349
350 # TODO: Copy some log files.
351
352 sys.stderr.write(f"FINISHED BUILD {modules}\n")
353
354 return after_ns - before_ns
355
Joe Onorato05434642023-12-20 05:00:04 +0000356 def _dist(self, dist_dir):
357 out_dir = pathlib.Path("out")
358 dest_dir = pathlib.Path(dist_dir).joinpath("logs")
359 os.makedirs(dest_dir, exist_ok=True)
360 basenames = [
361 "build.trace.gz",
362 "soong.log",
363 "soong_build_metrics.pb",
364 "soong_metrics",
365 ]
366 for base in basenames:
367 src = out_dir.joinpath(base)
368 if src.exists():
369 sys.stderr.write(f"DIST: copied {src} to {dest_dir}\n")
370 shutil.copy(src, dest_dir)
371
Joe Onorato88ede352023-12-19 02:56:38 +0000372 def _write_summary(self):
373 # Write the results, even if the build failed or we crashed, including
374 # whether we finished all of the benchmarks.
375 data = {
376 "start_time": self._options.Timestamp().isoformat(),
377 "branch": self._options.Branch(),
378 "tag": self._options.Tag(),
379 "benchmarks": [report.ToDict() for report in self._reports],
380 "complete": self._complete,
381 }
382 with open(self._options.LogDir().joinpath("summary.json"), "w", encoding="utf-8") as f:
383 json.dump(data, f, indent=2, sort_keys=True)
384
385
386def benchmark_table(benchmarks):
387 rows = [("ID", "DESCRIPTION", "REBUILD"),]
388 rows += [(benchmark.id, benchmark.title, " ".join(benchmark.modules)) for benchmark in
389 benchmarks]
390 return rows
391
392
393def prepare_log_dir(directory):
394 if os.path.exists(directory):
395 # If it exists and isn't a directory, fail.
396 if not os.path.isdir(directory):
397 report_error(f"Log directory already exists but isn't a directory: {directory}")
398 raise FatalError()
399 # Make sure the directory is empty. Do this rather than deleting it to handle
400 # symlinks cleanly.
401 for filename in os.listdir(directory):
402 entry = os.path.join(directory, filename)
403 if os.path.isdir(entry):
404 shutil.rmtree(entry)
405 else:
406 os.unlink(entry)
407 else:
408 # Create it
409 os.makedirs(directory)
410
411
412class Options():
413 def __init__(self):
414 self._had_error = False
415
416 # Wall time clock when we started
417 self._timestamp = datetime.datetime.now(datetime.timezone.utc)
418
419 # Move to the root of the tree right away. Everything must happen from there.
420 self.root = utils.get_root()
421 if not self.root:
422 report_error("Unable to find root of tree from cwd.")
423 raise FatalError()
424 os.chdir(self.root)
425
426 # Initialize the Benchmarks. Note that this pre-loads all of the files, etc.
427 # Doing all that here forces us to fail fast if one of them can't load a required
428 # file, at the cost of a small startup speed. Don't make this do something slow
429 # like scan the whole tree.
430 self._init_benchmarks()
431
432 # Argument parsing
433 epilog = f"""
434benchmarks:
435{pretty.FormatTable(benchmark_table(self._benchmarks), prefix=" ")}
436"""
437
438 parser = argparse.ArgumentParser(
439 prog="benchmarks",
440 allow_abbrev=False, # Don't let people write unsupportable scripts.
441 formatter_class=argparse.RawDescriptionHelpFormatter,
442 epilog=epilog,
443 description="Run build system performance benchmarks.")
444 self.parser = parser
445
446 parser.add_argument("--log-dir",
447 help="Directory for logs. Default is $TOP/../benchmarks/.")
448 parser.add_argument("--dated-logs", action="store_true",
449 help="Append timestamp to log dir.")
450 parser.add_argument("-n", action="store_true", dest="dry_run",
451 help="Dry run. Don't run the build commands but do everything else.")
452 parser.add_argument("--tag",
453 help="Variant of the run, for when there are multiple perf runs.")
454 parser.add_argument("--lunch", nargs="*",
455 help="Lunch combos to test")
456 parser.add_argument("--iterations", type=int, default=1,
457 help="Number of iterations of each test to run.")
458 parser.add_argument("--branch", type=str,
459 help="Specify branch. Otherwise a guess will be made based on repo.")
460 parser.add_argument("--benchmark", nargs="*", default=[b.id for b in self._benchmarks],
461 metavar="BENCHMARKS",
462 help="Benchmarks to run. Default suite will be run if omitted.")
Joe Onorato60c36ad2024-01-02 07:32:54 +0000463 parser.add_argument("--dist-one", action="store_true",
Joe Onorato05434642023-12-20 05:00:04 +0000464 help="Copy logs and metrics to the given dist dir. Requires that only"
465 + " one benchmark be supplied. Postroll steps will be skipped.")
Joe Onorato88ede352023-12-19 02:56:38 +0000466
467 self._args = parser.parse_args()
468
469 self._branch = self._branch()
470 self._log_dir = self._log_dir()
471 self._lunches = self._lunches()
472
473 # Validate the benchmark ids
474 all_ids = [benchmark.id for benchmark in self._benchmarks]
475 bad_ids = [id for id in self._args.benchmark if id not in all_ids]
476 if bad_ids:
477 for id in bad_ids:
478 self._error(f"Invalid benchmark: {id}")
479
Joe Onorato05434642023-12-20 05:00:04 +0000480 # --dist-one requires that only one benchmark be supplied
Joe Onorato60c36ad2024-01-02 07:32:54 +0000481 if self._args.dist_one and len(self.Benchmarks()) != 1:
Joe Onorato05434642023-12-20 05:00:04 +0000482 self._error("--dist-one requires that exactly one --benchmark.")
483
Joe Onorato88ede352023-12-19 02:56:38 +0000484 if self._had_error:
485 raise FatalError()
486
487 def Timestamp(self):
488 return self._timestamp
489
490 def _branch(self):
491 """Return the branch, either from the command line or by guessing from repo."""
492 if self._args.branch:
493 return self._args.branch
494 try:
495 branch = subprocess.check_output(f"cd {self.root}/.repo/manifests"
496 + " && git rev-parse --abbrev-ref --symbolic-full-name @{u}",
497 shell=True, encoding="utf-8")
498 return branch.strip().split("/")[-1]
499 except subprocess.CalledProcessError as ex:
500 report_error("Can't get branch from .repo dir. Specify --branch argument")
501 report_error(str(ex))
502 raise FatalError()
503
504 def Branch(self):
505 return self._branch
506
507 def _log_dir(self):
508 "The log directory to use, based on the current options"
509 if self._args.log_dir:
510 d = pathlib.Path(self._args.log_dir).resolve().absolute()
511 else:
512 d = self.root.joinpath("..", utils.DEFAULT_REPORT_DIR)
513 if self._args.dated_logs:
514 d = d.joinpath(self._timestamp.strftime('%Y-%m-%d'))
515 d = d.joinpath(self._branch)
516 if self._args.tag:
517 d = d.joinpath(self._args.tag)
518 return d.resolve().absolute()
519
520 def LogDir(self):
521 return self._log_dir
522
523 def Benchmarks(self):
524 return [b for b in self._benchmarks if b.id in self._args.benchmark]
525
526 def Tag(self):
527 return self._args.tag
528
529 def DryRun(self):
530 return self._args.dry_run
531
532 def _lunches(self):
533 def parse_lunch(lunch):
534 parts = lunch.split("-")
535 if len(parts) != 3:
536 raise OptionsError(f"Invalid lunch combo: {lunch}")
537 return Lunch(parts[0], parts[1], parts[2])
538 # If they gave lunch targets on the command line use that
539 if self._args.lunch:
540 result = []
541 # Split into Lunch objects
542 for lunch in self._args.lunch:
543 try:
544 result.append(parse_lunch(lunch))
545 except OptionsError as ex:
546 self._error(ex.message)
547 return result
548 # Use whats in the environment
549 product = os.getenv("TARGET_PRODUCT")
550 release = os.getenv("TARGET_RELEASE")
551 variant = os.getenv("TARGET_BUILD_VARIANT")
552 if (not product) or (not release) or (not variant):
553 # If they didn't give us anything, fail rather than guessing. There's no good
554 # default for AOSP.
555 self._error("No lunch combo specified. Either pass --lunch argument or run lunch.")
556 return []
557 return [Lunch(product, release, variant),]
558
559 def Lunches(self):
560 return self._lunches
561
562 def Iterations(self):
563 return self._args.iterations
564
Joe Onorato05434642023-12-20 05:00:04 +0000565 def DistOne(self):
566 return self._args.dist_one
567
Joe Onorato88ede352023-12-19 02:56:38 +0000568 def _init_benchmarks(self):
569 """Initialize the list of benchmarks."""
570 # Assumes that we've already chdired to the root of the tree.
571 self._benchmarks = [
572 Benchmark(id="full",
Joe Onorato01277d42023-12-20 04:00:12 +0000573 title="Full build",
574 change=Clean(),
575 modules=["droid"],
576 preroll=0,
577 postroll=3,
578 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000579 Benchmark(id="nochange",
Joe Onorato01277d42023-12-20 04:00:12 +0000580 title="No change",
581 change=NoChange(),
582 modules=["droid"],
583 preroll=2,
584 postroll=3,
585 ),
586 Benchmark(id="unreferenced",
587 title="Create unreferenced file",
588 change=Create("bionic/unreferenced.txt"),
589 modules=["droid"],
590 preroll=1,
591 postroll=2,
592 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000593 Benchmark(id="modify_bp",
Joe Onorato01277d42023-12-20 04:00:12 +0000594 title="Modify Android.bp",
595 change=Modify("bionic/libc/Android.bp", Comment("//")),
596 modules=["droid"],
597 preroll=1,
598 postroll=3,
599 ),
600 Benchmark(id="modify_stdio",
601 title="Modify stdio.cpp",
602 change=Modify("bionic/libc/stdio/stdio.cpp", Comment("//")),
603 modules=["libc"],
604 preroll=1,
605 postroll=2,
606 ),
607 Benchmark(id="modify_adbd",
608 title="Modify adbd",
609 change=Modify("packages/modules/adb/daemon/main.cpp", Comment("//")),
610 modules=["adbd"],
611 preroll=1,
612 postroll=2,
613 ),
614 Benchmark(id="services_private_field",
615 title="Add private field to ActivityManagerService.java",
616 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
617 "private"),
618 modules=["services"],
619 preroll=1,
620 postroll=2,
621 ),
622 Benchmark(id="services_public_field",
623 title="Add public field to ActivityManagerService.java",
624 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
625 "/** @hide */ public"),
626 modules=["services"],
627 preroll=1,
628 postroll=2,
629 ),
630 Benchmark(id="services_api",
631 title="Add API to ActivityManagerService.javaa",
632 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
633 "@android.annotation.SuppressLint(\"UnflaggedApi\") public"),
634 modules=["services"],
635 preroll=1,
636 postroll=2,
637 ),
638 Benchmark(id="framework_private_field",
639 title="Add private field to Settings.java",
640 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
641 "private"),
642 modules=["framework-minus-apex"],
643 preroll=1,
644 postroll=2,
645 ),
646 Benchmark(id="framework_public_field",
647 title="Add public field to Settings.java",
648 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
649 "/** @hide */ public"),
650 modules=["framework-minus-apex"],
651 preroll=1,
652 postroll=2,
653 ),
654 Benchmark(id="framework_api",
655 title="Add API to Settings.java",
656 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
657 "@android.annotation.SuppressLint(\"UnflaggedApi\") public"),
658 modules=["framework-minus-apex"],
659 preroll=1,
660 postroll=2,
661 ),
662 Benchmark(id="modify_framework_resource",
663 title="Modify framework resource",
664 change=Modify("frameworks/base/core/res/res/values/config.xml",
665 lambda: str(uuid.uuid4()),
666 before="</string>"),
667 modules=["framework-minus-apex"],
668 preroll=1,
669 postroll=2,
670 ),
671 Benchmark(id="add_framework_resource",
672 title="Add framework resource",
673 change=Modify("frameworks/base/core/res/res/values/config.xml",
674 lambda: f"<string name=\"BENCHMARK\">{uuid.uuid4()}</string>",
675 before="</resources>"),
676 modules=["framework-minus-apex"],
677 preroll=1,
678 postroll=2,
679 ),
680 Benchmark(id="add_systemui_field",
681 title="Add SystemUI field",
682 change=AddJavaField("frameworks/base/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java",
683 "public"),
684 modules=["SystemUI"],
685 preroll=1,
686 postroll=2,
687 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000688 ]
689
690 def _error(self, message):
691 report_error(message)
692 self._had_error = True
693
694
695def report_error(message):
696 sys.stderr.write(f"error: {message}\n")
697
698
699def main(argv):
700 try:
701 options = Options()
702 runner = Runner(options)
703 runner.Run()
704 except FatalError:
705 sys.stderr.write(f"FAILED\n")
706
707
708if __name__ == "__main__":
709 main(sys.argv)