1 #!/usr/bin/env python 2 # 3 # Copyright (C) 2015 The Android Open Source Project 4 # 5 # Licensed under the Apache License, Version 2.0 (the "License"); 6 # you may not use this file except in compliance with the License. 7 # You may obtain a copy of the License at 8 # 9 # http://www.apache.org/licenses/LICENSE-2.0 10 # 11 # Unless required by applicable law or agreed to in writing, software 12 # distributed under the License is distributed on an "AS IS" BASIS, 13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 # See the License for the specific language governing permissions and 15 # limitations under the License. 16 # 17 18 from __future__ import print_function 19 20 import argparse 21 import contextlib 22 import multiprocessing 23 import os 24 import operator 25 import posixpath 26 import signal 27 import subprocess 28 import sys 29 import time 30 import xml.etree.cElementTree as ElementTree 31 32 import logging 33 34 # Shared functions across gdbclient.py and ndk-gdb.py. 35 # ndk-gdb is installed to $NDK/host-tools/bin 36 NDK_PATH = os.path.normpath(os.path.join(os.path.dirname(__file__), '../..')) 37 sys.path.append(os.path.join(NDK_PATH, "python-packages")) 38 import gdbrunner 39 40 41 def log(msg): 42 logger = logging.getLogger(__name__) 43 logger.info(msg) 44 45 46 def error(msg): 47 sys.exit("ERROR: {}".format(msg)) 48 49 50 class ArgumentParser(gdbrunner.ArgumentParser): 51 def __init__(self): 52 super(ArgumentParser, self).__init__() 53 self.add_argument( 54 "--verbose", "-v", action="store_true", 55 help="Enable verbose mode") 56 57 self.add_argument( 58 "--force", "-f", action="store_true", 59 help="Kill existing debug session if it exists") 60 61 self.add_argument( 62 "--port", type=int, nargs="?", default="5039", 63 help="override the port used on the host") 64 65 self.add_argument( 66 "--delay", type=float, default=0.0, 67 help="Delay in seconds to wait after starting activity.\n" 68 "This may be necessary on slower devices.") 69 70 self.add_argument( 71 "-p", "--project", dest="project", 72 help="Specify application project path") 73 74 app_group = self.add_argument_group("target selection") 75 start_group = app_group.add_mutually_exclusive_group() 76 77 class NoopAction(argparse.Action): 78 def __call__(self, *args, **kwargs): 79 pass 80 81 # Action for --attach is a noop, because --launch's action will store a 82 # False in launch if --launch isn't specified. 83 start_group.add_argument( 84 "--attach", action=NoopAction, nargs=0, 85 help="Attach to application [default]") 86 87 start_group.add_argument( 88 "--launch", action="store_true", dest="launch", 89 help="Launch application activity (defaults to main activity, " 90 "configurable with --launch-activity)") 91 92 start_group.add_argument( 93 "--launch-list", action="store_true", 94 help="List all launchable activity names from manifest") 95 96 app_group.add_argument( 97 "--launch-activity", action="store", metavar="ACTIVITY", 98 dest="launch_target", help="Launch specified application activity") 99 100 101 debug_group = self.add_argument_group("debugging options") 102 debug_group.add_argument( 103 "-x", "--exec", dest="exec_file", 104 help="Execute gdb commands in EXEC_FILE after connection") 105 106 debug_group.add_argument( 107 "--nowait", action="store_true", 108 help="Do not wait for debugger to attach (may miss early JNI " 109 "breakpoints)") 110 111 debug_group.add_argument( 112 "-t", "--tui", action="store_true", dest="tui", 113 help="Use GDB's tui mode") 114 115 debug_group.add_argument( 116 "--stdcxx-py-pr", dest="stdcxxpypr", 117 help="Use C++ library pretty-printer", 118 choices=["auto", "none", "gnustl", "stlport"], 119 default="none") 120 121 122 def extract_package_name(xmlroot): 123 if "package" in xmlroot.attrib: 124 return xmlroot.attrib["package"] 125 error("Failed to find package name in AndroidManifest.xml") 126 127 128 ANDROID_XMLNS = "{http://schemas.android.com/apk/res/android}" 129 def is_debuggable(xmlroot): 130 applications = xmlroot.findall("application") 131 if len(applications) > 1: 132 error("Multiple application tags found in AndroidManifest.xml") 133 debuggable_attrib = "{}debuggable".format(ANDROID_XMLNS) 134 if debuggable_attrib in applications[0].attrib: 135 debuggable = applications[0].attrib[debuggable_attrib] 136 if debuggable == "true": 137 return True 138 elif debuggable == "false": 139 return False 140 else: 141 msg = "Unexpected android:debuggable value: '{}'" 142 error(msg.format(debuggable)) 143 return False 144 145 146 def extract_launchable(xmlroot): 147 ''' 148 A given application can have several activities, and each activity 149 can have several intent filters. We want to only list, in the final 150 output, the activities which have a intent-filter that contains the 151 following elements: 152 153 <action android:name="android.intent.action.MAIN" /> 154 <category android:name="android.intent.category.LAUNCHER" /> 155 ''' 156 launchable_activities = [] 157 application = xmlroot.findall("application")[0] 158 159 main_action = "android.intent.action.MAIN" 160 launcher_category = "android.intent.category.LAUNCHER" 161 name_attrib = "{}name".format(ANDROID_XMLNS) 162 163 for activity in application.iter("activity"): 164 if name_attrib not in activity.attrib: 165 continue 166 167 for intent_filter in activity.iter("intent-filter"): 168 found_action = False 169 found_category = False 170 for child in intent_filter: 171 if child.tag == "action": 172 if not found_action and name_attrib in child.attrib: 173 if child.attrib[name_attrib] == main_action: 174 found_action = True 175 if child.tag == "category": 176 if not found_category and name_attrib in child.attrib: 177 if child.attrib[name_attrib] == launcher_category: 178 found_category = True 179 if found_action and found_category: 180 launchable_activities.append(activity.attrib[name_attrib]) 181 return launchable_activities 182 183 184 def ndk_bin_path(): 185 path = os.path.join(NDK_PATH, "host-tools", "bin") 186 if not os.path.exists(path): 187 error("Failed to find ndk binary path, should be at '{}'".format(path)) 188 189 return path 190 191 192 def handle_args(): 193 def find_program(program, paths): 194 '''Find a binary in paths''' 195 exts = [""] 196 if sys.platform.startswith("win"): 197 exts += [".exe", ".bat", ".cmd"] 198 for path in paths: 199 if os.path.isdir(path): 200 for ext in exts: 201 full = path + os.sep + program + ext 202 if os.path.isfile(full): 203 return full 204 return None 205 206 # FIXME: This is broken for PATH that contains quoted colons. 207 paths = os.environ["PATH"].replace('"', '').split(os.pathsep) 208 209 args = ArgumentParser().parse_args() 210 ndk_bin = ndk_bin_path() 211 args.make_cmd = find_program("make", [ndk_bin]) 212 args.jdb_cmd = find_program("jdb", paths) 213 if args.make_cmd is None: 214 error("Failed to find make in '{}'".format(ndk_bin)) 215 if args.jdb_cmd is None: 216 print("WARNING: Failed to find jdb on your path, defaulting to " 217 "--nowait") 218 args.nowait = True 219 220 if args.verbose: 221 logger = logging.getLogger(__name__) 222 handler = logging.StreamHandler(sys.stdout) 223 formatter = logging.Formatter() 224 225 handler.setFormatter(formatter) 226 logger.addHandler(handler) 227 logger.propagate = False 228 229 logger.setLevel(logging.INFO) 230 231 return args 232 233 234 def find_project(args): 235 manifest_name = "AndroidManifest.xml" 236 if args.project is not None: 237 log("Using project directory: {}".format(args.project)) 238 args.project = os.path.realpath(args.project) 239 if not os.path.exists(os.path.join(args.project, manifest_name)): 240 msg = "could not find AndroidManifest.xml in '{}'" 241 error(msg.format(args.project)) 242 else: 243 # Walk upwards until we find AndroidManifest.xml, or run out of path. 244 current_dir = os.getcwdu() 245 while not os.path.exists(os.path.join(current_dir, manifest_name)): 246 parent_dir = os.path.dirname(current_dir) 247 if parent_dir == current_dir: 248 error("Could not find AndroidManifest.xml in current" 249 " directory or a parent directory.\n" 250 " Launch this script from inside a project, or" 251 " use --project=<path>.") 252 current_dir = parent_dir 253 args.project = current_dir 254 log("Using project directory: {} ".format(args.project)) 255 args.manifest_path = os.path.join(args.project, manifest_name) 256 return args.project 257 258 259 def canonicalize_activity(package_name, activity_name): 260 if activity_name.startswith("."): 261 return "{}{}".format(package_name, activity_name) 262 return activity_name 263 264 265 def parse_manifest(args): 266 manifest = ElementTree.parse(args.manifest_path) 267 manifest_root = manifest.getroot() 268 package_name = extract_package_name(manifest_root) 269 log("Found package name: {}".format(package_name)) 270 271 debuggable = is_debuggable(manifest_root) 272 if not debuggable: 273 error("Application is not marked as debuggable in its manifest.") 274 275 activities = extract_launchable(manifest_root) 276 activities = [canonicalize_activity(package_name, a) for a in activities] 277 278 if args.launch_list: 279 print("Launchable activities: {}".format(", ".join(activities))) 280 sys.exit(0) 281 282 args.activities = activities 283 args.package_name = package_name 284 285 286 def select_target(args): 287 assert args.launch 288 if len(args.activities) == 0: 289 error("No launchable activities found.") 290 291 if args.launch_target is None: 292 args.launch_target = args.activities[0] 293 294 if len(args.activities) > 1: 295 print("WARNING: Multiple launchable activities found, choosing" 296 " '{}'.".format(args.activities[0])) 297 else: 298 canonicalize = canonicalize_activity(args.package_name) 299 activity_name = canonicalize(args.launch_target) 300 301 if activity_name not in args.activities: 302 msg = "Could not find launchable activity: '{}'." 303 error(msg.format(activity_name)) 304 args.launch_target = activity_name 305 return args.launch_target 306 307 308 @contextlib.contextmanager 309 def cd(path): 310 curdir = os.getcwd() 311 os.chdir(path) 312 os.environ["PWD"] = path 313 try: 314 yield 315 finally: 316 os.environ["PWD"] = curdir 317 os.chdir(curdir) 318 319 320 def dump_var(args, variable, abi=None): 321 make_args = [args.make_cmd, "--no-print-dir", "-f", 322 os.path.join(NDK_PATH, "build/core/build-local.mk"), 323 "-C", args.project, "DUMP_{}".format(variable)] 324 325 if abi is not None: 326 make_args.append("APP_ABI={}".format(abi)) 327 328 with cd(args.project): 329 try: 330 make_output = subprocess.check_output(make_args, cwd=args.project) 331 except subprocess.CalledProcessError: 332 error("Failed to retrieve application ABI from Android.mk.") 333 return make_output.splitlines()[0] 334 335 336 def get_api_level(device_props): 337 # Check the device API level 338 if "ro.build.version.sdk" not in device_props: 339 error("Failed to find target device's supported API level.\n" 340 "ndk-gdb only supports devices running Android 2.2 or higher.") 341 api_level = int(device_props["ro.build.version.sdk"]) 342 if api_level < 8: 343 error("ndk-gdb only supports devices running Android 2.2 or higher.\n" 344 "(expected API level 8, actual: {})".format(api_level)) 345 346 return api_level 347 348 349 def fetch_abi(args): 350 ''' 351 Figure out the intersection of which ABIs the application is built for and 352 which ones the device supports, then pick the one preferred by the device, 353 so that we know which gdbserver to push and run on the device. 354 ''' 355 356 app_abis = dump_var(args, "APP_ABI").split(" ") 357 if "all" in app_abis: 358 app_abis = dump_var(args, "NDK_ALL_ABIS").split(" ") 359 app_abis_msg = "Application ABIs: {}".format(", ".join(app_abis)) 360 log(app_abis_msg) 361 362 device_props = args.device.get_props() 363 364 new_abi_props = ["ro.product.cpu.abilist"] 365 old_abi_props = ["ro.product.cpu.abi", "ro.product.cpu.abi2"] 366 abi_props = new_abi_props 367 if len(set(new_abi_props).intersection(device_props.keys())) == 0: 368 abi_props = old_abi_props 369 370 device_abis = [device_props[key].split(",") for key in abi_props] 371 372 # Flatten the list. 373 device_abis = reduce(operator.add, device_abis) 374 device_abis_msg = "Device ABIs: {}".format(", ".join(device_abis)) 375 log(device_abis_msg) 376 377 for abi in device_abis: 378 if abi in app_abis: 379 # TODO(jmgao): Do we expect gdb to work with ARM-x86 translation? 380 log("Selecting ABI: {}".format(abi)) 381 return abi 382 383 msg = "Application cannot run on the selected device." 384 385 # Don't repeat ourselves. 386 if not args.verbose: 387 msg += "\n{}\n{}".format(app_abis_msg, device_abis_msg) 388 389 error(msg) 390 391 392 def get_app_data_dir(args, package_name): 393 cmd = ["/system/bin/sh", "-c", "pwd", "2>/dev/null"] 394 cmd = gdbrunner.get_run_as_cmd(package_name, cmd) 395 (rc, stdout, _) = args.device.shell_nocheck(cmd) 396 if rc != 0: 397 error("Could not find application's data directory. Are you sure that " 398 "the application is installed and debuggable?") 399 data_dir = stdout.strip() 400 log("Found application data directory: {}".format(data_dir)) 401 return data_dir 402 403 404 def abi_to_arch(abi): 405 if abi.startswith("armeabi"): 406 return "arm" 407 elif abi == "arm64-v8a": 408 return "arm64" 409 else: 410 return abi 411 412 413 def get_gdbserver_path(args, package_name, app_data_dir, arch): 414 app_gdbserver_path = "{}/lib/gdbserver".format(app_data_dir) 415 cmd = ["ls", app_gdbserver_path, "2>/dev/null"] 416 cmd = gdbrunner.get_run_as_cmd(package_name, cmd) 417 (rc, _, _) = args.device.shell_nocheck(cmd) 418 if rc == 0: 419 log("Found app gdbserver: {}".format(app_gdbserver_path)) 420 return app_gdbserver_path 421 422 # We need to upload our gdbserver 423 log("App gdbserver not found at {}, uploading.".format(app_gdbserver_path)) 424 local_path = "{}/gdbserver/{}/gdbserver" 425 local_path = local_path.format(NDK_PATH, arch) 426 remote_path = "/data/local/tmp/{}-gdbserver".format(arch) 427 args.device.push(local_path, remote_path) 428 429 # Copy gdbserver into the data directory on M+, because selinux prevents 430 # execution of binaries directly from /data/local/tmp. 431 if get_api_level(args.props) >= 23: 432 destination = "{}/{}-gdbserver".format(app_data_dir, arch) 433 log("Copying gdbserver to {}.".format(destination)) 434 cmd = ["cat", remote_path, "|", "run-as", package_name, 435 "sh", "-c", "'cat > {}'".format(destination)] 436 (rc, _, _) = args.device.shell_nocheck(cmd) 437 if rc != 0: 438 error("Failed to copy gdbserver to {}.".format(destination)) 439 (rc, _, _) = args.device.shell_nocheck(["run-as", package_name, 440 "chmod", "700", destination]) 441 if rc != 0: 442 error("Failed to chmod gdbserver at {}.".format(destination)) 443 444 remote_path = destination 445 446 log("Uploaded gdbserver to {}".format(remote_path)) 447 return remote_path 448 449 450 def pull_binaries(device, out_dir, is64bit): 451 required_files = [] 452 libraries = ["libc.so", "libm.so", "libdl.so"] 453 454 if is64bit: 455 required_files = ["/system/bin/app_process64", "/system/bin/linker64"] 456 library_path = "/system/lib64" 457 else: 458 required_files = ["/system/bin/app_process", "/system/bin/linker"] 459 library_path = "/system/lib" 460 461 for library in libraries: 462 required_files.append(posixpath.join(library_path, library)) 463 464 for required_file in required_files: 465 # os.path.join not used because joining absolute paths will pick the last one 466 local_path = os.path.realpath(out_dir + required_file) 467 local_dirname = os.path.dirname(local_path) 468 if not os.path.isdir(local_dirname): 469 os.makedirs(local_dirname) 470 log("Pulling '{}' to '{}'".format(required_file, local_path)) 471 device.pull(required_file, local_path) 472 473 474 def generate_gdb_script(args, sysroot, binary_path, is64bit, connect_timeout=5): 475 gdb_commands = "file '{}'\n".format(binary_path) 476 477 solib_search_path = [sysroot, "{}/system/bin".format(sysroot)] 478 if is64bit: 479 solib_search_path.append("{}/system/lib64".format(sysroot)) 480 else: 481 solib_search_path.append("{}/system/lib".format(sysroot)) 482 solib_search_path = os.pathsep.join(solib_search_path) 483 gdb_commands += "set solib-absolute-prefix {}\n".format(sysroot) 484 gdb_commands += "set solib-search-path {}\n".format(solib_search_path) 485 486 # Try to connect for a few seconds, sometimes the device gdbserver takes 487 # a little bit to come up, especially on emulators. 488 gdb_commands += """ 489 python 490 491 def target_remote_with_retry(target, timeout_seconds): 492 import time 493 end_time = time.time() + timeout_seconds 494 while True: 495 try: 496 gdb.execute('target remote ' + target) 497 return True 498 except gdb.error as e: 499 time_left = end_time - time.time() 500 if time_left < 0 or time_left > timeout_seconds: 501 print("Error: unable to connect to device.") 502 print(e) 503 return False 504 time.sleep(min(0.25, time_left)) 505 506 target_remote_with_retry(':{}', {}) 507 508 end 509 """.format(args.port, connect_timeout) 510 511 # Set up the pretty printer if needed 512 if args.pypr_dir is not None and args.pypr_fn is not None: 513 gdb_commands += """ 514 python 515 import sys 516 sys.path.append("{pypr_dir}") 517 from printers import {pypr_fn} 518 {pypr_fn}(None) 519 end""".format(pypr_dir=args.pypr_dir, pypr_fn=args.pypr_fn) 520 521 if args.exec_file is not None: 522 try: 523 exec_file = open(args.exec_file, "r") 524 except IOError: 525 error("Failed to open GDB exec file: '{}'.".format(args.exec_file)) 526 527 with exec_file: 528 gdb_commands += exec_file.read() 529 530 return gdb_commands 531 532 533 def detect_stl_pretty_printer(args): 534 stl = dump_var(args, "APP_STL") 535 if not stl: 536 detected = "none" 537 if args.stdcxxpypr == "auto": 538 log("APP_STL not found, disabling pretty printer") 539 elif stl.startswith("stlport"): 540 detected = "stlport" 541 elif stl.startswith("gnustl"): 542 detected = "gnustl" 543 else: 544 detected = "none" 545 546 if args.stdcxxpypr == "auto": 547 log("Detected pretty printer: {}".format(detected)) 548 return detected 549 if detected != args.stdcxxpypr and args.stdcxxpypr != "none": 550 print("WARNING: detected APP_STL ('{}') does not match pretty printer".format(detected)) 551 log("Using specified pretty printer: {}".format(args.stdcxxpypr)) 552 return args.stdcxxpypr 553 554 555 def find_pretty_printer(pretty_printer): 556 if pretty_printer == "gnustl": 557 path = os.path.join("libstdcxx", "gcc-4.9") 558 function = "register_libstdcxx_printers" 559 elif pretty_printer == "stlport": 560 path = os.path.join("stlport", "stlport") 561 function = "register_stlport_printers" 562 pp_path = os.path.join( 563 NDK_PATH, "host-tools", "share", "pretty-printers", path) 564 return pp_path, function 565 566 567 def main(): 568 args = handle_args() 569 device = args.device 570 571 if device is None: 572 error("Could not find a unique connected device/emulator.") 573 574 adb_version = subprocess.check_output(device.adb_cmd + ["version"]) 575 log("ADB command used: '{}'".format(" ".join(device.adb_cmd))) 576 log("ADB version: {}".format(" ".join(adb_version.splitlines()))) 577 578 args.props = device.get_props() 579 580 project = find_project(args) 581 parse_manifest(args) 582 pkg_name = args.package_name 583 584 if args.launch is False: 585 log("Attaching to existing application process.") 586 else: 587 launch_target = select_target(args) 588 log("Selected target activity: '{}'".format(launch_target)) 589 590 abi = fetch_abi(args) 591 592 out_dir = os.path.join(project, (dump_var(args, "TARGET_OUT", abi))) 593 out_dir = os.path.realpath(out_dir) 594 595 pretty_printer = detect_stl_pretty_printer(args) 596 if pretty_printer != "none": 597 (args.pypr_dir, args.pypr_fn) = find_pretty_printer(pretty_printer) 598 else: 599 (args.pypr_dir, args.pypr_fn) = (None, None) 600 601 app_data_dir = get_app_data_dir(args, pkg_name) 602 arch = abi_to_arch(abi) 603 gdbserver_path = get_gdbserver_path(args, pkg_name, app_data_dir, arch) 604 605 # Kill the process and gdbserver if requested. 606 if args.force: 607 kill_pids = gdbrunner.get_pids(device, gdbserver_path) 608 if args.launch: 609 kill_pids += gdbrunner.get_pids(device, pkg_name) 610 kill_pids = map(str, kill_pids) 611 if kill_pids: 612 log("Killing processes: {}".format(", ".join(kill_pids))) 613 device.shell_nocheck(["run-as", pkg_name, "kill", "-9"] + kill_pids) 614 615 # Launch the application if needed, and get its pid 616 if args.launch: 617 am_cmd = ["am", "start"] 618 if not args.nowait: 619 am_cmd.append("-D") 620 component_name = "{}/{}".format(pkg_name, launch_target) 621 am_cmd.append(component_name) 622 log("Launching activity {}...".format(component_name)) 623 (rc, _, _) = device.shell_nocheck(am_cmd) 624 if rc != 0: 625 error("Failed to start {}".format(component_name)) 626 627 if args.delay > 0.0: 628 log("Sleeping for {} seconds.".format(args.delay)) 629 time.sleep(args.delay) 630 631 pids = gdbrunner.get_pids(device, pkg_name) 632 if len(pids) == 0: 633 error("Failed to find running process '{}'".format(pkg_name)) 634 if len(pids) > 1: 635 error("Multiple running processes named '{}'".format(pkg_name)) 636 pid = pids[0] 637 638 # Pull the linker, zygote, and notable system libraries 639 is64bit = "64" in abi 640 pull_binaries(device, out_dir, is64bit) 641 if is64bit: 642 zygote_path = os.path.join(out_dir, "system", "bin", "app_process64") 643 else: 644 zygote_path = os.path.join(out_dir, "system", "bin", "app_process") 645 646 # Start gdbserver. 647 debug_socket = os.path.join(app_data_dir, "debug_socket") 648 log("Starting gdbserver...") 649 gdbrunner.start_gdbserver( 650 device, None, gdbserver_path, 651 target_pid=pid, run_cmd=None, debug_socket=debug_socket, 652 port=args.port, user=pkg_name) 653 654 gdb_path = os.path.join(ndk_bin_path(), "gdb") 655 656 # Start jdb to unblock the application if necessary. 657 if args.launch and not args.nowait: 658 # Do this in a separate process before starting gdb, since jdb won't 659 # connect until gdb connects and continues. 660 def start_jdb(): 661 log("Starting jdb to unblock application.") 662 663 # Do setup stuff to keep ^C in the parent from killing us. 664 signal.signal(signal.SIGINT, signal.SIG_IGN) 665 windows = sys.platform.startswith("win") 666 if not windows: 667 os.setpgrp() 668 669 jdb_port = 65534 670 device.forward("tcp:{}".format(jdb_port), "jdwp:{}".format(pid)) 671 jdb_cmd = [args.jdb_cmd, "-connect", 672 "com.sun.jdi.SocketAttach:hostname=localhost,port={}".format(jdb_port)] 673 674 flags = subprocess.CREATE_NEW_PROCESS_GROUP if windows else 0 675 jdb = subprocess.Popen(jdb_cmd, 676 stdin=subprocess.PIPE, 677 stdout=subprocess.PIPE, 678 stderr=subprocess.STDOUT, 679 creationflags=flags) 680 jdb.stdin.write("exit\n") 681 jdb.wait() 682 log("JDB finished unblocking application.") 683 684 jdb_process = multiprocessing.Process(target=start_jdb) 685 jdb_process.start() 686 687 688 # Start gdb. 689 gdb_commands = generate_gdb_script(args, out_dir, zygote_path, is64bit) 690 gdb_flags = [] 691 if args.tui: 692 gdb_flags.append("--tui") 693 gdbrunner.start_gdb(gdb_path, gdb_commands, gdb_flags) 694 695 if __name__ == "__main__": 696 main() 697