qvm_ls.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. # pylint: disable=too-few-public-methods
  2. #
  3. # The Qubes OS Project, https://www.qubes-os.org/
  4. #
  5. # Copyright (C) 2015 Joanna Rutkowska <joanna@invisiblethingslab.com>
  6. # Copyright (C) 2015 Wojtek Porczyk <woju@invisiblethingslab.com>
  7. # Copyright (C) 2017 Marek Marczykowski-Górecki
  8. # <marmarek@invisiblethingslab.com>
  9. #
  10. # This program is free software; you can redistribute it and/or modify
  11. # it under the terms of the GNU Lesser General Public License as published by
  12. # the Free Software Foundation; either version 2.1 of the License, or
  13. # (at your option) any later version.
  14. #
  15. # This program is distributed in the hope that it will be useful,
  16. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. # GNU Lesser General Public License for more details.
  19. #
  20. # You should have received a copy of the GNU Lesser General Public License along
  21. # with this program; if not, write to the Free Software Foundation, Inc.,
  22. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  23. #
  24. '''qvm-ls - List available domains'''
  25. from __future__ import print_function
  26. import argparse
  27. import collections.abc
  28. import sys
  29. import textwrap
  30. import qubesadmin
  31. import qubesadmin.spinner
  32. import qubesadmin.tools
  33. import qubesadmin.utils
  34. import qubesadmin.vm
  35. import qubesadmin.exc
  36. #
  37. # columns
  38. #
  39. class Column(object):
  40. '''A column in qvm-ls output characterised by its head and a way
  41. to fetch a parameter describing the domain.
  42. :param str head: Column head (usually uppercase).
  43. :param str attr: Attribute, possibly complex (containing ``.``). This may \
  44. also be a callable that gets as its only argument the domain.
  45. :param str doc: Description of column (will be visible in --help-columns).
  46. '''
  47. #: collection of all columns
  48. columns = {}
  49. def __init__(self, head, attr=None, doc=None):
  50. self.ls_head = head
  51. self.__doc__ = doc
  52. # intentionally not always do set self._attr,
  53. # to cause AttributeError in self.format()
  54. if attr is not None:
  55. self._attr = attr
  56. self.__class__.columns[self.ls_head] = self
  57. def cell(self, vm, insertion=0):
  58. '''Format one cell.
  59. .. note::
  60. This is only for technical formatting (filling with space). If you
  61. want to subclass the :py:class:`Column` class, you should override
  62. :py:meth:`Column.format` method instead.
  63. :param qubes.vm.qubesvm.QubesVM: Domain to get a value from.
  64. :param int insertion: Intending to shift the value to the right.
  65. :returns: string to display
  66. :rtype: str
  67. '''
  68. value = self.format(vm) or '-'
  69. if insertion > 0 and self.ls_head == 'NAME':
  70. value = '└─' + value
  71. value = ' ' * (insertion-1) + value
  72. return value
  73. def format(self, vm):
  74. '''Format one cell value.
  75. Return value to put in a table cell.
  76. :param qubes.vm.qubesvm.QubesVM: Domain to get a value from.
  77. :returns: Value to put, or :py:obj:`None` if no value.
  78. :rtype: str or None
  79. '''
  80. ret = None
  81. try:
  82. if isinstance(self._attr, str):
  83. ret = vm
  84. for attrseg in self._attr.split('.'):
  85. ret = getattr(ret, attrseg)
  86. elif isinstance(self._attr, collections.abc.Callable):
  87. ret = self._attr(vm)
  88. except (AttributeError, ZeroDivisionError):
  89. # division by 0 may be caused by arithmetic in callable attr
  90. return None
  91. if ret is None:
  92. return None
  93. return str(ret)
  94. def __repr__(self):
  95. return '{}(head={!r})'.format(self.__class__.__name__,
  96. self.ls_head)
  97. def __eq__(self, other):
  98. return self.ls_head == other.ls_head
  99. def __lt__(self, other):
  100. return self.ls_head < other.ls_head
  101. class PropertyColumn(Column):
  102. '''Column that displays value from property (:py:class:`property` or
  103. :py:class:`qubes.property`) of domain.
  104. :param name: Name of VM property.
  105. '''
  106. def __init__(self, name):
  107. ls_head = name.replace('_', '-').upper()
  108. super(PropertyColumn, self).__init__(
  109. head=ls_head,
  110. attr=name)
  111. def __repr__(self):
  112. return '{}(head={!r}'.format(
  113. self.__class__.__name__,
  114. self.ls_head)
  115. def process_vm(vm):
  116. '''Process VM object to find all listable properties.
  117. :param qubesmgmt.vm.QubesVM vm: VM object.
  118. '''
  119. for prop_name in vm.property_list():
  120. PropertyColumn(prop_name)
  121. def flag(field):
  122. '''Mark method as flag field.
  123. :param int field: Which field to fill (counted from 1)
  124. '''
  125. def decorator(obj):
  126. # pylint: disable=missing-docstring
  127. obj.field = field
  128. return obj
  129. return decorator
  130. def simple_flag(field, letter, attr, doc=None):
  131. '''Create simple, binary flag.
  132. :param str attr: Attribute name to check. If result is true, flag is fired.
  133. :param str letter: The letter to show.
  134. '''
  135. def helper(self, vm):
  136. # pylint: disable=missing-docstring,unused-argument
  137. try:
  138. value = getattr(vm, attr)
  139. except AttributeError:
  140. value = False
  141. if value:
  142. return letter[0]
  143. helper.__doc__ = doc
  144. helper.field = field
  145. return helper
  146. class FlagsColumn(Column):
  147. '''Some fancy flags that describe general status of the domain.'''
  148. # pylint: disable=no-self-use
  149. def __init__(self):
  150. super(FlagsColumn, self).__init__(
  151. head='FLAGS',
  152. doc=self.__class__.__doc__)
  153. @flag(1)
  154. def type(self, vm):
  155. '''Type of domain.
  156. 0 AdminVM (AKA Dom0)
  157. aA AppVM
  158. dD DisposableVM
  159. sS StandaloneVM
  160. tT TemplateVM
  161. When it is HVM (optimised VM), the letter is capital.
  162. '''
  163. type_codes = {
  164. 'AdminVM': '0',
  165. 'TemplateVM': 't',
  166. 'AppVM': 'a',
  167. 'StandaloneVM': 's',
  168. 'DispVM': 'd',
  169. }
  170. ret = type_codes.get(vm.klass, None)
  171. if ret == '0':
  172. return ret
  173. if ret is not None:
  174. if getattr(vm, 'virt_mode', 'pv') == 'hvm':
  175. return ret.upper()
  176. return ret
  177. @flag(2)
  178. def power(self, vm):
  179. '''Current power state.
  180. r running
  181. t transient
  182. p paused
  183. s suspended
  184. h halting
  185. d dying
  186. c crashed
  187. ? unknown
  188. '''
  189. state = vm.get_power_state().lower()
  190. if state == 'unknown':
  191. return '?'
  192. if state in ('running', 'transient', 'paused', 'suspended',
  193. 'halting', 'dying', 'crashed'):
  194. return state[0]
  195. updateable = simple_flag(3, 'U', 'updateable',
  196. doc='If the domain is updateable.')
  197. provides_network = simple_flag(4, 'N', 'provides_network',
  198. doc='If the domain provides network.')
  199. installed_by_rpm = simple_flag(5, 'R', 'installed_by_rpm',
  200. doc='If the domain is installed by RPM.')
  201. internal = simple_flag(6, 'i', 'internal',
  202. doc='If the domain is internal (not normally shown, no appmenus).')
  203. debug = simple_flag(7, 'D', 'debug',
  204. doc='If the domain is being debugged.')
  205. autostart = simple_flag(8, 'A', 'autostart',
  206. doc='If the domain is marked for autostart.')
  207. # TODO (not sure if really):
  208. # include in backups
  209. # uses_custom_config
  210. def _no_flag(self, vm):
  211. '''Reserved for future use.'''
  212. @classmethod
  213. def get_flags(cls):
  214. '''Get all flags as list.
  215. Holes between flags are filled with :py:meth:`_no_flag`.
  216. :rtype: list
  217. '''
  218. flags = {}
  219. for mycls in cls.__mro__:
  220. for attr in mycls.__dict__.values():
  221. if not hasattr(attr, 'field'):
  222. continue
  223. if attr.field in flags:
  224. continue
  225. flags[attr.field] = attr
  226. return [(flags[i] if i in flags else cls._no_flag)
  227. for i in range(1, max(flags) + 1)]
  228. def format(self, vm):
  229. return ''.join((flag(self, vm) or '-') for flag in self.get_flags())
  230. def calc_size(vm, volume_name):
  231. ''' Calculates the volume size in MB '''
  232. try:
  233. return vm.volumes[volume_name].size // 1024 // 1024
  234. except KeyError:
  235. return 0
  236. def calc_usage(vm, volume_name):
  237. ''' Calculates the volume usage in MB '''
  238. try:
  239. return vm.volumes[volume_name].usage // 1024 // 1024
  240. except KeyError:
  241. return 0
  242. def calc_used(vm, volume_name):
  243. ''' Calculates the volume usage in percent '''
  244. size = calc_size(vm, volume_name)
  245. if size == 0:
  246. return 0
  247. usage = calc_usage(vm, volume_name)
  248. return '{}%'.format(usage * 100 // size)
  249. # todo maxmem
  250. Column('STATE',
  251. attr=(lambda vm: vm.get_power_state()),
  252. doc='Current power state.')
  253. Column('CLASS',
  254. attr=(lambda vm: vm.klass),
  255. doc='Class of the qube.')
  256. Column('GATEWAY',
  257. attr='netvm.gateway',
  258. doc='Network gateway.')
  259. Column('MEMORY',
  260. attr=(lambda vm: vm.get_mem() // 1024 if vm.is_running() else None),
  261. doc='Memory currently used by VM')
  262. Column('DISK',
  263. attr=(lambda vm: vm.get_disk_utilization() // 1024 // 1024),
  264. doc='Total disk utilisation.')
  265. Column('PRIV-CURR',
  266. attr=(lambda vm: calc_usage(vm, 'private')),
  267. doc='Disk utilisation by private image (/home, /usr/local).')
  268. Column('PRIV-MAX',
  269. attr=(lambda vm: calc_size(vm, 'private')),
  270. doc='Maximum available space for private image.')
  271. Column('PRIV-USED',
  272. attr=(lambda vm: calc_used(vm, 'private')),
  273. doc='Disk utilisation by private image as a percentage of available space.')
  274. Column('ROOT-CURR',
  275. attr=(lambda vm: calc_usage(vm, 'root')),
  276. doc='Disk utilisation by root image (/usr, /lib, /etc, ...).')
  277. Column('ROOT-MAX',
  278. attr=(lambda vm: calc_size(vm, 'root')),
  279. doc='Maximum available space for root image.')
  280. Column('ROOT-USED',
  281. attr=(lambda vm: calc_used(vm, 'root')),
  282. doc='Disk utilisation by root image as a percentage of available space.')
  283. FlagsColumn()
  284. class Table(object):
  285. '''Table that is displayed to the user.
  286. :param domains: Domains to include in the table.
  287. :param list colnames: Names of the columns (need not to be uppercase).
  288. '''
  289. def __init__(self, domains, colnames, spinner, raw_data=False,
  290. tree_sorted=False):
  291. self.domains = domains
  292. self.columns = tuple(Column.columns[col.upper().replace('_', '-')]
  293. for col in colnames)
  294. self.spinner = spinner
  295. self.raw_data = raw_data
  296. self.tree_sorted = tree_sorted
  297. def get_head(self):
  298. '''Get table head data (all column heads).'''
  299. return [col.ls_head for col in self.columns]
  300. def get_row(self, vm, insertion=0):
  301. '''Get single table row data (all columns for one domain).'''
  302. ret = []
  303. for col in self.columns:
  304. if self.tree_sorted and col.ls_head == 'NAME':
  305. ret.append(col.cell(vm, insertion))
  306. else:
  307. ret.append(col.cell(vm))
  308. self.spinner.update()
  309. return ret
  310. def tree_append_child(self, parent, level):
  311. '''Concatenate the network children of the vm to a list.
  312. :param qubes.vm.qubesvm.QubesVM: Parent vm of the children VMs
  313. '''
  314. childs = list()
  315. for child in parent.connected_vms:
  316. if child.provides_network and child in self.domains:
  317. childs.append((level, child))
  318. childs += self.tree_append_child(child, level+1)
  319. elif child in self.domains:
  320. childs.append((level, child))
  321. return childs
  322. def sort_to_tree(self, domains):
  323. '''Sort the domains as a network tree. It returns a list of sets. Each
  324. tuple stores the insertion of the cell name and the vm object.
  325. :param list() domains: The domains which will be sorted
  326. :return list(tuple()) tree: returns a list of tuple(insertion, vm)
  327. '''
  328. tree = list()
  329. # We need a copy of domains, because domains.remove() is not allowed
  330. # while iterating over it. Besides, the domains should be sorted anyway.
  331. sorted_doms = sorted(domains)
  332. for dom in sorted_doms:
  333. try:
  334. if dom.netvm is None and not dom.provides_network:
  335. tree.append((0, dom))
  336. domains.remove(dom)
  337. # dom0 and eventually others have no netvm attribute
  338. except (qubesadmin.exc.QubesNoSuchPropertyError, AttributeError):
  339. tree.append((0, dom))
  340. domains.remove(dom)
  341. for dom in sorted(domains):
  342. if dom.netvm is None and dom.provides_network:
  343. tree.append((0, dom))
  344. domains.remove(dom)
  345. tree += self.tree_append_child(dom, 1)
  346. return tree
  347. def write_table(self, stream=sys.stdout):
  348. '''Write whole table to file-like object.
  349. :param file stream: Stream to write the table to.
  350. '''
  351. table_data = []
  352. if not self.raw_data:
  353. self.spinner.show('please wait...')
  354. table_data.append(self.get_head())
  355. self.spinner.update()
  356. if self.tree_sorted:
  357. insertion_vm_list = self.sort_to_tree(self.domains)
  358. for insertion, vm in insertion_vm_list:
  359. table_data.append(self.get_row(vm, insertion))
  360. else:
  361. for vm in sorted(self.domains):
  362. table_data.append(self.get_row(vm))
  363. self.spinner.hide()
  364. qubesadmin.tools.print_table(table_data, stream=stream)
  365. else:
  366. for vm in sorted(self.domains):
  367. stream.write('|'.join(self.get_row(vm)) + '\n')
  368. #: Available formats. Feel free to plug your own one.
  369. formats = {
  370. 'simple': ('name', 'state', 'class', 'label', 'template', 'netvm'),
  371. 'network': ('name', 'state', 'netvm', 'ip', 'ipback', 'gateway'),
  372. 'kernel': ('name', 'state', 'class', 'template', 'kernel', 'kernelopts'),
  373. 'full': ('name', 'state', 'class', 'label', 'qid', 'xid', 'uuid'),
  374. # 'perf': ('name', 'state', 'cpu', 'memory'),
  375. 'disk': ('name', 'state', 'disk',
  376. 'priv-curr', 'priv-max', 'priv-used',
  377. 'root-curr', 'root-max', 'root-used'),
  378. }
  379. class _HelpColumnsAction(argparse.Action):
  380. '''Action for argument parser that displays all columns and exits.'''
  381. # pylint: disable=redefined-builtin
  382. def __init__(self,
  383. option_strings,
  384. dest=argparse.SUPPRESS,
  385. default=argparse.SUPPRESS,
  386. help='list all available columns with short descriptions and exit'):
  387. super(_HelpColumnsAction, self).__init__(
  388. option_strings=option_strings,
  389. dest=dest,
  390. default=default,
  391. nargs=0,
  392. help=help)
  393. def __call__(self, parser, namespace, values, option_string=None):
  394. width = max(len(column.ls_head) for column in Column.columns.values())
  395. wrapper = textwrap.TextWrapper(width=80,
  396. initial_indent=' ', subsequent_indent=' ' * (width + 6))
  397. text = 'Available columns:\n' + '\n'.join(
  398. wrapper.fill('{head:{width}s} {doc}'.format(
  399. head=column.ls_head,
  400. doc=column.__doc__ or '',
  401. width=width))
  402. for column in sorted(Column.columns.values()))
  403. text += '\n\nAdditionally any VM property may be used as a column, ' \
  404. 'see qvm-prefs --help-properties for available values'
  405. parser.exit(message=text + '\n')
  406. class _HelpFormatsAction(argparse.Action):
  407. '''Action for argument parser that displays all formats and exits.'''
  408. # pylint: disable=redefined-builtin
  409. def __init__(self,
  410. option_strings,
  411. dest=argparse.SUPPRESS,
  412. default=argparse.SUPPRESS,
  413. help='list all available formats with their definitions and exit'):
  414. super(_HelpFormatsAction, self).__init__(
  415. option_strings=option_strings,
  416. dest=dest,
  417. default=default,
  418. nargs=0,
  419. help=help)
  420. def __call__(self, parser, namespace, values, option_string=None):
  421. width = max(len(fmt) for fmt in formats)
  422. text = 'Available formats:\n' + ''.join(
  423. ' {fmt:{width}s} {columns}\n'.format(
  424. fmt=fmt, columns=','.join(formats[fmt]).upper(), width=width)
  425. for fmt in sorted(formats))
  426. parser.exit(message=text)
  427. # common VM power states for easy command-line filtering
  428. DOMAIN_POWER_STATES = ['running', 'paused', 'halted']
  429. def matches_power_states(domain, **states):
  430. '''Filter domains by their power state'''
  431. # if all values are False (default) => return match on every VM
  432. if not states or set(states.values()) == {False}:
  433. return True
  434. # otherwise => only VMs matching True states
  435. requested_states = [state for state, active in states.items() if active]
  436. return domain.get_power_state().lower() in requested_states
  437. def get_parser():
  438. '''Create :py:class:`argparse.ArgumentParser` suitable for
  439. :program:`qvm-ls`.
  440. '''
  441. # parser creation is delayed to get all the columns that are scattered
  442. # thorough the modules
  443. wrapper = textwrap.TextWrapper(width=80, break_on_hyphens=False,
  444. initial_indent=' ', subsequent_indent=' ')
  445. parser = qubesadmin.tools.QubesArgumentParser(
  446. vmname_nargs=argparse.ZERO_OR_MORE,
  447. formatter_class=argparse.RawTextHelpFormatter,
  448. description='List Qubes domains and their parametres.',
  449. epilog='available formats (see --help-formats):\n{}\n\n'
  450. 'available columns (see --help-columns):\n{}'.format(
  451. wrapper.fill(', '.join(sorted(formats.keys()))),
  452. wrapper.fill(', '.join(sorted(sorted(Column.columns.keys()))))))
  453. parser.add_argument('--help-columns', action=_HelpColumnsAction)
  454. parser.add_argument('--help-formats', action=_HelpFormatsAction)
  455. parser_formats = parser.add_mutually_exclusive_group()
  456. parser_formats.add_argument('--format', '-o', metavar='FORMAT',
  457. action='store', choices=formats.keys(), default='simple',
  458. help='preset format')
  459. parser_formats.add_argument('--fields', '-O', metavar='FIELD,...',
  460. action='store',
  461. help='user specified format (see available columns below)')
  462. parser.add_argument('--tags', nargs='+', metavar='TAG',
  463. help='show only VMs having specific tag(s)')
  464. for pwrstate in DOMAIN_POWER_STATES:
  465. parser.add_argument('--{}'.format(pwrstate), action='store_true',
  466. help='show {} VMs'.format(pwrstate))
  467. parser.add_argument('--raw-data', action='store_true',
  468. help='Display specify data of specified VMs. Intended for '
  469. 'bash-parsing.')
  470. parser.add_argument('--tree', '-t',
  471. action='store_const', const='tree',
  472. help='sort domain list as network tree')
  473. parser.add_argument('--spinner',
  474. action='store_true', dest='spinner',
  475. help='reenable spinner')
  476. parser.add_argument('--no-spinner',
  477. action='store_false', dest='spinner',
  478. help='disable spinner')
  479. # shortcuts, compatibility with Qubes 3.2
  480. parser.add_argument('--raw-list', action='store_true',
  481. help='Same as --raw-data --fields=name')
  482. parser.add_argument('--disk', '-d',
  483. action='store_const', dest='format', const='disk',
  484. help='Same as --format=disk')
  485. parser.add_argument('--network', '-n',
  486. action='store_const', dest='format', const='network',
  487. help='Same as --format=network')
  488. parser.add_argument('--kernel', '-k',
  489. action='store_const', dest='format', const='kernel',
  490. help='Same as --format=kernel')
  491. parser.set_defaults(spinner=True)
  492. # parser.add_argument('--conf', '-c',
  493. # action='store', metavar='CFGFILE',
  494. # help='Qubes config file')
  495. return parser
  496. def main(args=None, app=None):
  497. '''Main routine of :program:`qvm-ls`.
  498. :param list args: Optional arguments to override those delivered from \
  499. command line.
  500. :param app: Operate on given app object instead of instantiating new one.
  501. '''
  502. parser = get_parser()
  503. try:
  504. args = parser.parse_args(args, app=app)
  505. except qubesadmin.exc.QubesException as e:
  506. parser.print_error(str(e))
  507. return 1
  508. # fetch all the properties with one Admin API call, instead of issuing
  509. # one call per property
  510. args.app.cache_enabled = True
  511. if args.raw_list:
  512. args.raw_data = True
  513. args.fields = 'name'
  514. if args.fields:
  515. columns = [col.strip() for col in args.fields.split(',')]
  516. else:
  517. columns = formats[args.format]
  518. # assume unknown columns are VM properties
  519. for col in columns:
  520. if col.upper() not in Column.columns:
  521. PropertyColumn(col.lower())
  522. if args.spinner and not args.raw_data:
  523. # we need Enterprise Edition™, since it's the only one that detects TTY
  524. # and uses dots if we are redirected somewhere else
  525. spinner = qubesadmin.spinner.QubesSpinnerEnterpriseEdition(sys.stderr)
  526. else:
  527. spinner = qubesadmin.spinner.DummySpinner(sys.stderr)
  528. if args.domains:
  529. domains = args.domains
  530. else:
  531. domains = args.app.domains
  532. if args.all_domains:
  533. # Normally, --all means "all domains except for AdminVM".
  534. # However, in the case of qvm-ls it does not make sense to exclude
  535. # AdminVMs, so we override the list from parser.
  536. domains = [
  537. vm for vm in args.app.domains if vm.name not in args.exclude
  538. ]
  539. if args.tags:
  540. # filter only VMs having at least one of the specified tags
  541. domains = [dom for dom in domains
  542. if set(dom.tags).intersection(set(args.tags))]
  543. pwrstates = {state: getattr(args, state) for state in DOMAIN_POWER_STATES}
  544. domains = [d for d in domains
  545. if matches_power_states(d, **pwrstates)]
  546. table = Table(domains, columns, spinner, args.raw_data, args.tree)
  547. table.write_table(sys.stdout)
  548. return 0
  549. if __name__ == '__main__':
  550. sys.exit(main())