1from __future__ import print_function, unicode_literals
2
3import argparse
4import inspect
5import logging
6import platform
7import os
8import sys
9
10import colorama
11import pkg_resources
12import six
13from six.moves.urllib.parse import urlparse
14
15from rbtools import get_version_string
16from rbtools.api.capabilities import Capabilities
17from rbtools.api.client import RBClient
18from rbtools.api.errors import APIError, ServerInterfaceError
19from rbtools.api.transport.sync import SyncTransport
20from rbtools.clients import scan_usable_client
21from rbtools.clients.errors import OptionsCheckError
22from rbtools.utils.console import get_input, get_pass
23from rbtools.utils.filesystem import (cleanup_tempfiles, get_home_path,
24                                      is_exe_in_path, load_config)
25from rbtools.utils.process import log_command_line
26
27
28RB_MAIN = 'rbt'
29
30
31class CommandExit(Exception):
32    def __init__(self, exit_code=0):
33        super(CommandExit, self).__init__('Exit with code %s' % exit_code)
34        self.exit_code = exit_code
35
36
37class CommandError(Exception):
38    pass
39
40
41class ParseError(CommandError):
42    pass
43
44
45class SmartHelpFormatter(argparse.HelpFormatter):
46    """Smartly formats help text, preserving paragraphs."""
47
48    def _split_lines(self, text, width):
49        # NOTE: This function depends on overriding _split_lines's behavior.
50        #       It is clearly documented that this function should not be
51        #       considered public API. However, given that the width we need
52        #       is calculated by HelpFormatter, and HelpFormatter has no
53        #       blessed public API, we have no other choice but to override
54        #       it here.
55        lines = []
56
57        for line in text.splitlines():
58            lines += super(SmartHelpFormatter, self)._split_lines(line, width)
59            lines.append('')
60
61        return lines[:-1]
62
63
64class Option(object):
65    """Represents an option for a command.
66
67    The arguments to the constructor should be treated like those
68    to argparse's add_argument, with the exception that the keyword
69    argument 'config_key' is also valid. If config_key is provided
70    it will be used to retrieve the config value as a default if the
71    option is not specified. This will take precedence over the
72    default argument.
73
74    Serves as a wrapper around the ArgumentParser options, allowing us
75    to specify defaults which will be grabbed from the configuration
76    after it is loaded.
77    """
78
79    def __init__(self, *opts, **attrs):
80        self.opts = opts
81        self.attrs = attrs
82
83    def add_to(self, parent, config={}, argv=[]):
84        """Adds the option to the parent parser or group.
85
86        If the option maps to a configuration key, this will handle figuring
87        out the correct default.
88
89        Once we've determined the right set of flags, the option will be
90        added to the parser.
91        """
92        attrs = self.attrs.copy()
93
94        if 'config_key' in attrs:
95            config_key = attrs.pop('config_key')
96
97            if config_key in config:
98                attrs['default'] = config[config_key]
99
100        if 'deprecated_in' in attrs:
101            attrs['help'] += '\n[Deprecated since %s]' % attrs['deprecated_in']
102
103        # These are used for other purposes, and are not supported by
104        # argparse.
105        for attr in ('added_in', 'deprecated_in', 'extended_help',
106                     'versions_changed'):
107            attrs.pop(attr, None)
108
109        parent.add_argument(*self.opts, **attrs)
110
111
112class OptionGroup(object):
113    """Represents a named group of options.
114
115    Each group has a name, an optional description, and a list of options.
116    It serves as a way to organize related options, making it easier for
117    users to scan for the options they want.
118
119    This works like argparse's argument groups, but is designed to work with
120    our special Option class.
121    """
122
123    def __init__(self, name=None, description=None, option_list=[]):
124        self.name = name
125        self.description = description
126        self.option_list = option_list
127
128    def add_to(self, parser, config={}, argv=[]):
129        """Adds the group and all its contained options to the parser."""
130        group = parser.add_argument_group(self.name, self.description)
131
132        for option in self.option_list:
133            option.add_to(group, config, argv)
134
135
136class LogLevelFilter(logging.Filter):
137    """Filters log messages of a given level.
138
139    Only log messages that have the specified level will be allowed by
140    this filter. This prevents propagation of higher level types to lower
141    log handlers.
142    """
143
144    def __init__(self, level):
145        self.level = level
146
147    def filter(self, record):
148        return record.levelno == self.level
149
150
151class Command(object):
152    """Base class for rb commands.
153
154    This class will handle retrieving the configuration, and parsing
155    command line options.
156
157    ``description`` is a string containing a short description of the
158    command which is suitable for display in usage text.
159
160    ``usage`` is a list of usage strings each showing a use case. These
161    should not include the main rbt command or the command name; they
162    will be added automatically.
163
164    ``args`` is a string containing the usage text for what arguments the
165    command takes.
166
167    ``option_list`` is a list of command line options for the command.
168    Each list entry should be an Option or OptionGroup instance.
169    """
170
171    name = ''
172    author = ''
173    description = ''
174    args = ''
175    option_list = []
176    _global_options = [
177        Option('-d', '--debug',
178               action='store_true',
179               dest='debug',
180               config_key='DEBUG',
181               default=False,
182               help='Displays debug output.',
183               extended_help='This information can be valuable when debugging '
184                             'problems running the command.'),
185    ]
186
187    server_options = OptionGroup(
188        name='Review Board Server Options',
189        description='Options necessary to communicate and authenticate '
190                    'with a Review Board server.',
191        option_list=[
192            Option('--server',
193                   dest='server',
194                   metavar='URL',
195                   config_key='REVIEWBOARD_URL',
196                   default=None,
197                   help='Specifies the Review Board server to use.'),
198            Option('--username',
199                   dest='username',
200                   metavar='USERNAME',
201                   config_key='USERNAME',
202                   default=None,
203                   help='The user name to be supplied to the Review Board '
204                        'server.'),
205            Option('--password',
206                   dest='password',
207                   metavar='PASSWORD',
208                   config_key='PASSWORD',
209                   default=None,
210                   help='The password to be supplied to the Review Board '
211                        'server.'),
212            Option('--ext-auth-cookies',
213                   dest='ext_auth_cookies',
214                   metavar='EXT_AUTH_COOKIES',
215                   config_key='EXT_AUTH_COOKIES',
216                   default=None,
217                   help='Use an external cookie store with pre-fetched '
218                        'authentication data. This is useful with servers '
219                        'that require extra web authentication to access '
220                        'Review Board, e.g. on single sign-on enabled sites.',
221                   added_in='0.7.5'),
222            Option('--api-token',
223                   dest='api_token',
224                   metavar='TOKEN',
225                   config_key='API_TOKEN',
226                   default=None,
227                   help='The API token to use for authentication, instead of '
228                        'using a username and password.',
229                   added_in='0.7'),
230            Option('--disable-proxy',
231                   action='store_false',
232                   dest='enable_proxy',
233                   config_key='ENABLE_PROXY',
234                   default=True,
235                   help='Prevents requests from going through a proxy '
236                        'server.'),
237            Option('--disable-ssl-verification',
238                   action='store_true',
239                   dest='disable_ssl_verification',
240                   config_key='DISABLE_SSL_VERIFICATION',
241                   default=False,
242                   help='Disable SSL certificate verification. This is useful '
243                        'with servers that have self-signed certificates.',
244                   added_in='0.7.3'),
245            Option('--disable-cookie-storage',
246                   config_key='SAVE_COOKIES',
247                   dest='save_cookies',
248                   action='store_false',
249                   default=True,
250                   help='Use an in-memory cookie store instead of writing '
251                        'them to a file. No credentials will be saved or '
252                        'loaded.',
253                   added_in='0.7.3'),
254            Option('--disable-cache',
255                   dest='disable_cache',
256                   config_key='DISABLE_CACHE',
257                   action='store_true',
258                   default=False,
259                   help='Disable the HTTP cache completely. This will '
260                        'result in slower requests.',
261                   added_in='0.7.3'),
262            Option('--disable-cache-storage',
263                   dest='in_memory_cache',
264                   config_key='IN_MEMORY_CACHE',
265                   action='store_true',
266                   default=False,
267                   help='Disable storing the API cache on the filesystem, '
268                        'instead keeping it in memory temporarily.',
269                   added_in='0.7.3'),
270            Option('--cache-location',
271                   dest='cache_location',
272                   metavar='FILE',
273                   config_key='CACHE_LOCATION',
274                   default=None,
275                   help='The file to use for the API cache database.',
276                   added_in='0.7.3'),
277            Option('--ca-certs',
278                   dest='ca_certs',
279                   metavar='FILE',
280                   config_key='CA_CERTS',
281                   default=None,
282                   help='Additional TLS CA bundle.'),
283            Option('--client-key',
284                   dest='client_key',
285                   metavar='FILE',
286                   config_key='CLIENT_KEY',
287                   default=None,
288                   help='Key for TLS client authentication.'),
289            Option('--client-cert',
290                   dest='client_cert',
291                   metavar='FILE',
292                   config_key='CLIENT_CERT',
293                   default=None,
294                   help='Certificate for TLS client authentication.'),
295        ]
296    )
297
298    repository_options = OptionGroup(
299        name='Repository Options',
300        option_list=[
301            Option('--repository',
302                   dest='repository_name',
303                   metavar='NAME',
304                   config_key='REPOSITORY',
305                   default=None,
306                   help='The name of the repository configured on '
307                        'Review Board that matches the local repository.'),
308            Option('--repository-url',
309                   dest='repository_url',
310                   metavar='URL',
311                   config_key='REPOSITORY_URL',
312                   default=None,
313                   help='The URL for a repository.'
314                        '\n'
315                        'When generating diffs, this can be used for '
316                        'creating a diff outside of a working copy '
317                        '(currently only supported by Subversion with '
318                        'specific revisions or --diff-filename, and by '
319                        'ClearCase with relative paths outside the view).'
320                        '\n'
321                        'For Git, this specifies the origin URL of the '
322                        'current repository, overriding the origin URL '
323                        'supplied by the client.',
324                   versions_changed={
325                       '0.6': 'Prior versions used the `REPOSITORY` setting '
326                              'in .reviewboardrc, and allowed a '
327                              'repository name to be passed to '
328                              '--repository-url. This is no '
329                              'longer supported in 0.6 and higher. You '
330                              'may need to update your configuration and '
331                              'scripts appropriately.',
332                   }),
333            Option('--repository-type',
334                   dest='repository_type',
335                   metavar='TYPE',
336                   config_key='REPOSITORY_TYPE',
337                   default=None,
338                   help='The type of repository in the current directory. '
339                        'In most cases this should be detected '
340                        'automatically, but some directory structures '
341                        'containing multiple repositories require this '
342                        'option to select the proper type. The '
343                        '`rbt list-repo-types` command can be used to '
344                        'list the supported values.'),
345        ]
346    )
347
348    diff_options = OptionGroup(
349        name='Diff Generation Options',
350        description='Options for choosing what gets included in a diff, '
351                    'and how the diff is generated.',
352        option_list=[
353            Option('--no-renames',
354                   dest='no_renames',
355                   action='store_true',
356                   help='Add the --no-renames option to the git when '
357                        'generating diff.'
358                        '\n'
359                        'Supported by: Git',
360                   added_in='0.7.11'),
361            Option('--revision-range',
362                   dest='revision_range',
363                   metavar='REV1:REV2',
364                   default=None,
365                   help='Generates a diff for the given revision range.',
366                   deprecated_in='0.6'),
367            Option('-I', '--include',
368                   metavar='FILENAME',
369                   dest='include_files',
370                   action='append',
371                   help='Includes only the specified file in the diff. '
372                        'This can be used multiple times to specify '
373                        'multiple files.'
374                        '\n'
375                        'Supported by: Bazaar, CVS, Git, Mercurial, '
376                        'Perforce, and Subversion.',
377                   added_in='0.6'),
378            Option('-X', '--exclude',
379                   metavar='PATTERN',
380                   dest='exclude_patterns',
381                   action='append',
382                   config_key='EXCLUDE_PATTERNS',
383                   help='Excludes all files that match the given pattern '
384                        'from the diff. This can be used multiple times to '
385                        'specify multiple patterns. UNIX glob syntax is used '
386                        'for pattern matching.'
387                        '\n'
388                        'Supported by: Bazaar, CVS, Git, Mercurial, '
389                        'Perforce, and Subversion.',
390                   extended_help=(
391                       'Patterns that begin with a path separator (/ on Mac '
392                       'OS and Linux, \\ on Windows) will be treated as being '
393                       'relative to the root of the repository. All other '
394                       'patterns are treated as being relative to the current '
395                       'working directory.'
396                       '\n'
397                       'For example, to exclude all ".txt" files from the '
398                       'resulting diff, you would use "-X /\'*.txt\'".'
399                       '\n'
400                       'When working with Mercurial, the patterns are '
401                       'provided directly to "hg" and are not limited to '
402                       'globs. For more information on advanced pattern '
403                       'syntax in Mercurial, run "hg help patterns"'
404                       '\n'
405                       'When working with CVS all diffs are generated '
406                       'relative to the current working directory so '
407                       'patterns beginning with a path separator are treated '
408                       'as relative to the current working directory.'
409                       '\n'
410                       'When working with Perforce, an exclude pattern '
411                       'beginning with `//` will be matched against depot '
412                       'paths; all other patterns will be matched against '
413                       'local paths.'),
414                   added_in='0.7'),
415            Option('--parent',
416                   dest='parent_branch',
417                   metavar='BRANCH',
418                   config_key='PARENT_BRANCH',
419                   default=None,
420                   help='The parent branch this diff should be generated '
421                        'against (Bazaar/Git/Mercurial only).'),
422            Option('--diff-filename',
423                   dest='diff_filename',
424                   default=None,
425                   metavar='FILENAME',
426                   help='Uploads an existing diff file, instead of '
427                        'generating a new diff.'),
428        ]
429    )
430
431    branch_options = OptionGroup(
432        name='Branch Options',
433        description='Options for selecting branches.',
434        option_list=[
435            Option('--tracking-branch',
436                   dest='tracking',
437                   metavar='BRANCH',
438                   config_key='TRACKING_BRANCH',
439                   default=None,
440                   help='The remote tracking branch from which your local '
441                        'branch is derived (Git/Mercurial only).'
442                        '\n'
443                        'For Git, the default is to use the remote branch '
444                        'that the local branch is tracking, if any, falling '
445                        'back on `origin/master`.'
446                        '\n'
447                        'For Mercurial, the default is one of: '
448                        '`reviewboard`, `origin`, `parent`, or `default`.'),
449        ]
450    )
451
452    git_options = OptionGroup(
453        name='Git Options',
454        description='Git-specific options for diff generation.',
455        option_list=[
456            Option('--git-find-renames-threshold',
457                   dest='git_find_renames_threshold',
458                   metavar='THRESHOLD',
459                   default=None,
460                   help='The threshold to pass to `--find-renames` when '
461                        'generating a git diff.'
462                        '\n'
463                        'For more information, see `git help diff`.'),
464        ])
465
466    perforce_options = OptionGroup(
467        name='Perforce Options',
468        description='Perforce-specific options for selecting the '
469                    'Perforce client and communicating with the '
470                    'repository.',
471        option_list=[
472            Option('--p4-client',
473                   dest='p4_client',
474                   config_key='P4_CLIENT',
475                   default=None,
476                   metavar='CLIENT_NAME',
477                   help='The Perforce client name for the repository.'),
478            Option('--p4-port',
479                   dest='p4_port',
480                   config_key='P4_PORT',
481                   default=None,
482                   metavar='PORT',
483                   help='The IP address for the Perforce server.'),
484            Option('--p4-passwd',
485                   dest='p4_passwd',
486                   config_key='P4_PASSWD',
487                   default=None,
488                   metavar='PASSWORD',
489                   help='The Perforce password or ticket of the user '
490                        'in the P4USER environment variable.'),
491        ]
492    )
493
494    subversion_options = OptionGroup(
495        name='Subversion Options',
496        description='Subversion-specific options for controlling diff '
497                    'generation.',
498        option_list=[
499            Option('--basedir',
500                   dest='basedir',
501                   config_key='BASEDIR',
502                   default=None,
503                   metavar='PATH',
504                   help='The path within the repository where the diff '
505                        'was generated. This overrides the detected path. '
506                        'Often used when passing --diff-filename.'),
507            Option('--svn-username',
508                   dest='svn_username',
509                   default=None,
510                   metavar='USERNAME',
511                   help='The username for the SVN repository.'),
512            Option('--svn-password',
513                   dest='svn_password',
514                   default=None,
515                   metavar='PASSWORD',
516                   help='The password for the SVN repository.'),
517            Option('--svn-prompt-password',
518                   dest='svn_prompt_password',
519                   config_key='SVN_PROMPT_PASSWORD',
520                   default=False,
521                   action='store_true',
522                   help="Prompt for the user's svn password. This option "
523                        "overrides the password provided by the "
524                        "--svn-password option.",
525                   added_in='0.7.3'),
526            Option('--svn-show-copies-as-adds',
527                   dest='svn_show_copies_as_adds',
528                   metavar='y|n',
529                   default=None,
530                   help='Treat copied or moved files as new files.'
531                        '\n'
532                        'This is only supported in Subversion 1.7+.',
533                   added_in='0.5.2'),
534            Option('--svn-changelist',
535                   dest='svn_changelist',
536                   default=None,
537                   metavar='ID',
538                   help='Generates the diff for review based on a '
539                        'local changelist.',
540                   deprecated_in='0.6'),
541        ]
542    )
543
544    tfs_options = OptionGroup(
545        name='TFS Options',
546        description='Team Foundation Server specific options for '
547                    'communicating with the TFS server.',
548        option_list=[
549            Option('--tfs-login',
550                   dest='tfs_login',
551                   default=None,
552                   metavar='TFS_LOGIN',
553                   help='Logs in to TFS as a specific user (ie.'
554                        'user@domain,password). Visit https://msdn.microsoft.'
555                        'com/en-us/library/hh190725.aspx to learn about '
556                        'saving credentials for reuse.'),
557            Option('--tf-cmd',
558                   dest='tf_cmd',
559                   default=None,
560                   metavar='TF_CMD',
561                   config_key='TF_CMD',
562                   help='The full path of where to find the tf command. This '
563                        'overrides any detected path.'),
564            Option('--tfs-shelveset-owner',
565                   dest='tfs_shelveset_owner',
566                   default=None,
567                   metavar='TFS_SHELVESET_OWNER',
568                   help='When posting a shelveset name created by another '
569                        'user (other than the one who owns the current '
570                        'workdir), look for that shelveset using this '
571                        'username.'),
572        ]
573    )
574
575    default_transport_cls = SyncTransport
576
577    def __init__(self, transport_cls=SyncTransport):
578        """Initialize the base functionality for the command.
579
580        Args:
581            transport_cls (rbtools.api.transport.Transport, optional):
582                The transport class used for all API communication. By default,
583                this uses the transport defined in
584                :py:attr:`default_transport_cls`.
585        """
586        self.log = logging.getLogger('rb.%s' % self.name)
587        self.transport_cls = transport_cls or self.default_transport_cls
588
589    def create_parser(self, config, argv=[]):
590        """Create and return the argument parser for this command."""
591        parser = argparse.ArgumentParser(
592            prog=RB_MAIN,
593            usage=self.usage(),
594            add_help=False,
595            formatter_class=SmartHelpFormatter)
596
597        for option in self.option_list:
598            option.add_to(parser, config, argv)
599
600        for option in self._global_options:
601            option.add_to(parser, config, argv)
602
603        return parser
604
605    def post_process_options(self):
606        if self.options.disable_ssl_verification:
607            try:
608                import ssl
609                ssl._create_unverified_context()
610            except Exception:
611                raise CommandError('The --disable-ssl-verification flag is '
612                                   'only available with Python 2.7.9+')
613
614    def usage(self):
615        """Return a usage string for the command."""
616        usage = '%%(prog)s %s [options] %s' % (self.name, self.args)
617
618        if self.description:
619            return '%s\n\n%s' % (usage, self.description)
620        else:
621            return usage
622
623    def _create_formatter(self, level, fmt):
624        """Create a logging formatter for the appropriate logging level.
625
626        When writing to a TTY, the format will be colorized by the colors
627        specified in the ``COLORS`` configuration in :file:`.reviewboardrc`.
628        Otherwise, the format will not be altered.
629
630        Args:
631            level (unicode):
632                The logging level name.
633
634            fmt (unicode):
635                The logging format.
636
637        Returns:
638            logging.Formatter:
639            The created formatter.
640        """
641        color = ''
642        reset = ''
643
644        if sys.stdout.isatty():
645            color_name = self.config['COLOR'].get(level.upper())
646
647            if color_name:
648                color = getattr(colorama.Fore, color_name.upper(), '')
649
650                if color:
651                    reset = colorama.Fore.RESET
652
653        return logging.Formatter(fmt.format(color=color, reset=reset))
654
655    def init_logging(self):
656        """Initializes logging for the command.
657
658        This will set up different log handlers based on the formatting we want
659        for the given levels.
660
661        The INFO log handler will just show the text, like a print statement.
662
663        WARNING and higher will show the level name as a prefix, in the form of
664        "LEVEL: message".
665
666        If debugging is enabled, a debug log handler will be set up showing
667        debug messages in the form of ">>> message", making it easier to
668        distinguish between debugging and other messages.
669        """
670        if sys.stdout.isatty():
671            # We only use colorized logging when writing to TTYs, so we don't
672            # bother initializing it then.
673            colorama.init()
674
675        root = logging.getLogger()
676
677        if self.options.debug:
678            handler = logging.StreamHandler()
679            handler.setFormatter(self._create_formatter(
680                'DEBUG', '{color}>>>{reset} %(message)s'))
681            handler.setLevel(logging.DEBUG)
682            handler.addFilter(LogLevelFilter(logging.DEBUG))
683            root.addHandler(handler)
684
685            root.setLevel(logging.DEBUG)
686        else:
687            root.setLevel(logging.INFO)
688
689        # Handler for info messages. We'll treat these like prints.
690        handler = logging.StreamHandler()
691        handler.setFormatter(self._create_formatter(
692            'INFO', '{color}%(message)s{reset}'))
693
694        handler.setLevel(logging.INFO)
695        handler.addFilter(LogLevelFilter(logging.INFO))
696        root.addHandler(handler)
697
698        # Handlers for warnings, errors, and criticals. They'll show the
699        # level prefix and the message.
700        levels = (
701            ('WARNING', logging.WARNING),
702            ('ERROR', logging.ERROR),
703            ('CRITICAL', logging.CRITICAL),
704        )
705
706        for level_name, level in levels:
707            handler = logging.StreamHandler()
708            handler.setFormatter(self._create_formatter(
709                level_name, '{color}%(levelname)s:{reset} %(message)s'))
710            handler.addFilter(LogLevelFilter(level))
711            handler.setLevel(level)
712            root.addHandler(handler)
713
714        logging.debug('RBTools %s', get_version_string())
715        logging.debug('Python %s', sys.version)
716        logging.debug('Running on %s', platform.platform())
717        logging.debug('Home = %s', get_home_path())
718        logging.debug('Current directory = %s', os.getcwd())
719
720    def create_arg_parser(self, argv):
721        """Create and return the argument parser.
722
723        Args:
724            argv (list of unicode):
725                A list of command line arguments
726
727        Returns:
728            argparse.ArgumentParser:
729            Argument parser for commandline arguments
730        """
731        self.config = load_config()
732        parser = self.create_parser(self.config, argv)
733        parser.add_argument('args', nargs=argparse.REMAINDER)
734
735        return parser
736
737    def run_from_argv(self, argv):
738        """Execute the command using the provided arguments.
739
740        The options and commandline arguments will be parsed
741        from ``argv`` and the commands ``main`` method will
742        be called.
743        """
744        parser = self.create_arg_parser(argv)
745        self.options = parser.parse_args(argv[2:])
746
747        args = self.options.args
748
749        # Check that the proper number of arguments have been provided.
750        argspec = inspect.getargspec(self.main)
751        minargs = len(argspec[0]) - 1
752        maxargs = minargs
753
754        # Arguments that have a default value are considered optional.
755        if argspec[3] is not None:
756            minargs -= len(argspec[3])
757
758        if argspec[1] is not None:
759            maxargs = None
760
761        if len(args) < minargs or (maxargs is not None and
762                                   len(args) > maxargs):
763            parser.error('Invalid number of arguments provided')
764            sys.exit(1)
765
766        self.init_logging()
767        log_command_line('Command line: %s', argv)
768
769        try:
770            exit_code = self.main(*args) or 0
771        except CommandError as e:
772            if isinstance(e, ParseError):
773                parser.error(e)
774            elif self.options.debug:
775                raise
776
777            logging.error(e)
778            exit_code = 1
779        except CommandExit as e:
780            exit_code = e.exit_code
781        except Exception as e:
782            # If debugging is on, we'll let python spit out the
783            # stack trace and report the exception, otherwise
784            # we'll suppress the trace and print the exception
785            # manually.
786            if self.options.debug:
787                raise
788
789            logging.critical(e)
790            exit_code = 1
791
792        cleanup_tempfiles()
793        sys.exit(exit_code)
794
795    def initialize_scm_tool(self, client_name=None,
796                            require_repository_info=True):
797        """Initialize the SCM tool for the current working directory.
798
799        Args:
800            client_name (unicode, optional):
801                A specific client name, which can come from the configuration.
802                This can be used to disambiguate if there are nested
803                repositories, or to speed up detection.
804
805            require_repository_info (bool, optional):
806                Whether information on a repository is required. This is the
807                default. If disabled, this will return ``None`` for the
808                repository information if a matching repository could not be
809                found.
810
811        Returns:
812            tuple:
813            A 2-tuple, containing the repository info structure and the tool
814            instance.
815        """
816        repository_info, tool = scan_usable_client(
817            self.config,
818            self.options,
819            client_name=client_name,
820            require_repository_info=require_repository_info)
821
822        try:
823            tool.check_options()
824        except OptionsCheckError as e:
825            raise CommandError(six.text_type(e))
826
827        return repository_info, tool
828
829    def setup_tool(self, tool, api_root=None):
830        """Performs extra initialization on the tool.
831
832        If api_root is not provided we'll assume we want to
833        initialize the tool using only local information
834        """
835        tool.capabilities = self.get_capabilities(api_root)
836
837    def get_server_url(self, repository_info, tool):
838        """Return the Review Board server url.
839
840        Args:
841            repository_info (rbtools.clients.RepositoryInfo, optional):
842                Information about the current repository
843
844            tool (rbtools.clients.SCMClient, optional):
845                The repository client.
846
847        Returns:
848            unicode:
849            The server URL.
850        """
851        if self.options.server:
852            server_url = self.options.server
853        elif tool and repository_info is not None:
854            server_url = tool.scan_for_server(repository_info)
855        else:
856            server_url = None
857
858        if not server_url:
859            raise CommandError('Unable to find a Review Board server for this '
860                               'source code tree.')
861
862        return server_url
863
864    def credentials_prompt(self, realm, uri, username=None, password=None,
865                           *args, **kwargs):
866        """Prompt the user for credentials using the command line.
867
868        This will prompt the user, and then return the provided
869        username and password. This is used as a callback in the
870        API when the user requires authorization.
871        """
872        if username is None or password is None:
873            if getattr(self.options, 'diff_filename', None) == '-':
874                raise CommandError('HTTP authentication is required, but '
875                                   'cannot be used with --diff-filename=-')
876
877            # Interactive prompts don't work correctly when input doesn't come
878            # from a terminal. This could seem to be a rare case not worth
879            # worrying about, but this is what happens when using native
880            # Python in Cygwin terminal emulator under Windows and it's very
881            # puzzling to the users, especially because stderr is also _not_
882            # flushed automatically in this case, so the program just appears
883            # to hang.
884            if not sys.stdin.isatty():
885                logging.error('Authentication is required but input is not a '
886                              'tty.')
887                if sys.platform == 'win32':
888                    logging.info('Check that you are not running this script '
889                                 'from a Cygwin terminal emulator (or use '
890                                 'Cygwin Python to run it).')
891
892                raise CommandError('Unable to log in to Review Board.')
893
894            print()
895            print('Please log in to the Review Board server at %s.' %
896                  urlparse(uri)[1])
897
898            if username is None:
899                username = get_input('Username: ')
900
901            if password is None:
902                password = get_pass('Password: ')
903
904        return username, password
905
906    def otp_token_prompt(self, uri, token_method, *args, **kwargs):
907        """Prompt the user for a one-time password token.
908
909        Their account is configured with two-factor authentication. The
910        server will have sent a token to their configured mobile device
911        or application. The user will be prompted for this token.
912        """
913        if getattr(self.options, 'diff_filename', None) == '-':
914            raise CommandError('A two-factor authentication token is '
915                               'required, but cannot be used with '
916                               '--diff-filename=-')
917
918        print()
919        print('Please enter your two-factor authentication token for Review '
920              'Board.')
921
922        if token_method == 'sms':
923            print('You should be getting a text message with '
924                  'an authentication token.')
925            print('Enter the token below.')
926        elif token_method == 'call':
927            print('You should be getting an automated phone call with '
928                  'an authentication token.')
929            print('Enter the token below.')
930        elif token_method == 'generator':
931            print('Enter the token shown on your token generator app below.')
932
933        print()
934
935        return get_pass('Token: ', require=True)
936
937    def _make_api_client(self, server_url):
938        """Return an RBClient object for the server.
939
940        The RBClient will be instantiated with the proper arguments
941        for talking to the provided Review Board server url.
942        """
943        return RBClient(
944            server_url,
945            username=self.options.username,
946            password=self.options.password,
947            api_token=self.options.api_token,
948            auth_callback=self.credentials_prompt,
949            otp_token_callback=self.otp_token_prompt,
950            disable_proxy=not self.options.enable_proxy,
951            verify_ssl=not self.options.disable_ssl_verification,
952            allow_caching=not self.options.disable_cache,
953            cache_location=self.options.cache_location,
954            in_memory_cache=self.options.in_memory_cache,
955            save_cookies=self.options.save_cookies,
956            ext_auth_cookies=self.options.ext_auth_cookies,
957            ca_certs=self.options.ca_certs,
958            client_key=self.options.client_key,
959            client_cert=self.options.client_cert,
960            transport_cls=self.transport_cls)
961
962    def get_api(self, server_url):
963        """Returns an RBClient instance and the associated root resource.
964
965        Commands should use this method to gain access to the API,
966        instead of instantianting their own client.
967        """
968        if not urlparse(server_url).scheme:
969            server_url = '%s%s' % ('http://', server_url)
970
971        api_client = self._make_api_client(server_url)
972        api_root = None
973
974        try:
975            api_root = api_client.get_root()
976        except ServerInterfaceError as e:
977            raise CommandError('Could not reach the Review Board '
978                               'server at %s: %s' % (server_url, e))
979        except APIError as e:
980            if e.http_status != 404:
981                raise CommandError('Unexpected API Error: %s' % e)
982
983        # If we either couldn't find an API endpoint or its contents don't
984        # appear to be from Review Board, we should provide helpful
985        # instructions to the user.
986        if api_root is None or not hasattr(api_root, 'get_review_requests'):
987            if server_url.rstrip('/') == 'https://rbcommons.com':
988                raise CommandError(
989                    'RBTools must be configured to point to your RBCommons '
990                    'team account. For example: '
991                    'https://rbcommons.com/s/<myteam>/')
992            elif server_url.startswith('https://rbcommons.com/s/'):
993                raise CommandError(
994                    'Your configured RBCommons team account could not be '
995                    'found. Make sure the team name is correct and the team '
996                    'is still active.')
997            else:
998                raise CommandError(
999                    'The configured Review Board server URL (%s) does not '
1000                    'appear to be correct.'
1001                    % server_url)
1002
1003        return api_client, api_root
1004
1005    def get_capabilities(self, api_root):
1006        """Retrieve Capabilities from the server and return them."""
1007        if 'capabilities' in api_root:
1008            # Review Board 2.0+ provides capabilities in the root resource.
1009            return Capabilities(api_root.capabilities)
1010
1011        info = api_root.get_info()
1012
1013        if 'capabilities' in info:
1014            return Capabilities(info.capabilities)
1015        else:
1016            return Capabilities({})
1017
1018    def main(self, *args):
1019        """The main logic of the command.
1020
1021        This method should be overridden to implement the commands
1022        functionality.
1023        """
1024        raise NotImplementedError()
1025
1026
1027def find_entry_point_for_command(command_name):
1028    """Return an entry point for the given rbtools command.
1029
1030    If no entry point is found, None is returned.
1031    """
1032    # Attempt to retrieve the command class from the entry points. We
1033    # first look in rbtools for the commands, and failing that, we look
1034    # for third-party commands.
1035    entry_point = pkg_resources.get_entry_info('rbtools', 'rbtools_commands',
1036                                               command_name)
1037
1038    if not entry_point:
1039        try:
1040            entry_point = next(pkg_resources.iter_entry_points(
1041                'rbtools_commands', command_name))
1042        except StopIteration:
1043            # There aren't any custom entry points defined.
1044            pass
1045
1046    return entry_point
1047
1048
1049def command_exists(cmd_name):
1050    """Determine if the given command exists.
1051
1052    This function checks for the existence of an RBTools command entry point
1053    with the given name and an executable named rbt-"cmd_name" on the path.
1054    Aliases are not considered.
1055    """
1056    return (find_entry_point_for_command(cmd_name) or
1057            is_exe_in_path('rbt-%s' % cmd_name))
1058