Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
| 2 # Copyright 2014 The Swarming Authors. All rights reserved. | 2 # Copyright 2014 The Swarming Authors. All rights reserved. |
| 3 # Use of this source code is governed by the Apache v2.0 license that can be | 3 # Use of this source code is governed by the Apache v2.0 license that can be |
| 4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
| 5 | 5 |
| 6 """OS specific utility functions. | 6 """OS specific utility functions. |
| 7 | 7 |
| 8 Includes code: | 8 Includes code: |
| 9 - to declare the current system this code is running under. | 9 - to declare the current system this code is running under. |
| 10 - to run a command on user login. | 10 - to run a command on user login. |
| 11 - to restart the host. | 11 - to restart the host. |
| 12 | 12 |
| 13 This file serves as an API to bot_config.py. bot_config.py can be replaced on | 13 This file serves as an API to bot_config.py. bot_config.py can be replaced on |
| 14 the server to allow additional server-specific functionality. | 14 the server to allow additional server-specific functionality. |
| 15 """ | 15 """ |
| 16 | 16 |
| 17 import cgi | 17 import cgi |
| 18 import ctypes | 18 import ctypes |
| 19 import getpass | 19 import getpass |
| 20 import glob | 20 import glob |
| 21 import hashlib | |
| 21 import json | 22 import json |
| 22 import logging | 23 import logging |
| 23 import multiprocessing | 24 import multiprocessing |
| 24 import os | 25 import os |
| 25 import pipes | 26 import pipes |
| 26 import platform | 27 import platform |
| 27 import re | 28 import re |
| 28 import shlex | 29 import shlex |
| 29 import signal | 30 import signal |
| 30 import socket | 31 import socket |
| (...skipping 74 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 105 | 106 |
| 106 # Used to calculated Swarming bot uptime. | 107 # Used to calculated Swarming bot uptime. |
| 107 _STARTED_TS = time.time() | 108 _STARTED_TS = time.time() |
| 108 | 109 |
| 109 | 110 |
| 110 # Cache of GCE OAuth2 token. | 111 # Cache of GCE OAuth2 token. |
| 111 _CACHED_OAUTH2_TOKEN_GCE = {} | 112 _CACHED_OAUTH2_TOKEN_GCE = {} |
| 112 _CACHED_OAUTH2_TOKEN_GCE_LOCK = threading.Lock() | 113 _CACHED_OAUTH2_TOKEN_GCE_LOCK = threading.Lock() |
| 113 | 114 |
| 114 | 115 |
| 116 # Set this to package adb and M2Crypto.RSA when Android support is initialized. | |
| 117 adb = None | |
| 118 RSA = None | |
| 119 | |
| 120 # Set when ADB is initialized. It contains one or multiple key used to | |
| 121 # authenticate to Android debug protocol (adb). | |
| 122 _ADB_KEYS = [] | |
| 123 | |
| 124 | |
| 125 # Cache of /system/build.prop on Android devices connected to this host. | |
| 126 _BUILD_PROP_ANDROID = {} | |
| 127 | |
| 128 | |
| 115 def _write(filepath, content): | 129 def _write(filepath, content): |
| 116 """Writes out a file and returns True on success.""" | 130 """Writes out a file and returns True on success.""" |
| 117 logging.info('Writing in %s:\n%s', filepath, content) | 131 logging.info('Writing in %s:\n%s', filepath, content) |
| 118 try: | 132 try: |
| 119 with open(filepath, mode='wb') as f: | 133 with open(filepath, mode='wb') as f: |
| 120 f.write(content) | 134 f.write(content) |
| 121 return True | 135 return True |
| 122 except IOError as e: | 136 except IOError as e: |
| 123 logging.error('Failed to write %s: %s', filepath, e) | 137 logging.error('Failed to write %s: %s', filepath, e) |
| 124 return False | 138 return False |
| (...skipping 1074 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 1199 token_info.Label.Sid, p_sid_size.contents.value - 1) | 1213 token_info.Label.Sid, p_sid_size.contents.value - 1) |
| 1200 value = res.contents.value | 1214 value = res.contents.value |
| 1201 return mapping.get(value) or u'0x%04x' % value | 1215 return mapping.get(value) or u'0x%04x' % value |
| 1202 finally: | 1216 finally: |
| 1203 ctypes.windll.kernel32.CloseHandle(token) | 1217 ctypes.windll.kernel32.CloseHandle(token) |
| 1204 | 1218 |
| 1205 | 1219 |
| 1206 ### Android. | 1220 ### Android. |
| 1207 | 1221 |
| 1208 | 1222 |
| 1223 def initialize_android(pub_key, priv_key): | |
|
Vadim Sh.
2015/08/20 22:40:09
how do you feel about moving it to "android_utilit
M-A Ruel
2015/08/21 23:37:39
Done.
| |
| 1224 """Initialize Android support through adb. | |
| 1225 | |
| 1226 You can steal pub_key, priv_key pair from ~/.android/adbkey and | |
| 1227 ~/.android/adbkey.pub. | |
|
Vadim Sh.
2015/08/20 22:40:08
is it true only for chrome-infra bots?
M-A Ruel
2015/08/21 23:37:39
It's created by adb.
| |
| 1228 """ | |
| 1229 global adb | |
| 1230 global RSA | |
| 1231 assert bool(pub_key) == bool(priv_key) | |
| 1232 | |
| 1233 if adb is None: | |
| 1234 try: | |
| 1235 # Workaround pylint lint errors. | |
|
Vadim Sh.
2015/08/20 22:40:09
just disable them, it will look less weird
M-A Ruel
2015/08/21 23:37:39
Not necessary anymore.
| |
| 1236 import adb as adb2 | |
| 1237 adb = adb2 | |
| 1238 # This is needed otherwise adb_commands is not defined. | |
| 1239 from adb import adb_commands as _ | |
| 1240 from M2Crypto import RSA as RSA2 | |
| 1241 RSA = RSA2 | |
| 1242 logging.info('adb fully loaded') | |
| 1243 except ImportError as e: | |
| 1244 adb = False | |
| 1245 logging.error('adb is unavailable: %s' % e) | |
| 1246 return False | |
| 1247 | |
| 1248 if pub_key: | |
| 1249 _ADB_KEYS.append(M2CryptoSigner(pub_key, priv_key)) | |
| 1250 | |
| 1251 # Try to add local adb keys if available. | |
| 1252 path = os.path.expanduser('~/.android/adbkey') | |
|
Vadim Sh.
2015/08/20 22:40:08
is it intentionally different from priv_key? What
M-A Ruel
2015/08/21 23:37:39
The private and public keys for adb are stored in
| |
| 1253 if os.path.isfile(path) and os.path.isfile(path+'.pub'): | |
| 1254 with open(path + '.pub', 'rb') as f: | |
| 1255 pub = f.read() | |
| 1256 with open(path, 'rb') as f: | |
| 1257 priv = f.read() | |
| 1258 _ADB_KEYS.append(M2CryptoSigner(pub, priv)) | |
| 1259 | |
| 1260 if not _ADB_KEYS: | |
| 1261 adb = False | |
| 1262 logging.error('No key is provided, android support is disabled.') | |
| 1263 return False | |
| 1264 else: | |
| 1265 if pub_key: | |
| 1266 logging.warning( | |
| 1267 'initialize_android() was called repeatedly: ignoring keys') | |
| 1268 return bool(adb) | |
| 1269 | |
| 1270 | |
| 1271 class M2CryptoSigner(object): | |
| 1272 """Implements adb_protocol.AuthSigner using | |
| 1273 https://github.com/martinpaljak/M2Crypto. | |
| 1274 """ | |
| 1275 def __init__(self, pub, priv): | |
| 1276 self.priv_key = RSA.load_key_string(priv) | |
| 1277 self.pub_key = pub | |
| 1278 | |
| 1279 def Sign(self, data): | |
| 1280 return self.priv_key.sign(data, 'sha1') | |
| 1281 | |
| 1282 def GetPublicKey(self): | |
| 1283 return self.pub_key | |
| 1284 | |
| 1285 | |
| 1286 # TODO(maruel): M2Crypto is not included by default on Ubuntu. | |
| 1287 # rsa is included in client/third_party/rsa/rsa/ and | |
| 1288 # pycrypto is normally installed on Ubuntu. It would be preferable to use one of | |
| 1289 # these 2 but my skills failed up to now, authentication consistently fails. | |
| 1290 # Revisit later or delete the code. | |
| 1291 # | |
| 1292 # | |
| 1293 #sys.path.insert(0, os.path.join(THIS_FILE, 'third_party', 'rsa')) | |
| 1294 #import rsa | |
| 1295 # | |
| 1296 #class RSASigner(object): | |
| 1297 # """Implements adb_protocol.AuthSigner using http://stuvel.eu/rsa.""" | |
| 1298 # def __init__(self): | |
| 1299 # self.privkey = rsa.PrivateKey.load_pkcs1(PRIV_CONVERTED_KEY) | |
| 1300 # | |
| 1301 # def Sign(self, data): | |
| 1302 # return rsa.sign(data, self.privkey, 'SHA-1') | |
| 1303 # | |
| 1304 # def GetPublicKey(self): | |
| 1305 # return PUB_KEY | |
| 1306 # | |
| 1307 # | |
| 1308 #try: | |
| 1309 # from Crypto.Hash import SHA | |
| 1310 # from Crypto.PublicKey import RSA | |
| 1311 # from Crypto.Signature import PKCS1_v1_5 | |
| 1312 # from Crypto.Signature import PKCS1_PSS | |
| 1313 #except ImportError: | |
| 1314 # SHA = None | |
| 1315 # | |
| 1316 # | |
| 1317 #class CryptoSigner(object): | |
| 1318 # """Implements adb_protocol.AuthSigner using | |
| 1319 # https://www.dlitz.net/software/pycrypto/. | |
| 1320 # """ | |
| 1321 # def __init__(self): | |
| 1322 # self.private_key = RSA.importKey(PRIV_KEY, None) | |
| 1323 # self._signer = PKCS1_v1_5.new(self.private_key) | |
| 1324 # #self.private_key = RSA.importKey(PRIV_CONVERTED_KEY, None) | |
| 1325 # #self._signer = PKCS1_PSS.new(self.private_key) | |
| 1326 # | |
| 1327 # def Sign(self, data): | |
| 1328 # h = SHA.new(data) | |
| 1329 # return self._signer.sign(h) | |
| 1330 # | |
| 1331 # def GetPublicKey(self): | |
| 1332 # return PUB_KEY | |
| 1333 | |
| 1334 | |
| 1335 def kill_adb_android(): | |
| 1336 """adb sucks. Kill it with fire.""" | |
| 1337 if not adb: | |
| 1338 return | |
| 1339 try: | |
| 1340 subprocess.call(['adb', 'kill-server']) | |
| 1341 except OSError: | |
| 1342 pass | |
| 1343 subprocess.call(['killall', 'adb']) | |
|
Vadim Sh.
2015/08/20 22:40:08
killall --exact adb
(In general I'm not a fan of
M-A Ruel
2015/08/21 23:37:39
Added --exact. But that's adb, it needs to be kill
| |
| 1344 | |
| 1345 | |
| 1346 def get_devices_android(): | |
| 1347 """Returns the list of devices available. | |
| 1348 | |
| 1349 Caller MUST call close_devices_android(cmds) on the return value. | |
| 1350 | |
| 1351 Returns one of: | |
| 1352 - dict of {serial_number: adb.adb_commands.AdbCommands}. The value may be | |
| 1353 None if there was an Auth failure. | |
| 1354 - None if adb is unavailable. | |
| 1355 """ | |
| 1356 if not adb: | |
| 1357 return None | |
| 1358 | |
| 1359 cmds = {} | |
| 1360 for handle in adb.adb_commands.AdbCommands.Devices(): | |
| 1361 logging.info('Trying Android %s', handle.serial_number) | |
| 1362 try: | |
| 1363 handle.Open() | |
| 1364 except adb.common.usb1.USBErrorBusy: | |
| 1365 logging.warning('Got USBErrorBusy. Killing adb') | |
| 1366 kill_adb_android() | |
| 1367 # TODO(maruel): Handle when it throws again, which means another process | |
| 1368 # holds an handle to the USB ports or group acl (plugdev) hasn't been | |
|
Vadim Sh.
2015/08/20 22:40:09
typo: a handle
M-A Ruel
2015/08/21 23:37:39
Done.
| |
| 1369 # setup properly. | |
| 1370 handle.Open() | |
|
Vadim Sh.
2015/08/20 22:40:09
maybe at least log exception here and ignore this
M-A Ruel
2015/08/21 23:37:39
Done.
| |
| 1371 | |
| 1372 try: | |
| 1373 cmd = adb.adb_commands.AdbCommands.Connect( | |
| 1374 handle, banner='swarming', rsa_keys=_ADB_KEYS, auth_timeout_ms=100) | |
|
Vadim Sh.
2015/08/20 22:40:08
100ms? Is it really that fast?
M-A Ruel
2015/08/21 23:37:39
Yes.
| |
| 1375 except adb.usb_exceptions.DeviceAuthError as e: | |
| 1376 logging.warning('AUTH FAILURE: %s: %s', handle.serial_number, e) | |
| 1377 cmd = None | |
| 1378 except adb.usb_exceptions.ReadFailedError as e: | |
| 1379 logging.warning('READ FAILURE: %s: %s', handle.serial_number, e) | |
| 1380 cmd = None | |
| 1381 except ValueError as e: | |
| 1382 logging.warning( | |
| 1383 'Trying unpluging and pluging it back: %s: %s', | |
| 1384 handle.serial_number, e) | |
| 1385 cmd = None | |
| 1386 cmds[handle.serial_number] = cmd | |
| 1387 | |
| 1388 logging.info('FOUND %d devices', len(cmds)) | |
| 1389 # Remove any /system/build.prop cache so if a device is disconnect, reflashed | |
| 1390 # then reconnected, it will likely be refresh properly. The main concern is | |
| 1391 # that the bot didn't have the time to loop once while this is being done. | |
| 1392 # Restarting the bot works fine too. | |
| 1393 for key in _BUILD_PROP_ANDROID.keys(): | |
| 1394 if key not in cmds: | |
| 1395 _BUILD_PROP_ANDROID.pop(key) | |
| 1396 return cmds | |
| 1397 | |
| 1398 | |
| 1399 def close_devices_android(devices): | |
| 1400 """Closes all devices opened by get_devices_android().""" | |
| 1401 for device in (devices or {}).itervalues(): | |
| 1402 if device: | |
| 1403 device.Close() | |
|
Vadim Sh.
2015/08/20 22:40:09
catch and log exceptions here? (so that all device
M-A Ruel
2015/08/21 23:37:39
I don't know which exception type would be thrown
| |
| 1404 | |
| 1405 | |
| 1406 def get_build_prop_android(cmd): | |
| 1407 """Returns the system properties for a device. | |
| 1408 | |
| 1409 This isn't really changing through the lifetime of a bot. One corner case is | |
| 1410 when the device is flashed or disconnected. | |
| 1411 """ | |
| 1412 if cmd.handle.serial_number not in _BUILD_PROP_ANDROID: | |
| 1413 properties = {} | |
| 1414 try: | |
| 1415 out = cmd.Shell('cat /system/build.prop').decode('utf-8') | |
| 1416 except adb.usb_exceptions.ReadFailedError: | |
| 1417 # It's a bit annoying because it means timeout_ms was wasted. Blacklist | |
| 1418 # the device until it is disconnected and reconnected. | |
| 1419 properties = None | |
| 1420 else: | |
| 1421 for line in out.splitlines(): | |
| 1422 if line.startswith(u'#') or not line: | |
| 1423 continue | |
| 1424 key, value = line.split(u'=', 1) | |
| 1425 properties[key] = value | |
| 1426 _BUILD_PROP_ANDROID[cmd.handle.serial_number] = properties | |
| 1427 return _BUILD_PROP_ANDROID[cmd.handle.serial_number] | |
| 1428 | |
| 1429 | |
| 1430 def get_temp_android(cmd): | |
| 1431 """Returns the device's 2 temperatures.""" | |
| 1432 temps = [] | |
| 1433 for i in xrange(2): | |
| 1434 try: | |
| 1435 temps.append( | |
| 1436 int(cmd.Shell('cat /sys/class/thermal/thermal_zone%d/temp' % i))) | |
| 1437 except ValueError: | |
| 1438 pass | |
| 1439 return temps | |
| 1440 | |
| 1441 | |
| 1442 def get_battery_android(cmd): | |
| 1443 """Returns details about the battery's state.""" | |
| 1444 props = {} | |
| 1445 out = cmd.Shell('dumpsys battery').decode('utf-8', 'replace') | |
| 1446 for line in out.splitlines(): | |
| 1447 if line.endswith(u':'): | |
| 1448 continue | |
| 1449 key, value = line.split(u': ', 2) | |
| 1450 props[key.lstrip()] = value | |
| 1451 out = {u'power': []} | |
| 1452 if props[u'AC powered'] == u'true': | |
| 1453 out[u'power'].append(u'AC') | |
| 1454 if props[u'USB powered'] == u'true': | |
| 1455 out[u'power'].append(u'USB') | |
| 1456 if props[u'Wireless powered'] == u'true': | |
| 1457 out[u'power'].append(u'Wireless') | |
| 1458 for key in (u'health', u'level', u'status', u'temperature', u'voltage'): | |
| 1459 out[key] = props[key] | |
| 1460 return out | |
| 1461 | |
| 1462 | |
| 1463 def get_disk_android(cmd): | |
| 1464 """Returns details about the battery's state.""" | |
| 1465 props = {} | |
| 1466 out = cmd.Shell('dumpsys diskstats').decode('utf-8', 'replace') | |
| 1467 for line in out.splitlines(): | |
| 1468 if line.endswith(u':'): | |
| 1469 continue | |
| 1470 key, value = line.split(u': ', 2) | |
| 1471 match = re.match(u'^(\d+)K / (\d+)K.*', value) | |
| 1472 if match: | |
| 1473 props[key.lstrip()] = { | |
| 1474 'free_mb': round(float(match.group(1)) / 1024., 1), | |
| 1475 'size_mb': round(float(match.group(2)) / 1024., 1), | |
| 1476 } | |
| 1477 return { | |
| 1478 u'cache': props[u'Cache-Free'], | |
| 1479 u'data': props[u'Data-Free'], | |
| 1480 u'system': props[u'System-Free'], | |
| 1481 } | |
| 1482 | |
| 1483 | |
| 1484 def get_dimensions_all_devices_android(cmds): | |
| 1485 """Returns the default dimensions for an host with multiple android devices. | |
| 1486 """ | |
| 1487 dimensions = get_dimensions() | |
| 1488 if not cmds: | |
| 1489 return dimensions | |
| 1490 | |
| 1491 # Pop a few dimensions otherwise there will be too many dimensions. | |
| 1492 del dimensions[u'cpu'] | |
| 1493 del dimensions[u'cores'] | |
| 1494 del dimensions[u'gpu'] | |
| 1495 dimensions.pop(u'machine_type') | |
| 1496 | |
| 1497 # Make sure all the devices use the same board. | |
| 1498 keys = (u'build.id', u'product.board') | |
| 1499 for key in keys: | |
| 1500 dimensions[key] = set() | |
| 1501 dimensions[u'android'] = [] | |
| 1502 for serial_number, cmd in cmds.iteritems(): | |
| 1503 if cmd: | |
| 1504 properties = get_build_prop_android(cmd) | |
| 1505 if properties: | |
| 1506 for key in keys: | |
| 1507 dimensions[key].add(properties[u'ro.' + key]) | |
| 1508 dimensions[u'android'].append(serial_number) | |
| 1509 dimensions[u'android'].sort() | |
| 1510 for key in keys: | |
| 1511 if not dimensions[key]: | |
| 1512 del dimensions[key] | |
| 1513 else: | |
| 1514 dimensions[key] = sorted(dimensions[key]) | |
| 1515 nb_android = len(dimensions[u'android']) | |
| 1516 dimensions[u'android_devices'] = map( | |
| 1517 str, range(nb_android, max(0, nb_android-2), -1)) | |
| 1518 return dimensions | |
| 1519 | |
| 1520 | |
| 1521 def get_state_all_devices_android(cmds): | |
| 1522 """Returns state information about all the devices connected to the host. | |
| 1523 """ | |
| 1524 state = get_state() | |
| 1525 if not cmds: | |
| 1526 return state | |
| 1527 | |
| 1528 # Add a few values that were poped from dimensions. | |
| 1529 cpu_type = get_cpu_type() | |
| 1530 cpu_bitness = get_cpu_bitness() | |
| 1531 state[u'cpu'] = [ | |
| 1532 cpu_type, | |
| 1533 cpu_type + u'-' + cpu_bitness, | |
| 1534 ] | |
| 1535 state[u'cores'] = [unicode(get_num_processors())] | |
| 1536 state[u'gpu'] = get_gpu()[0] | |
| 1537 machine_type = get_machine_type() | |
| 1538 if machine_type: | |
| 1539 state[u'machine_type'] = [machine_type] | |
| 1540 | |
| 1541 keys = ( | |
| 1542 u'board.platform', u'product.cpu.abi', u'product.cpu.abi2', | |
| 1543 u'build.tags', u'build.type', u'build.version.sdk') | |
| 1544 state['devices'] = {} | |
| 1545 for serial_number, cmd in cmds.iteritems(): | |
| 1546 if cmd: | |
| 1547 properties = get_build_prop_android(cmd) | |
| 1548 if properties: | |
| 1549 # TODO(maruel): uptime, diskstats, wifi, power, throttle, etc. | |
| 1550 device = { | |
| 1551 u'build': {key: properties[u'ro.'+key] for key in keys}, | |
| 1552 u'disk': get_disk_android(cmd), | |
| 1553 u'battery': get_battery_android(cmd), | |
| 1554 u'state': u'available', | |
| 1555 u'temp': get_temp_android(cmd), | |
| 1556 } | |
| 1557 else: | |
| 1558 device = {u'state': u'unavailable'} | |
| 1559 else: | |
| 1560 device = {u'state': 'unauthenticated'} | |
| 1561 state[u'devices'][serial_number] = device | |
| 1562 return state | |
| 1209 | 1563 |
| 1210 | 1564 |
| 1211 ### | 1565 ### |
| 1212 | 1566 |
| 1213 | 1567 |
| 1214 def get_dimensions(): | 1568 def get_dimensions(): |
| 1215 """Returns the default dimensions.""" | 1569 """Returns the default dimensions.""" |
| 1216 os_name = get_os_name() | 1570 os_name = get_os_name() |
| 1217 cpu_type = get_cpu_type() | 1571 cpu_type = get_cpu_type() |
| 1218 cpu_bitness = get_cpu_bitness() | 1572 cpu_bitness = get_cpu_bitness() |
| (...skipping 310 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 1529 os.remove(item) | 1883 os.remove(item) |
| 1530 for item in glob.iglob('%s.???' % name): | 1884 for item in glob.iglob('%s.???' % name): |
| 1531 os.remove(item) | 1885 os.remove(item) |
| 1532 except Exception as e: | 1886 except Exception as e: |
| 1533 logging.exception('trim_rolled_log(%s) failed: %s', name, e) | 1887 logging.exception('trim_rolled_log(%s) failed: %s', name, e) |
| 1534 | 1888 |
| 1535 | 1889 |
| 1536 def main(): | 1890 def main(): |
| 1537 """Prints out the output of get_dimensions() and get_state().""" | 1891 """Prints out the output of get_dimensions() and get_state().""" |
| 1538 # Pass an empty tag, so pop it up since it has no significance. | 1892 # Pass an empty tag, so pop it up since it has no significance. |
| 1893 devices = get_devices_android() | |
| 1539 data = { | 1894 data = { |
| 1540 u'dimensions': get_dimensions(), | 1895 u'dimensions': get_dimensions_all_devices_android(devices), |
| 1541 u'state': get_state(), | 1896 u'state': get_state_all_devices_android(devices), |
| 1542 } | 1897 } |
| 1898 close_devices_android(devices) | |
|
Vadim Sh.
2015/08/20 22:40:08
put close_devices_android(...) in finally section.
M-A Ruel
2015/08/21 23:37:39
Done.
| |
| 1543 json.dump(data, sys.stdout, indent=2, sort_keys=True, separators=(',', ': ')) | 1899 json.dump(data, sys.stdout, indent=2, sort_keys=True, separators=(',', ': ')) |
| 1544 print('') | 1900 print('') |
| 1545 return 0 | 1901 return 0 |
| 1546 | 1902 |
| 1547 | 1903 |
| 1548 if __name__ == '__main__': | 1904 if __name__ == '__main__': |
| 1549 sys.exit(main()) | 1905 sys.exit(main()) |
| OLD | NEW |