add escalator cli framework
[escalator.git] / client / escalatorclient / shell.py
1 # Copyright 2012 OpenStack Foundation
2 # All Rights Reserved.
3 #
4 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
5 #    not use this file except in compliance with the License. You may obtain
6 #    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, WITHOUT
12 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 #    License for the specific language governing permissions and limitations
14 #    under the License.
15
16 """
17 Command-line interface to the OpenStack Images API.
18 """
19
20 from __future__ import print_function
21
22 import argparse
23 import copy
24 import getpass
25 import json
26 import logging
27 import os
28 from os.path import expanduser
29 import sys
30 import traceback
31
32 from oslo_utils import encodeutils
33 from oslo_utils import importutils
34 import six.moves.urllib.parse as urlparse
35
36 import escalatorclient
37 from escalatorclient import _i18n
38 from escalatorclient.common import utils
39 from escalatorclient import exc
40
41 from keystoneclient.auth.identity import v2 as v2_auth
42 from keystoneclient.auth.identity import v3 as v3_auth
43 from keystoneclient import discover
44 from keystoneclient.openstack.common.apiclient import exceptions as ks_exc
45 from keystoneclient import session
46
47 osprofiler_profiler = importutils.try_import("osprofiler.profiler")
48 _ = _i18n._
49
50
51 class escalatorShell(object):
52
53     def _append_global_identity_args(self, parser):
54         # FIXME(bobt): these are global identity (Keystone) arguments which
55         # should be consistent and shared by all service clients. Therefore,
56         # they should be provided by python-keystoneclient. We will need to
57         # refactor this code once this functionality is avaible in
58         # python-keystoneclient. See
59         #
60         # https://bugs.launchpad.net/python-keystoneclient/+bug/1332337
61         #
62         parser.add_argument('-k', '--insecure',
63                             default=False,
64                             action='store_true',
65                             help='Explicitly allow escalatorclient to perform '
66                             '\"insecure SSL\" (https) requests. The server\'s '
67                             'certificate will not be verified against any '
68                             'certificate authorities. This option should '
69                             'be used with caution.')
70
71         parser.add_argument('--os-cert',
72                             help='Path of certificate file to use in SSL '
73                             'connection. This file can optionally be '
74                             'prepended with the private key.')
75
76         parser.add_argument('--cert-file',
77                             dest='os_cert',
78                             help='DEPRECATED! Use --os-cert.')
79
80         parser.add_argument('--os-key',
81                             help='Path of client key to use in SSL '
82                             'connection. This option is not necessary '
83                             'if your key is prepended to your cert file.')
84
85         parser.add_argument('--key-file',
86                             dest='os_key',
87                             help='DEPRECATED! Use --os-key.')
88
89         parser.add_argument('--os-cacert',
90                             metavar='<ca-certificate-file>',
91                             dest='os_cacert',
92                             default=utils.env('OS_CACERT'),
93                             help='Path of CA TLS certificate(s) used to '
94                             'verify the remote server\'s certificate. '
95                             'Without this option escalator looks for the '
96                             'default system CA certificates.')
97
98         parser.add_argument('--ca-file',
99                             dest='os_cacert',
100                             help='DEPRECATED! Use --os-cacert.')
101
102         parser.add_argument('--os-username',
103                             default=utils.env('OS_USERNAME'),
104                             help='Defaults to env[OS_USERNAME].')
105
106         parser.add_argument('--os_username',
107                             help=argparse.SUPPRESS)
108
109         parser.add_argument('--os-user-id',
110                             default=utils.env('OS_USER_ID'),
111                             help='Defaults to env[OS_USER_ID].')
112
113         parser.add_argument('--os-user-domain-id',
114                             default=utils.env('OS_USER_DOMAIN_ID'),
115                             help='Defaults to env[OS_USER_DOMAIN_ID].')
116
117         parser.add_argument('--os-user-domain-name',
118                             default=utils.env('OS_USER_DOMAIN_NAME'),
119                             help='Defaults to env[OS_USER_DOMAIN_NAME].')
120
121         parser.add_argument('--os-project-id',
122                             default=utils.env('OS_PROJECT_ID'),
123                             help='Another way to specify tenant ID. '
124                                  'This option is mutually exclusive with '
125                                  ' --os-tenant-id. '
126                                  'Defaults to env[OS_PROJECT_ID].')
127
128         parser.add_argument('--os-project-name',
129                             default=utils.env('OS_PROJECT_NAME'),
130                             help='Another way to specify tenant name. '
131                                  'This option is mutually exclusive with '
132                                  ' --os-tenant-name. '
133                                  'Defaults to env[OS_PROJECT_NAME].')
134
135         parser.add_argument('--os-project-domain-id',
136                             default=utils.env('OS_PROJECT_DOMAIN_ID'),
137                             help='Defaults to env[OS_PROJECT_DOMAIN_ID].')
138
139         parser.add_argument('--os-project-domain-name',
140                             default=utils.env('OS_PROJECT_DOMAIN_NAME'),
141                             help='Defaults to env[OS_PROJECT_DOMAIN_NAME].')
142
143         parser.add_argument('--os-password',
144                             default=utils.env('OS_PASSWORD'),
145                             help='Defaults to env[OS_PASSWORD].')
146
147         parser.add_argument('--os_password',
148                             help=argparse.SUPPRESS)
149
150         parser.add_argument('--os-tenant-id',
151                             default=utils.env('OS_TENANT_ID'),
152                             help='Defaults to env[OS_TENANT_ID].')
153
154         parser.add_argument('--os_tenant_id',
155                             help=argparse.SUPPRESS)
156
157         parser.add_argument('--os-tenant-name',
158                             default=utils.env('OS_TENANT_NAME'),
159                             help='Defaults to env[OS_TENANT_NAME].')
160
161         parser.add_argument('--os_tenant_name',
162                             help=argparse.SUPPRESS)
163
164         parser.add_argument('--os-auth-url',
165                             default=utils.env('OS_AUTH_URL'),
166                             help='Defaults to env[OS_AUTH_URL].')
167
168         parser.add_argument('--os_auth_url',
169                             help=argparse.SUPPRESS)
170
171         parser.add_argument('--os-region-name',
172                             default=utils.env('OS_REGION_NAME'),
173                             help='Defaults to env[OS_REGION_NAME].')
174
175         parser.add_argument('--os_region_name',
176                             help=argparse.SUPPRESS)
177
178         parser.add_argument('--os-auth-token',
179                             default=utils.env('OS_AUTH_TOKEN'),
180                             help='Defaults to env[OS_AUTH_TOKEN].')
181
182         parser.add_argument('--os_auth_token',
183                             help=argparse.SUPPRESS)
184
185         parser.add_argument('--os-service-type',
186                             default=utils.env('OS_SERVICE_TYPE'),
187                             help='Defaults to env[OS_SERVICE_TYPE].')
188
189         parser.add_argument('--os_service_type',
190                             help=argparse.SUPPRESS)
191
192         parser.add_argument('--os-endpoint-type',
193                             default=utils.env('OS_ENDPOINT_TYPE'),
194                             help='Defaults to env[OS_ENDPOINT_TYPE].')
195
196         parser.add_argument('--os_endpoint_type',
197                             help=argparse.SUPPRESS)
198
199         parser.add_argument('--os-endpoint',
200                             default=utils.env('OS_ENDPOINT'),
201                             help='Defaults to env[OS_ENDPOINT].')
202
203         parser.add_argument('--os_endpoint',
204                             help=argparse.SUPPRESS)
205
206     def get_base_parser(self):
207         parser = argparse.ArgumentParser(
208             prog='escalator',
209             description=__doc__.strip(),
210             epilog='See "escalator help COMMAND" '
211                    'for help on a specific command.',
212             add_help=False,
213             formatter_class=HelpFormatter,
214         )
215
216         # Global arguments
217         parser.add_argument('-h', '--help',
218                             action='store_true',
219                             help=argparse.SUPPRESS,
220                             )
221
222         parser.add_argument('-d', '--debug',
223                             default=bool(utils.env('ESCALATORCLIENT_DEBUG')),
224                             action='store_true',
225                             help='Defaults to env[ESCALATORCLIENT_DEBUG].')
226
227         parser.add_argument('-v', '--verbose',
228                             default=False, action="store_true",
229                             help="Print more verbose output")
230
231         parser.add_argument('--get-schema',
232                             default=False, action="store_true",
233                             dest='get_schema',
234                             help='Ignores cached copy and forces retrieval '
235                                  'of schema that generates portions of the '
236                                  'help text. Ignored with API version 1.')
237
238         parser.add_argument('--timeout',
239                             default=600,
240                             help='Number of seconds to wait for a response')
241
242         parser.add_argument('--no-ssl-compression',
243                             dest='ssl_compression',
244                             default=True, action='store_false',
245                             help='Disable SSL compression when using https.')
246
247         parser.add_argument('-f', '--force',
248                             dest='force',
249                             default=False, action='store_true',
250                             help='Prevent select actions from requesting '
251                             'user confirmation.')
252
253         parser.add_argument('--os-image-url',
254                             default=utils.env('OS_IMAGE_URL'),
255                             help=('Defaults to env[OS_IMAGE_URL]. '
256                                   'If the provided image url contains '
257                                   'a version number and '
258                                   '`--os-image-api-version` is omitted '
259                                   'the version of the URL will be picked as '
260                                   'the image api version to use.'))
261
262         parser.add_argument('--os_image_url',
263                             help=argparse.SUPPRESS)
264
265         parser.add_argument('--os-image-api-version',
266                             default=utils.env('OS_IMAGE_API_VERSION',
267                                               default=None),
268                             help='Defaults to env[OS_IMAGE_API_VERSION] or 1.')
269
270         parser.add_argument('--os_image_api_version',
271                             help=argparse.SUPPRESS)
272
273         if osprofiler_profiler:
274             parser.add_argument('--profile',
275                                 metavar='HMAC_KEY',
276                                 help='HMAC key to use for encrypting context '
277                                 'data for performance profiling of operation. '
278                                 'This key should be the value of HMAC key '
279                                 'configured in osprofiler middleware in '
280                                 'escalator, it is specified in paste '
281                                 'configuration file at '
282                                 '/etc/escalator/api-paste.ini and '
283                                 '/etc/escalator/registry-paste.ini. Without key '
284                                 'the profiling will not be triggered even '
285                                 'if osprofiler is enabled on server side.')
286
287         # FIXME(bobt): this method should come from python-keystoneclient
288         self._append_global_identity_args(parser)
289
290         return parser
291
292     def get_subcommand_parser(self, version):
293         parser = self.get_base_parser()
294
295         self.subcommands = {}
296         subparsers = parser.add_subparsers(metavar='<subcommand>')
297         try:
298             submodule = utils.import_versioned_module(version, 'shell')
299         except ImportError:
300             print('"%s" is not a supported API version. Example '
301                   'values are "1" or "2".' % version)
302             utils.exit()
303
304         self._find_actions(subparsers, submodule)
305         self._find_actions(subparsers, self)
306
307         self._add_bash_completion_subparser(subparsers)
308
309         return parser
310
311     def _find_actions(self, subparsers, actions_module):
312         for attr in (a for a in dir(actions_module) if a.startswith('do_')):
313             # I prefer to be hypen-separated instead of underscores.
314             command = attr[3:].replace('_', '-')
315             callback = getattr(actions_module, attr)
316             desc = callback.__doc__ or ''
317             help = desc.strip().split('\n')[0]
318             arguments = getattr(callback, 'arguments', [])
319
320             subparser = subparsers.add_parser(command,
321                                               help=help,
322                                               description=desc,
323                                               add_help=False,
324                                               formatter_class=HelpFormatter
325                                               )
326             subparser.add_argument('-h', '--help',
327                                    action='help',
328                                    help=argparse.SUPPRESS,
329                                    )
330             self.subcommands[command] = subparser
331             for (args, kwargs) in arguments:
332                 subparser.add_argument(*args, **kwargs)
333             subparser.set_defaults(func=callback)
334
335     def _add_bash_completion_subparser(self, subparsers):
336         subparser = subparsers.add_parser('bash_completion',
337                                           add_help=False,
338                                           formatter_class=HelpFormatter)
339         self.subcommands['bash_completion'] = subparser
340         subparser.set_defaults(func=self.do_bash_completion)
341
342     def _get_image_url(self, args):
343         """Translate the available url-related options into a single string.
344
345         Return the endpoint that should be used to talk to escalator if a
346         clear decision can be made. Otherwise, return None.
347         """
348         if args.os_image_url:
349             return args.os_image_url
350         else:
351             return None
352
353     def _discover_auth_versions(self, session, auth_url):
354         # discover the API versions the server is supporting base on the
355         # given URL
356         v2_auth_url = None
357         v3_auth_url = None
358         try:
359             ks_discover = discover.Discover(session=session, auth_url=auth_url)
360             v2_auth_url = ks_discover.url_for('2.0')
361             v3_auth_url = ks_discover.url_for('3.0')
362         except ks_exc.ClientException as e:
363             # Identity service may not support discover API version.
364             # Lets trying to figure out the API version from the original URL.
365             url_parts = urlparse.urlparse(auth_url)
366             (scheme, netloc, path, params, query, fragment) = url_parts
367             path = path.lower()
368             if path.startswith('/v3'):
369                 v3_auth_url = auth_url
370             elif path.startswith('/v2'):
371                 v2_auth_url = auth_url
372             else:
373                 # not enough information to determine the auth version
374                 msg = ('Unable to determine the Keystone version '
375                        'to authenticate with using the given '
376                        'auth_url. Identity service may not support API '
377                        'version discovery. Please provide a versioned '
378                        'auth_url instead. error=%s') % (e)
379                 raise exc.CommandError(msg)
380
381         return (v2_auth_url, v3_auth_url)
382
383     def _get_keystone_session(self, **kwargs):
384         ks_session = session.Session.construct(kwargs)
385
386         # discover the supported keystone versions using the given auth url
387         auth_url = kwargs.pop('auth_url', None)
388         (v2_auth_url, v3_auth_url) = self._discover_auth_versions(
389             session=ks_session,
390             auth_url=auth_url)
391
392         # Determine which authentication plugin to use. First inspect the
393         # auth_url to see the supported version. If both v3 and v2 are
394         # supported, then use the highest version if possible.
395         user_id = kwargs.pop('user_id', None)
396         username = kwargs.pop('username', None)
397         password = kwargs.pop('password', None)
398         user_domain_name = kwargs.pop('user_domain_name', None)
399         user_domain_id = kwargs.pop('user_domain_id', None)
400         # project and tenant can be used interchangeably
401         project_id = (kwargs.pop('project_id', None) or
402                       kwargs.pop('tenant_id', None))
403         project_name = (kwargs.pop('project_name', None) or
404                         kwargs.pop('tenant_name', None))
405         project_domain_id = kwargs.pop('project_domain_id', None)
406         project_domain_name = kwargs.pop('project_domain_name', None)
407         auth = None
408
409         use_domain = (user_domain_id or
410                       user_domain_name or
411                       project_domain_id or
412                       project_domain_name)
413         use_v3 = v3_auth_url and (use_domain or (not v2_auth_url))
414         use_v2 = v2_auth_url and not use_domain
415
416         if use_v3:
417             auth = v3_auth.Password(
418                 v3_auth_url,
419                 user_id=user_id,
420                 username=username,
421                 password=password,
422                 user_domain_id=user_domain_id,
423                 user_domain_name=user_domain_name,
424                 project_id=project_id,
425                 project_name=project_name,
426                 project_domain_id=project_domain_id,
427                 project_domain_name=project_domain_name)
428         elif use_v2:
429             auth = v2_auth.Password(
430                 v2_auth_url,
431                 username,
432                 password,
433                 tenant_id=project_id,
434                 tenant_name=project_name)
435         else:
436             # if we get here it means domain information is provided
437             # (caller meant to use Keystone V3) but the auth url is
438             # actually Keystone V2. Obviously we can't authenticate a V3
439             # user using V2.
440             exc.CommandError("Credential and auth_url mismatch. The given "
441                              "auth_url is using Keystone V2 endpoint, which "
442                              "may not able to handle Keystone V3 credentials. "
443                              "Please provide a correct Keystone V3 auth_url.")
444
445         ks_session.auth = auth
446         return ks_session
447
448     def _get_endpoint_and_token(self, args, force_auth=False):
449         image_url = self._get_image_url(args)
450         auth_token = args.os_auth_token
451
452         auth_reqd = force_auth or\
453             (utils.is_authentication_required(args.func) and not
454              (auth_token and image_url))
455
456         if not auth_reqd:
457             endpoint = image_url
458             token = args.os_auth_token
459         else:
460
461             if not args.os_username:
462                 raise exc.CommandError(
463                     _("You must provide a username via"
464                       " either --os-username or "
465                       "env[OS_USERNAME]"))
466
467             if not args.os_password:
468                 # No password, If we've got a tty, try prompting for it
469                 if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty():
470                     # Check for Ctl-D
471                     try:
472                         args.os_password = getpass.getpass('OS Password: ')
473                     except EOFError:
474                         pass
475                 # No password because we didn't have a tty or the
476                 # user Ctl-D when prompted.
477                 if not args.os_password:
478                     raise exc.CommandError(
479                         _("You must provide a password via "
480                           "either --os-password, "
481                           "env[OS_PASSWORD], "
482                           "or prompted response"))
483
484             # Validate password flow auth
485             project_info = (
486                 args.os_tenant_name or args.os_tenant_id or (
487                     args.os_project_name and (
488                         args.os_project_domain_name or
489                         args.os_project_domain_id
490                     )
491                 ) or args.os_project_id
492             )
493
494             if not project_info:
495                 # tenant is deprecated in Keystone v3. Use the latest
496                 # terminology instead.
497                 raise exc.CommandError(
498                     _("You must provide a project_id or project_name ("
499                       "with project_domain_name or project_domain_id) "
500                       "via "
501                       "  --os-project-id (env[OS_PROJECT_ID])"
502                       "  --os-project-name (env[OS_PROJECT_NAME]),"
503                       "  --os-project-domain-id "
504                       "(env[OS_PROJECT_DOMAIN_ID])"
505                       "  --os-project-domain-name "
506                       "(env[OS_PROJECT_DOMAIN_NAME])"))
507
508             if not args.os_auth_url:
509                 raise exc.CommandError(
510                     _("You must provide an auth url via"
511                       " either --os-auth-url or "
512                       "via env[OS_AUTH_URL]"))
513
514             kwargs = {
515                 'auth_url': args.os_auth_url,
516                 'username': args.os_username,
517                 'user_id': args.os_user_id,
518                 'user_domain_id': args.os_user_domain_id,
519                 'user_domain_name': args.os_user_domain_name,
520                 'password': args.os_password,
521                 'tenant_name': args.os_tenant_name,
522                 'tenant_id': args.os_tenant_id,
523                 'project_name': args.os_project_name,
524                 'project_id': args.os_project_id,
525                 'project_domain_name': args.os_project_domain_name,
526                 'project_domain_id': args.os_project_domain_id,
527                 'insecure': args.insecure,
528                 'cacert': args.os_cacert,
529                 'cert': args.os_cert,
530                 'key': args.os_key
531             }
532             ks_session = self._get_keystone_session(**kwargs)
533             token = args.os_auth_token or ks_session.get_token()
534
535             endpoint_type = args.os_endpoint_type or 'public'
536             service_type = args.os_service_type or 'image'
537             endpoint = args.os_image_url or ks_session.get_endpoint(
538                 service_type=service_type,
539                 interface=endpoint_type,
540                 region_name=args.os_region_name)
541
542         return endpoint, token
543
544     def _get_versioned_client(self, api_version, args, force_auth=False):
545         # ndpoint, token = self._get_endpoint_and_token(
546         # args,force_auth=force_auth)
547         # endpoint = "http://10.43.175.62:19292"
548         endpoint = args.os_endpoint
549         # print endpoint
550         kwargs = {
551             # 'token': token,
552             'insecure': args.insecure,
553             'timeout': args.timeout,
554             'cacert': args.os_cacert,
555             'cert': args.os_cert,
556             'key': args.os_key,
557             'ssl_compression': args.ssl_compression
558         }
559         client = escalatorclient.Client(api_version, endpoint, **kwargs)
560         return client
561
562     def _cache_schemas(self, options, home_dir='~/.escalatorclient'):
563         homedir = expanduser(home_dir)
564         if not os.path.exists(homedir):
565             os.makedirs(homedir)
566
567         resources = ['image', 'metadefs/namespace', 'metadefs/resource_type']
568         schema_file_paths = [homedir + os.sep + x + '_schema.json'
569                              for x in ['image', 'namespace', 'resource_type']]
570
571         client = None
572         for resource, schema_file_path in zip(resources, schema_file_paths):
573             if (not os.path.exists(schema_file_path)) or options.get_schema:
574                 try:
575                     if not client:
576                         client = self._get_versioned_client('2', options,
577                                                             force_auth=True)
578                     schema = client.schemas.get(resource)
579
580                     with open(schema_file_path, 'w') as f:
581                         f.write(json.dumps(schema.raw()))
582                 except Exception:
583                     # NOTE(esheffield) do nothing here, we'll get a message
584                     # later if the schema is missing
585                     pass
586
587     def main(self, argv):
588         # Parse args once to find version
589
590         # NOTE(flepied) Under Python3, parsed arguments are removed
591         # from the list so make a copy for the first parsing
592         base_argv = copy.deepcopy(argv)
593         parser = self.get_base_parser()
594         (options, args) = parser.parse_known_args(base_argv)
595
596         try:
597             # NOTE(flaper87): Try to get the version from the
598             # image-url first. If no version was specified, fallback
599             # to the api-image-version arg. If both of these fail then
600             # fallback to the minimum supported one and let keystone
601             # do the magic.
602             endpoint = self._get_image_url(options)
603             endpoint, url_version = utils.strip_version(endpoint)
604         except ValueError:
605             # NOTE(flaper87): ValueError is raised if no endpoint is povided
606             url_version = None
607
608         # build available subcommands based on version
609         try:
610             api_version = int(options.os_image_api_version or url_version or 1)
611         except ValueError:
612             print("Invalid API version parameter")
613             utils.exit()
614
615         if api_version == 2:
616             self._cache_schemas(options)
617
618         subcommand_parser = self.get_subcommand_parser(api_version)
619         self.parser = subcommand_parser
620
621         # Handle top-level --help/-h before attempting to parse
622         # a command off the command line
623         if options.help or not argv:
624             self.do_help(options)
625             return 0
626
627         # Parse args again and call whatever callback was selected
628         args = subcommand_parser.parse_args(argv)
629
630         # Short-circuit and deal with help command right away.
631         if args.func == self.do_help:
632             self.do_help(args)
633             return 0
634         elif args.func == self.do_bash_completion:
635             self.do_bash_completion(args)
636             return 0
637
638         LOG = logging.getLogger('escalatorclient')
639         LOG.addHandler(logging.StreamHandler())
640         LOG.setLevel(logging.DEBUG if args.debug else logging.INFO)
641
642         profile = osprofiler_profiler and options.profile
643         if profile:
644             osprofiler_profiler.init(options.profile)
645
646         client = self._get_versioned_client(api_version, args,
647                                             force_auth=False)
648
649         try:
650             args.func(client, args)
651         except exc.Unauthorized:
652             raise exc.CommandError("Invalid OpenStack Identity credentials.")
653         except Exception:
654             # NOTE(kragniz) Print any exceptions raised to stderr if the
655             # --debug flag is set
656             if args.debug:
657                 traceback.print_exc()
658             raise
659         finally:
660             if profile:
661                 trace_id = osprofiler_profiler.get().get_base_id()
662                 print("Profiling trace ID: %s" % trace_id)
663                 print("To display trace use next command:\n"
664                       "osprofiler trace show --html %s " % trace_id)
665
666     @utils.arg('command', metavar='<subcommand>', nargs='?',
667                help='Display help for <subcommand>.')
668     def do_help(self, args):
669         """
670         Display help about this program or one of its subcommands.
671         """
672         if getattr(args, 'command', None):
673             if args.command in self.subcommands:
674                 self.subcommands[args.command].print_help()
675             else:
676                 raise exc.CommandError("'%s' is not a valid subcommand" %
677                                        args.command)
678         else:
679             self.parser.print_help()
680
681     def do_bash_completion(self, _args):
682         """Prints arguments for bash_completion.
683
684         Prints all of the commands and options to stdout so that the
685         escalator.bash_completion script doesn't have to hard code them.
686         """
687         commands = set()
688         options = set()
689         for sc_str, sc in self.subcommands.items():
690             commands.add(sc_str)
691             for option in sc._optionals._option_string_actions.keys():
692                 options.add(option)
693
694         commands.remove('bash_completion')
695         commands.remove('bash-completion')
696         print(' '.join(commands | options))
697
698
699 class HelpFormatter(argparse.HelpFormatter):
700
701     def start_section(self, heading):
702         # Title-case the headings
703         heading = '%s%s' % (heading[0].upper(), heading[1:])
704         super(HelpFormatter, self).start_section(heading)
705
706
707 def main():
708     try:
709         escalatorShell().main(map(encodeutils.safe_decode, sys.argv[1:]))
710     except KeyboardInterrupt:
711         utils.exit('... terminating escalator client', exit_code=130)
712     except Exception as e:
713         utils.exit(utils.exception_to_str(e))