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