1#!/usr/bin/env python 2# 3# Copyright 2007 Google Inc. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17"""Tool for uploading diffs from a version control system to the codereview app. 18 19Usage summary: upload.py [options] [-- diff_options] 20 21Diff options are passed to the diff command of the underlying system. 22 23Supported version control systems: 24 Git 25 Mercurial 26 Subversion 27 28It is important for Git/Mercurial users to specify a tree/node/branch to diff 29against by using the '--rev' option. 30""" 31# This code is derived from appcfg.py in the App Engine SDK (open source), 32# and from ASPN recipe #146306. 33 34import cookielib 35import getpass 36import logging 37import md5 38import mimetypes 39import optparse 40import os 41import re 42import socket 43import subprocess 44import sys 45import urllib 46import urllib2 47import urlparse 48 49try: 50 import readline 51except ImportError: 52 pass 53 54# The logging verbosity: 55# 0: Errors only. 56# 1: Status messages. 57# 2: Info logs. 58# 3: Debug logs. 59verbosity = 1 60 61# Max size of patch or base file. 62MAX_UPLOAD_SIZE = 900 * 1024 63 64 65def GetEmail(prompt): 66 """Prompts the user for their email address and returns it. 67 68 The last used email address is saved to a file and offered up as a suggestion 69 to the user. If the user presses enter without typing in anything the last 70 used email address is used. If the user enters a new address, it is saved 71 for next time we prompt. 72 73 """ 74 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address") 75 last_email = "" 76 if os.path.exists(last_email_file_name): 77 try: 78 last_email_file = open(last_email_file_name, "r") 79 last_email = last_email_file.readline().strip("\n") 80 last_email_file.close() 81 prompt += " [%s]" % last_email 82 except IOError, e: 83 pass 84 email = raw_input(prompt + ": ").strip() 85 if email: 86 try: 87 last_email_file = open(last_email_file_name, "w") 88 last_email_file.write(email) 89 last_email_file.close() 90 except IOError, e: 91 pass 92 else: 93 email = last_email 94 return email 95 96 97def StatusUpdate(msg): 98 """Print a status message to stdout. 99 100 If 'verbosity' is greater than 0, print the message. 101 102 Args: 103 msg: The string to print. 104 """ 105 if verbosity > 0: 106 print msg 107 108 109def ErrorExit(msg): 110 """Print an error message to stderr and exit.""" 111 print >>sys.stderr, msg 112 sys.exit(1) 113 114 115class ClientLoginError(urllib2.HTTPError): 116 """Raised to indicate there was an error authenticating with ClientLogin.""" 117 118 def __init__(self, url, code, msg, headers, args): 119 urllib2.HTTPError.__init__(self, url, code, msg, headers, None) 120 self.args = args 121 self.reason = args["Error"] 122 123 124class AbstractRpcServer(object): 125 """Provides a common interface for a simple RPC server.""" 126 127 def __init__(self, host, auth_function, host_override=None, extra_headers={}, 128 save_cookies=False): 129 """Creates a new HttpRpcServer. 130 131 Args: 132 host: The host to send requests to. 133 auth_function: A function that takes no arguments and returns an 134 (email, password) tuple when called. Will be called if authentication 135 is required. 136 host_override: The host header to send to the server (defaults to host). 137 extra_headers: A dict of extra headers to append to every request. 138 save_cookies: If True, save the authentication cookies to local disk. 139 If False, use an in-memory cookiejar instead. Subclasses must 140 implement this functionality. Defaults to False. 141 """ 142 self.host = host 143 self.host_override = host_override 144 self.auth_function = auth_function 145 self.authenticated = False 146 self.extra_headers = extra_headers 147 self.save_cookies = save_cookies 148 self.opener = self._GetOpener() 149 if self.host_override: 150 logging.info("Server: %s; Host: %s", self.host, self.host_override) 151 else: 152 logging.info("Server: %s", self.host) 153 154 def _GetOpener(self): 155 """Returns an OpenerDirector for making HTTP requests. 156 157 Returns: 158 A urllib2.OpenerDirector object. 159 """ 160 raise NotImplementedError() 161 162 def _CreateRequest(self, url, data=None): 163 """Creates a new urllib request.""" 164 logging.debug("Creating request for: '%s' with payload:\n%s", url, data) 165 req = urllib2.Request(url, data=data) 166 if self.host_override: 167 req.add_header("Host", self.host_override) 168 for key, value in self.extra_headers.iteritems(): 169 req.add_header(key, value) 170 return req 171 172 def _GetAuthToken(self, email, password): 173 """Uses ClientLogin to authenticate the user, returning an auth token. 174 175 Args: 176 email: The user's email address 177 password: The user's password 178 179 Raises: 180 ClientLoginError: If there was an error authenticating with ClientLogin. 181 HTTPError: If there was some other form of HTTP error. 182 183 Returns: 184 The authentication token returned by ClientLogin. 185 """ 186 account_type = "GOOGLE" 187 if self.host.endswith(".google.com"): 188 # Needed for use inside Google. 189 account_type = "HOSTED" 190 req = self._CreateRequest( 191 url="https://www.google.com/accounts/ClientLogin", 192 data=urllib.urlencode({ 193 "Email": email, 194 "Passwd": password, 195 "service": "ah", 196 "source": "rietveld-codereview-upload", 197 "accountType": account_type, 198 }), 199 ) 200 try: 201 response = self.opener.open(req) 202 response_body = response.read() 203 response_dict = dict(x.split("=") 204 for x in response_body.split("\n") if x) 205 return response_dict["Auth"] 206 except urllib2.HTTPError, e: 207 if e.code == 403: 208 body = e.read() 209 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x) 210 raise ClientLoginError(req.get_full_url(), e.code, e.msg, 211 e.headers, response_dict) 212 else: 213 raise 214 215 def _GetAuthCookie(self, auth_token): 216 """Fetches authentication cookies for an authentication token. 217 218 Args: 219 auth_token: The authentication token returned by ClientLogin. 220 221 Raises: 222 HTTPError: If there was an error fetching the authentication cookies. 223 """ 224 # This is a dummy value to allow us to identify when we're successful. 225 continue_location = "http://localhost/" 226 args = {"continue": continue_location, "auth": auth_token} 227 req = self._CreateRequest("http://%s/_ah/login?%s" % 228 (self.host, urllib.urlencode(args))) 229 try: 230 response = self.opener.open(req) 231 except urllib2.HTTPError, e: 232 response = e 233 if (response.code != 302 or 234 response.info()["location"] != continue_location): 235 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, 236 response.headers, response.fp) 237 self.authenticated = True 238 239 def _Authenticate(self): 240 """Authenticates the user. 241 242 The authentication process works as follows: 243 1) We get a username and password from the user 244 2) We use ClientLogin to obtain an AUTH token for the user 245 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html). 246 3) We pass the auth token to /_ah/login on the server to obtain an 247 authentication cookie. If login was successful, it tries to redirect 248 us to the URL we provided. 249 250 If we attempt to access the upload API without first obtaining an 251 authentication cookie, it returns a 401 response and directs us to 252 authenticate ourselves with ClientLogin. 253 """ 254 for i in range(3): 255 credentials = self.auth_function() 256 try: 257 auth_token = self._GetAuthToken(credentials[0], credentials[1]) 258 except ClientLoginError, e: 259 if e.reason == "BadAuthentication": 260 print >>sys.stderr, "Invalid username or password." 261 continue 262 if e.reason == "CaptchaRequired": 263 print >>sys.stderr, ( 264 "Please go to\n" 265 "https://www.google.com/accounts/DisplayUnlockCaptcha\n" 266 "and verify you are a human. Then try again.") 267 break 268 if e.reason == "NotVerified": 269 print >>sys.stderr, "Account not verified." 270 break 271 if e.reason == "TermsNotAgreed": 272 print >>sys.stderr, "User has not agreed to TOS." 273 break 274 if e.reason == "AccountDeleted": 275 print >>sys.stderr, "The user account has been deleted." 276 break 277 if e.reason == "AccountDisabled": 278 print >>sys.stderr, "The user account has been disabled." 279 break 280 if e.reason == "ServiceDisabled": 281 print >>sys.stderr, ("The user's access to the service has been " 282 "disabled.") 283 break 284 if e.reason == "ServiceUnavailable": 285 print >>sys.stderr, "The service is not available; try again later." 286 break 287 raise 288 self._GetAuthCookie(auth_token) 289 return 290 291 def Send(self, request_path, payload=None, 292 content_type="application/octet-stream", 293 timeout=None, 294 **kwargs): 295 """Sends an RPC and returns the response. 296 297 Args: 298 request_path: The path to send the request to, eg /api/appversion/create. 299 payload: The body of the request, or None to send an empty request. 300 content_type: The Content-Type header to use. 301 timeout: timeout in seconds; default None i.e. no timeout. 302 (Note: for large requests on OS X, the timeout doesn't work right.) 303 kwargs: Any keyword arguments are converted into query string parameters. 304 305 Returns: 306 The response body, as a string. 307 """ 308 # TODO: Don't require authentication. Let the server say 309 # whether it is necessary. 310 if not self.authenticated: 311 self._Authenticate() 312 313 old_timeout = socket.getdefaulttimeout() 314 socket.setdefaulttimeout(timeout) 315 try: 316 tries = 0 317 while True: 318 tries += 1 319 args = dict(kwargs) 320 url = "http://%s%s" % (self.host, request_path) 321 if args: 322 url += "?" + urllib.urlencode(args) 323 req = self._CreateRequest(url=url, data=payload) 324 req.add_header("Content-Type", content_type) 325 try: 326 f = self.opener.open(req) 327 response = f.read() 328 f.close() 329 return response 330 except urllib2.HTTPError, e: 331 if tries > 3: 332 raise 333 elif e.code == 401: 334 self._Authenticate() 335## elif e.code >= 500 and e.code < 600: 336## # Server Error - try again. 337## continue 338 else: 339 raise 340 finally: 341 socket.setdefaulttimeout(old_timeout) 342 343 344class HttpRpcServer(AbstractRpcServer): 345 """Provides a simplified RPC-style interface for HTTP requests.""" 346 347 def _Authenticate(self): 348 """Save the cookie jar after authentication.""" 349 super(HttpRpcServer, self)._Authenticate() 350 if self.save_cookies: 351 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file) 352 self.cookie_jar.save() 353 354 def _GetOpener(self): 355 """Returns an OpenerDirector that supports cookies and ignores redirects. 356 357 Returns: 358 A urllib2.OpenerDirector object. 359 """ 360 opener = urllib2.OpenerDirector() 361 opener.add_handler(urllib2.ProxyHandler()) 362 opener.add_handler(urllib2.UnknownHandler()) 363 opener.add_handler(urllib2.HTTPHandler()) 364 opener.add_handler(urllib2.HTTPDefaultErrorHandler()) 365 opener.add_handler(urllib2.HTTPSHandler()) 366 opener.add_handler(urllib2.HTTPErrorProcessor()) 367 if self.save_cookies: 368 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies") 369 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file) 370 if os.path.exists(self.cookie_file): 371 try: 372 self.cookie_jar.load() 373 self.authenticated = True 374 StatusUpdate("Loaded authentication cookies from %s" % 375 self.cookie_file) 376 except (cookielib.LoadError, IOError): 377 # Failed to load cookies - just ignore them. 378 pass 379 else: 380 # Create an empty cookie file with mode 600 381 fd = os.open(self.cookie_file, os.O_CREAT, 0600) 382 os.close(fd) 383 # Always chmod the cookie file 384 os.chmod(self.cookie_file, 0600) 385 else: 386 # Don't save cookies across runs of update.py. 387 self.cookie_jar = cookielib.CookieJar() 388 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar)) 389 return opener 390 391 392parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]") 393parser.add_option("-y", "--assume_yes", action="store_true", 394 dest="assume_yes", default=False, 395 help="Assume that the answer to yes/no questions is 'yes'.") 396# Logging 397group = parser.add_option_group("Logging options") 398group.add_option("-q", "--quiet", action="store_const", const=0, 399 dest="verbose", help="Print errors only.") 400group.add_option("-v", "--verbose", action="store_const", const=2, 401 dest="verbose", default=1, 402 help="Print info level logs (default).") 403group.add_option("--noisy", action="store_const", const=3, 404 dest="verbose", help="Print all logs.") 405# Review server 406group = parser.add_option_group("Review server options") 407group.add_option("-s", "--server", action="store", dest="server", 408 default="codereview.appspot.com", 409 metavar="SERVER", 410 help=("The server to upload to. The format is host[:port]. " 411 "Defaults to 'codereview.appspot.com'.")) 412group.add_option("-e", "--email", action="store", dest="email", 413 metavar="EMAIL", default=None, 414 help="The username to use. Will prompt if omitted.") 415group.add_option("-H", "--host", action="store", dest="host", 416 metavar="HOST", default=None, 417 help="Overrides the Host header sent with all RPCs.") 418group.add_option("--no_cookies", action="store_false", 419 dest="save_cookies", default=True, 420 help="Do not save authentication cookies to local disk.") 421# Issue 422group = parser.add_option_group("Issue options") 423group.add_option("-d", "--description", action="store", dest="description", 424 metavar="DESCRIPTION", default=None, 425 help="Optional description when creating an issue.") 426group.add_option("-f", "--description_file", action="store", 427 dest="description_file", metavar="DESCRIPTION_FILE", 428 default=None, 429 help="Optional path of a file that contains " 430 "the description when creating an issue.") 431group.add_option("-r", "--reviewers", action="store", dest="reviewers", 432 metavar="REVIEWERS", default=None, 433 help="Add reviewers (comma separated email addresses).") 434group.add_option("--cc", action="store", dest="cc", 435 metavar="CC", default=None, 436 help="Add CC (comma separated email addresses).") 437# Upload options 438group = parser.add_option_group("Patch options") 439group.add_option("-m", "--message", action="store", dest="message", 440 metavar="MESSAGE", default=None, 441 help="A message to identify the patch. " 442 "Will prompt if omitted.") 443group.add_option("-i", "--issue", type="int", action="store", 444 metavar="ISSUE", default=None, 445 help="Issue number to which to add. Defaults to new issue.") 446group.add_option("--download_base", action="store_true", 447 dest="download_base", default=False, 448 help="Base files will be downloaded by the server " 449 "(side-by-side diffs may not work on files with CRs).") 450group.add_option("--rev", action="store", dest="revision", 451 metavar="REV", default=None, 452 help="Branch/tree/revision to diff against (used by DVCS).") 453group.add_option("--send_mail", action="store_true", 454 dest="send_mail", default=False, 455 help="Send notification email to reviewers.") 456 457 458def GetRpcServer(options): 459 """Returns an instance of an AbstractRpcServer. 460 461 Returns: 462 A new AbstractRpcServer, on which RPC calls can be made. 463 """ 464 465 rpc_server_class = HttpRpcServer 466 467 def GetUserCredentials(): 468 """Prompts the user for a username and password.""" 469 email = options.email 470 if email is None: 471 email = GetEmail("Email (login for uploading to %s)" % options.server) 472 password = getpass.getpass("Password for %s: " % email) 473 return (email, password) 474 475 # If this is the dev_appserver, use fake authentication. 476 host = (options.host or options.server).lower() 477 if host == "localhost" or host.startswith("localhost:"): 478 email = options.email 479 if email is None: 480 email = "test@example.com" 481 logging.info("Using debug user %s. Override with --email" % email) 482 server = rpc_server_class( 483 options.server, 484 lambda: (email, "password"), 485 host_override=options.host, 486 extra_headers={"Cookie": 487 'dev_appserver_login="%s:False"' % email}, 488 save_cookies=options.save_cookies) 489 # Don't try to talk to ClientLogin. 490 server.authenticated = True 491 return server 492 493 return rpc_server_class(options.server, GetUserCredentials, 494 host_override=options.host, 495 save_cookies=options.save_cookies) 496 497 498def EncodeMultipartFormData(fields, files): 499 """Encode form fields for multipart/form-data. 500 501 Args: 502 fields: A sequence of (name, value) elements for regular form fields. 503 files: A sequence of (name, filename, value) elements for data to be 504 uploaded as files. 505 Returns: 506 (content_type, body) ready for httplib.HTTP instance. 507 508 Source: 509 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306 510 """ 511 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-' 512 CRLF = '\r\n' 513 lines = [] 514 for (key, value) in fields: 515 lines.append('--' + BOUNDARY) 516 lines.append('Content-Disposition: form-data; name="%s"' % key) 517 lines.append('') 518 lines.append(value) 519 for (key, filename, value) in files: 520 lines.append('--' + BOUNDARY) 521 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % 522 (key, filename)) 523 lines.append('Content-Type: %s' % GetContentType(filename)) 524 lines.append('') 525 lines.append(value) 526 lines.append('--' + BOUNDARY + '--') 527 lines.append('') 528 body = CRLF.join(lines) 529 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY 530 return content_type, body 531 532 533def GetContentType(filename): 534 """Helper to guess the content-type from the filename.""" 535 return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 536 537 538# Use a shell for subcommands on Windows to get a PATH search. 539use_shell = sys.platform.startswith("win") 540 541def RunShellWithReturnCode(command, print_output=False, 542 universal_newlines=True): 543 """Executes a command and returns the output from stdout and the return code. 544 545 Args: 546 command: Command to execute. 547 print_output: If True, the output is printed to stdout. 548 If False, both stdout and stderr are ignored. 549 universal_newlines: Use universal_newlines flag (default: True). 550 551 Returns: 552 Tuple (output, return code) 553 """ 554 logging.info("Running %s", command) 555 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 556 shell=use_shell, universal_newlines=universal_newlines) 557 if print_output: 558 output_array = [] 559 while True: 560 line = p.stdout.readline() 561 if not line: 562 break 563 print line.strip("\n") 564 output_array.append(line) 565 output = "".join(output_array) 566 else: 567 output = p.stdout.read() 568 p.wait() 569 errout = p.stderr.read() 570 if print_output and errout: 571 print >>sys.stderr, errout 572 p.stdout.close() 573 p.stderr.close() 574 return output, p.returncode 575 576 577def RunShell(command, silent_ok=False, universal_newlines=True, 578 print_output=False): 579 data, retcode = RunShellWithReturnCode(command, print_output, 580 universal_newlines) 581 if retcode: 582 ErrorExit("Got error status from %s:\n%s" % (command, data)) 583 if not silent_ok and not data: 584 ErrorExit("No output from %s" % command) 585 return data 586 587 588class VersionControlSystem(object): 589 """Abstract base class providing an interface to the VCS.""" 590 591 def __init__(self, options): 592 """Constructor. 593 594 Args: 595 options: Command line options. 596 """ 597 self.options = options 598 599 def GenerateDiff(self, args): 600 """Return the current diff as a string. 601 602 Args: 603 args: Extra arguments to pass to the diff command. 604 """ 605 raise NotImplementedError( 606 "abstract method -- subclass %s must override" % self.__class__) 607 608 def GetUnknownFiles(self): 609 """Return a list of files unknown to the VCS.""" 610 raise NotImplementedError( 611 "abstract method -- subclass %s must override" % self.__class__) 612 613 def CheckForUnknownFiles(self): 614 """Show an "are you sure?" prompt if there are unknown files.""" 615 unknown_files = self.GetUnknownFiles() 616 if unknown_files: 617 print "The following files are not added to version control:" 618 for line in unknown_files: 619 print line 620 prompt = "Are you sure to continue?(y/N) " 621 answer = raw_input(prompt).strip() 622 if answer != "y": 623 ErrorExit("User aborted") 624 625 def GetBaseFile(self, filename): 626 """Get the content of the upstream version of a file. 627 628 Returns: 629 A tuple (base_content, new_content, is_binary, status) 630 base_content: The contents of the base file. 631 new_content: For text files, this is empty. For binary files, this is 632 the contents of the new file, since the diff output won't contain 633 information to reconstruct the current file. 634 is_binary: True iff the file is binary. 635 status: The status of the file. 636 """ 637 638 raise NotImplementedError( 639 "abstract method -- subclass %s must override" % self.__class__) 640 641 642 def GetBaseFiles(self, diff): 643 """Helper that calls GetBase file for each file in the patch. 644 645 Returns: 646 A dictionary that maps from filename to GetBaseFile's tuple. Filenames 647 are retrieved based on lines that start with "Index:" or 648 "Property changes on:". 649 """ 650 files = {} 651 for line in diff.splitlines(True): 652 if line.startswith('Index:') or line.startswith('Property changes on:'): 653 unused, filename = line.split(':', 1) 654 # On Windows if a file has property changes its filename uses '\' 655 # instead of '/'. 656 filename = filename.strip().replace('\\', '/') 657 files[filename] = self.GetBaseFile(filename) 658 return files 659 660 661 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options, 662 files): 663 """Uploads the base files (and if necessary, the current ones as well).""" 664 665 def UploadFile(filename, file_id, content, is_binary, status, is_base): 666 """Uploads a file to the server.""" 667 file_too_large = False 668 if is_base: 669 type = "base" 670 else: 671 type = "current" 672 if len(content) > MAX_UPLOAD_SIZE: 673 print ("Not uploading the %s file for %s because it's too large." % 674 (type, filename)) 675 file_too_large = True 676 content = "" 677 checksum = md5.new(content).hexdigest() 678 if options.verbose > 0 and not file_too_large: 679 print "Uploading %s file for %s" % (type, filename) 680 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id) 681 form_fields = [("filename", filename), 682 ("status", status), 683 ("checksum", checksum), 684 ("is_binary", str(is_binary)), 685 ("is_current", str(not is_base)), 686 ] 687 if file_too_large: 688 form_fields.append(("file_too_large", "1")) 689 if options.email: 690 form_fields.append(("user", options.email)) 691 ctype, body = EncodeMultipartFormData(form_fields, 692 [("data", filename, content)]) 693 response_body = rpc_server.Send(url, body, 694 content_type=ctype) 695 if not response_body.startswith("OK"): 696 StatusUpdate(" --> %s" % response_body) 697 sys.exit(1) 698 699 patches = dict() 700 [patches.setdefault(v, k) for k, v in patch_list] 701 for filename in patches.keys(): 702 base_content, new_content, is_binary, status = files[filename] 703 file_id_str = patches.get(filename) 704 if file_id_str.find("nobase") != -1: 705 base_content = None 706 file_id_str = file_id_str[file_id_str.rfind("_") + 1:] 707 file_id = int(file_id_str) 708 if base_content != None: 709 UploadFile(filename, file_id, base_content, is_binary, status, True) 710 if new_content != None: 711 UploadFile(filename, file_id, new_content, is_binary, status, False) 712 713 def IsImage(self, filename): 714 """Returns true if the filename has an image extension.""" 715 mimetype = mimetypes.guess_type(filename)[0] 716 if not mimetype: 717 return False 718 return mimetype.startswith("image/") 719 720 721class SubversionVCS(VersionControlSystem): 722 """Implementation of the VersionControlSystem interface for Subversion.""" 723 724 def __init__(self, options): 725 super(SubversionVCS, self).__init__(options) 726 if self.options.revision: 727 match = re.match(r"(\d+)(:(\d+))?", self.options.revision) 728 if not match: 729 ErrorExit("Invalid Subversion revision %s." % self.options.revision) 730 self.rev_start = match.group(1) 731 self.rev_end = match.group(3) 732 else: 733 self.rev_start = self.rev_end = None 734 # Cache output from "svn list -r REVNO dirname". 735 # Keys: dirname, Values: 2-tuple (ouput for start rev and end rev). 736 self.svnls_cache = {} 737 # SVN base URL is required to fetch files deleted in an older revision. 738 # Result is cached to not guess it over and over again in GetBaseFile(). 739 required = self.options.download_base or self.options.revision is not None 740 self.svn_base = self._GuessBase(required) 741 742 def GuessBase(self, required): 743 """Wrapper for _GuessBase.""" 744 return self.svn_base 745 746 def _GuessBase(self, required): 747 """Returns the SVN base URL. 748 749 Args: 750 required: If true, exits if the url can't be guessed, otherwise None is 751 returned. 752 """ 753 info = RunShell(["svn", "info"]) 754 for line in info.splitlines(): 755 words = line.split() 756 if len(words) == 2 and words[0] == "URL:": 757 url = words[1] 758 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url) 759 username, netloc = urllib.splituser(netloc) 760 if username: 761 logging.info("Removed username from base URL") 762 if netloc.endswith("svn.python.org"): 763 if netloc == "svn.python.org": 764 if path.startswith("/projects/"): 765 path = path[9:] 766 elif netloc != "pythondev@svn.python.org": 767 ErrorExit("Unrecognized Python URL: %s" % url) 768 base = "http://svn.python.org/view/*checkout*%s/" % path 769 logging.info("Guessed Python base = %s", base) 770 elif netloc.endswith("svn.collab.net"): 771 if path.startswith("/repos/"): 772 path = path[6:] 773 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path 774 logging.info("Guessed CollabNet base = %s", base) 775 elif netloc.endswith(".googlecode.com"): 776 path = path + "/" 777 base = urlparse.urlunparse(("http", netloc, path, params, 778 query, fragment)) 779 logging.info("Guessed Google Code base = %s", base) 780 else: 781 path = path + "/" 782 base = urlparse.urlunparse((scheme, netloc, path, params, 783 query, fragment)) 784 logging.info("Guessed base = %s", base) 785 return base 786 if required: 787 ErrorExit("Can't find URL in output from svn info") 788 return None 789 790 def GenerateDiff(self, args): 791 cmd = ["svn", "diff"] 792 if self.options.revision: 793 cmd += ["-r", self.options.revision] 794 cmd.extend(args) 795 data = RunShell(cmd) 796 count = 0 797 for line in data.splitlines(): 798 if line.startswith("Index:") or line.startswith("Property changes on:"): 799 count += 1 800 logging.info(line) 801 if not count: 802 ErrorExit("No valid patches found in output from svn diff") 803 return data 804 805 def _CollapseKeywords(self, content, keyword_str): 806 """Collapses SVN keywords.""" 807 # svn cat translates keywords but svn diff doesn't. As a result of this 808 # behavior patching.PatchChunks() fails with a chunk mismatch error. 809 # This part was originally written by the Review Board development team 810 # who had the same problem (http://reviews.review-board.org/r/276/). 811 # Mapping of keywords to known aliases 812 svn_keywords = { 813 # Standard keywords 814 'Date': ['Date', 'LastChangedDate'], 815 'Revision': ['Revision', 'LastChangedRevision', 'Rev'], 816 'Author': ['Author', 'LastChangedBy'], 817 'HeadURL': ['HeadURL', 'URL'], 818 'Id': ['Id'], 819 820 # Aliases 821 'LastChangedDate': ['LastChangedDate', 'Date'], 822 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'], 823 'LastChangedBy': ['LastChangedBy', 'Author'], 824 'URL': ['URL', 'HeadURL'], 825 } 826 827 def repl(m): 828 if m.group(2): 829 return "$%s::%s$" % (m.group(1), " " * len(m.group(3))) 830 return "$%s$" % m.group(1) 831 keywords = [keyword 832 for name in keyword_str.split(" ") 833 for keyword in svn_keywords.get(name, [])] 834 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content) 835 836 def GetUnknownFiles(self): 837 status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True) 838 unknown_files = [] 839 for line in status.split("\n"): 840 if line and line[0] == "?": 841 unknown_files.append(line) 842 return unknown_files 843 844 def ReadFile(self, filename): 845 """Returns the contents of a file.""" 846 file = open(filename, 'rb') 847 result = "" 848 try: 849 result = file.read() 850 finally: 851 file.close() 852 return result 853 854 def GetStatus(self, filename): 855 """Returns the status of a file.""" 856 if not self.options.revision: 857 status = RunShell(["svn", "status", "--ignore-externals", filename]) 858 if not status: 859 ErrorExit("svn status returned no output for %s" % filename) 860 status_lines = status.splitlines() 861 # If file is in a cl, the output will begin with 862 # "\n--- Changelist 'cl_name':\n". See 863 # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt 864 if (len(status_lines) == 3 and 865 not status_lines[0] and 866 status_lines[1].startswith("--- Changelist")): 867 status = status_lines[2] 868 else: 869 status = status_lines[0] 870 # If we have a revision to diff against we need to run "svn list" 871 # for the old and the new revision and compare the results to get 872 # the correct status for a file. 873 else: 874 dirname, relfilename = os.path.split(filename) 875 if dirname not in self.svnls_cache: 876 cmd = ["svn", "list", "-r", self.rev_start, dirname or "."] 877 out, returncode = RunShellWithReturnCode(cmd) 878 if returncode: 879 ErrorExit("Failed to get status for %s." % filename) 880 old_files = out.splitlines() 881 args = ["svn", "list"] 882 if self.rev_end: 883 args += ["-r", self.rev_end] 884 cmd = args + [dirname or "."] 885 out, returncode = RunShellWithReturnCode(cmd) 886 if returncode: 887 ErrorExit("Failed to run command %s" % cmd) 888 self.svnls_cache[dirname] = (old_files, out.splitlines()) 889 old_files, new_files = self.svnls_cache[dirname] 890 if relfilename in old_files and relfilename not in new_files: 891 status = "D " 892 elif relfilename in old_files and relfilename in new_files: 893 status = "M " 894 else: 895 status = "A " 896 return status 897 898 def GetBaseFile(self, filename): 899 status = self.GetStatus(filename) 900 base_content = None 901 new_content = None 902 903 # If a file is copied its status will be "A +", which signifies 904 # "addition-with-history". See "svn st" for more information. We need to 905 # upload the original file or else diff parsing will fail if the file was 906 # edited. 907 if status[0] == "A" and status[3] != "+": 908 # We'll need to upload the new content if we're adding a binary file 909 # since diff's output won't contain it. 910 mimetype = RunShell(["svn", "propget", "svn:mime-type", filename], 911 silent_ok=True) 912 base_content = "" 913 is_binary = mimetype and not mimetype.startswith("text/") 914 if is_binary and self.IsImage(filename): 915 new_content = self.ReadFile(filename) 916 elif (status[0] in ("M", "D", "R") or 917 (status[0] == "A" and status[3] == "+") or # Copied file. 918 (status[0] == " " and status[1] == "M")): # Property change. 919 args = [] 920 if self.options.revision: 921 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start) 922 else: 923 # Don't change filename, it's needed later. 924 url = filename 925 args += ["-r", "BASE"] 926 cmd = ["svn"] + args + ["propget", "svn:mime-type", url] 927 mimetype, returncode = RunShellWithReturnCode(cmd) 928 if returncode: 929 # File does not exist in the requested revision. 930 # Reset mimetype, it contains an error message. 931 mimetype = "" 932 get_base = False 933 is_binary = mimetype and not mimetype.startswith("text/") 934 if status[0] == " ": 935 # Empty base content just to force an upload. 936 base_content = "" 937 elif is_binary: 938 if self.IsImage(filename): 939 get_base = True 940 if status[0] == "M": 941 if not self.rev_end: 942 new_content = self.ReadFile(filename) 943 else: 944 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end) 945 new_content = RunShell(["svn", "cat", url], 946 universal_newlines=True, silent_ok=True) 947 else: 948 base_content = "" 949 else: 950 get_base = True 951 952 if get_base: 953 if is_binary: 954 universal_newlines = False 955 else: 956 universal_newlines = True 957 if self.rev_start: 958 # "svn cat -r REV delete_file.txt" doesn't work. cat requires 959 # the full URL with "@REV" appended instead of using "-r" option. 960 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start) 961 base_content = RunShell(["svn", "cat", url], 962 universal_newlines=universal_newlines, 963 silent_ok=True) 964 else: 965 base_content = RunShell(["svn", "cat", filename], 966 universal_newlines=universal_newlines, 967 silent_ok=True) 968 if not is_binary: 969 args = [] 970 if self.rev_start: 971 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start) 972 else: 973 url = filename 974 args += ["-r", "BASE"] 975 cmd = ["svn"] + args + ["propget", "svn:keywords", url] 976 keywords, returncode = RunShellWithReturnCode(cmd) 977 if keywords and not returncode: 978 base_content = self._CollapseKeywords(base_content, keywords) 979 else: 980 StatusUpdate("svn status returned unexpected output: %s" % status) 981 sys.exit(1) 982 return base_content, new_content, is_binary, status[0:5] 983 984 985class GitVCS(VersionControlSystem): 986 """Implementation of the VersionControlSystem interface for Git.""" 987 988 def __init__(self, options): 989 super(GitVCS, self).__init__(options) 990 # Map of filename -> hash of base file. 991 self.base_hashes = {} 992 993 def GenerateDiff(self, extra_args): 994 # This is more complicated than svn's GenerateDiff because we must convert 995 # the diff output to include an svn-style "Index:" line as well as record 996 # the hashes of the base files, so we can upload them along with our diff. 997 if self.options.revision: 998 extra_args = [self.options.revision] + extra_args 999 gitdiff = RunShell(["git", "diff", "--full-index"] + extra_args) 1000 svndiff = [] 1001 filecount = 0 1002 filename = None 1003 for line in gitdiff.splitlines(): 1004 match = re.match(r"diff --git a/(.*) b/.*$", line) 1005 if match: 1006 filecount += 1 1007 filename = match.group(1) 1008 svndiff.append("Index: %s\n" % filename) 1009 else: 1010 # The "index" line in a git diff looks like this (long hashes elided): 1011 # index 82c0d44..b2cee3f 100755 1012 # We want to save the left hash, as that identifies the base file. 1013 match = re.match(r"index (\w+)\.\.", line) 1014 if match: 1015 self.base_hashes[filename] = match.group(1) 1016 svndiff.append(line + "\n") 1017 if not filecount: 1018 ErrorExit("No valid patches found in output from git diff") 1019 return "".join(svndiff) 1020 1021 def GetUnknownFiles(self): 1022 status = RunShell(["git", "ls-files", "--exclude-standard", "--others"], 1023 silent_ok=True) 1024 return status.splitlines() 1025 1026 def GetBaseFile(self, filename): 1027 hash = self.base_hashes[filename] 1028 base_content = None 1029 new_content = None 1030 is_binary = False 1031 if hash == "0" * 40: # All-zero hash indicates no base file. 1032 status = "A" 1033 base_content = "" 1034 else: 1035 status = "M" 1036 base_content, returncode = RunShellWithReturnCode(["git", "show", hash]) 1037 if returncode: 1038 ErrorExit("Got error status from 'git show %s'" % hash) 1039 return (base_content, new_content, is_binary, status) 1040 1041 1042class MercurialVCS(VersionControlSystem): 1043 """Implementation of the VersionControlSystem interface for Mercurial.""" 1044 1045 def __init__(self, options, repo_dir): 1046 super(MercurialVCS, self).__init__(options) 1047 # Absolute path to repository (we can be in a subdir) 1048 self.repo_dir = os.path.normpath(repo_dir) 1049 # Compute the subdir 1050 cwd = os.path.normpath(os.getcwd()) 1051 assert cwd.startswith(self.repo_dir) 1052 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/") 1053 if self.options.revision: 1054 self.base_rev = self.options.revision 1055 else: 1056 self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip() 1057 1058 def _GetRelPath(self, filename): 1059 """Get relative path of a file according to the current directory, 1060 given its logical path in the repo.""" 1061 assert filename.startswith(self.subdir), filename 1062 return filename[len(self.subdir):].lstrip(r"\/") 1063 1064 def GenerateDiff(self, extra_args): 1065 # If no file specified, restrict to the current subdir 1066 extra_args = extra_args or ["."] 1067 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args 1068 data = RunShell(cmd, silent_ok=True) 1069 svndiff = [] 1070 filecount = 0 1071 for line in data.splitlines(): 1072 m = re.match("diff --git a/(\S+) b/(\S+)", line) 1073 if m: 1074 # Modify line to make it look like as it comes from svn diff. 1075 # With this modification no changes on the server side are required 1076 # to make upload.py work with Mercurial repos. 1077 # NOTE: for proper handling of moved/copied files, we have to use 1078 # the second filename. 1079 filename = m.group(2) 1080 svndiff.append("Index: %s" % filename) 1081 svndiff.append("=" * 67) 1082 filecount += 1 1083 logging.info(line) 1084 else: 1085 svndiff.append(line) 1086 if not filecount: 1087 ErrorExit("No valid patches found in output from hg diff") 1088 return "\n".join(svndiff) + "\n" 1089 1090 def GetUnknownFiles(self): 1091 """Return a list of files unknown to the VCS.""" 1092 args = [] 1093 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."], 1094 silent_ok=True) 1095 unknown_files = [] 1096 for line in status.splitlines(): 1097 st, fn = line.split(" ", 1) 1098 if st == "?": 1099 unknown_files.append(fn) 1100 return unknown_files 1101 1102 def GetBaseFile(self, filename): 1103 # "hg status" and "hg cat" both take a path relative to the current subdir 1104 # rather than to the repo root, but "hg diff" has given us the full path 1105 # to the repo root. 1106 base_content = "" 1107 new_content = None 1108 is_binary = False 1109 oldrelpath = relpath = self._GetRelPath(filename) 1110 # "hg status -C" returns two lines for moved/copied files, one otherwise 1111 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath]) 1112 out = out.splitlines() 1113 # HACK: strip error message about missing file/directory if it isn't in 1114 # the working copy 1115 if out[0].startswith('%s: ' % relpath): 1116 out = out[1:] 1117 if len(out) > 1: 1118 # Moved/copied => considered as modified, use old filename to 1119 # retrieve base contents 1120 oldrelpath = out[1].strip() 1121 status = "M" 1122 else: 1123 status, _ = out[0].split(' ', 1) 1124 if status != "A": 1125 base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath], 1126 silent_ok=True) 1127 is_binary = "\0" in base_content # Mercurial's heuristic 1128 if status != "R": 1129 new_content = open(relpath, "rb").read() 1130 is_binary = is_binary or "\0" in new_content 1131 if is_binary and base_content: 1132 # Fetch again without converting newlines 1133 base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath], 1134 silent_ok=True, universal_newlines=False) 1135 if not is_binary or not self.IsImage(relpath): 1136 new_content = None 1137 return base_content, new_content, is_binary, status 1138 1139 1140# NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync. 1141def SplitPatch(data): 1142 """Splits a patch into separate pieces for each file. 1143 1144 Args: 1145 data: A string containing the output of svn diff. 1146 1147 Returns: 1148 A list of 2-tuple (filename, text) where text is the svn diff output 1149 pertaining to filename. 1150 """ 1151 patches = [] 1152 filename = None 1153 diff = [] 1154 for line in data.splitlines(True): 1155 new_filename = None 1156 if line.startswith('Index:'): 1157 unused, new_filename = line.split(':', 1) 1158 new_filename = new_filename.strip() 1159 elif line.startswith('Property changes on:'): 1160 unused, temp_filename = line.split(':', 1) 1161 # When a file is modified, paths use '/' between directories, however 1162 # when a property is modified '\' is used on Windows. Make them the same 1163 # otherwise the file shows up twice. 1164 temp_filename = temp_filename.strip().replace('\\', '/') 1165 if temp_filename != filename: 1166 # File has property changes but no modifications, create a new diff. 1167 new_filename = temp_filename 1168 if new_filename: 1169 if filename and diff: 1170 patches.append((filename, ''.join(diff))) 1171 filename = new_filename 1172 diff = [line] 1173 continue 1174 if diff is not None: 1175 diff.append(line) 1176 if filename and diff: 1177 patches.append((filename, ''.join(diff))) 1178 return patches 1179 1180 1181def UploadSeparatePatches(issue, rpc_server, patchset, data, options): 1182 """Uploads a separate patch for each file in the diff output. 1183 1184 Returns a list of [patch_key, filename] for each file. 1185 """ 1186 patches = SplitPatch(data) 1187 rv = [] 1188 for patch in patches: 1189 if len(patch[1]) > MAX_UPLOAD_SIZE: 1190 print ("Not uploading the patch for " + patch[0] + 1191 " because the file is too large.") 1192 continue 1193 form_fields = [("filename", patch[0])] 1194 if not options.download_base: 1195 form_fields.append(("content_upload", "1")) 1196 files = [("data", "data.diff", patch[1])] 1197 ctype, body = EncodeMultipartFormData(form_fields, files) 1198 url = "/%d/upload_patch/%d" % (int(issue), int(patchset)) 1199 print "Uploading patch for " + patch[0] 1200 response_body = rpc_server.Send(url, body, content_type=ctype) 1201 lines = response_body.splitlines() 1202 if not lines or lines[0] != "OK": 1203 StatusUpdate(" --> %s" % response_body) 1204 sys.exit(1) 1205 rv.append([lines[1], patch[0]]) 1206 return rv 1207 1208 1209def GuessVCS(options): 1210 """Helper to guess the version control system. 1211 1212 This examines the current directory, guesses which VersionControlSystem 1213 we're using, and returns an instance of the appropriate class. Exit with an 1214 error if we can't figure it out. 1215 1216 Returns: 1217 A VersionControlSystem instance. Exits if the VCS can't be guessed. 1218 """ 1219 # Mercurial has a command to get the base directory of a repository 1220 # Try running it, but don't die if we don't have hg installed. 1221 # NOTE: we try Mercurial first as it can sit on top of an SVN working copy. 1222 try: 1223 out, returncode = RunShellWithReturnCode(["hg", "root"]) 1224 if returncode == 0: 1225 return MercurialVCS(options, out.strip()) 1226 except OSError, (errno, message): 1227 if errno != 2: # ENOENT -- they don't have hg installed. 1228 raise 1229 1230 # Subversion has a .svn in all working directories. 1231 if os.path.isdir('.svn'): 1232 logging.info("Guessed VCS = Subversion") 1233 return SubversionVCS(options) 1234 1235 # Git has a command to test if you're in a git tree. 1236 # Try running it, but don't die if we don't have git installed. 1237 try: 1238 out, returncode = RunShellWithReturnCode(["git", "rev-parse", 1239 "--is-inside-work-tree"]) 1240 if returncode == 0: 1241 return GitVCS(options) 1242 except OSError, (errno, message): 1243 if errno != 2: # ENOENT -- they don't have git installed. 1244 raise 1245 1246 ErrorExit(("Could not guess version control system. " 1247 "Are you in a working copy directory?")) 1248 1249 1250def RealMain(argv, data=None): 1251 """The real main function. 1252 1253 Args: 1254 argv: Command line arguments. 1255 data: Diff contents. If None (default) the diff is generated by 1256 the VersionControlSystem implementation returned by GuessVCS(). 1257 1258 Returns: 1259 A 2-tuple (issue id, patchset id). 1260 The patchset id is None if the base files are not uploaded by this 1261 script (applies only to SVN checkouts). 1262 """ 1263 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:" 1264 "%(lineno)s %(message)s ")) 1265 os.environ['LC_ALL'] = 'C' 1266 options, args = parser.parse_args(argv[1:]) 1267 global verbosity 1268 verbosity = options.verbose 1269 if verbosity >= 3: 1270 logging.getLogger().setLevel(logging.DEBUG) 1271 elif verbosity >= 2: 1272 logging.getLogger().setLevel(logging.INFO) 1273 vcs = GuessVCS(options) 1274 if isinstance(vcs, SubversionVCS): 1275 # base field is only allowed for Subversion. 1276 # Note: Fetching base files may become deprecated in future releases. 1277 base = vcs.GuessBase(options.download_base) 1278 else: 1279 base = None 1280 if not base and options.download_base: 1281 options.download_base = True 1282 logging.info("Enabled upload of base file") 1283 if not options.assume_yes: 1284 vcs.CheckForUnknownFiles() 1285 if data is None: 1286 data = vcs.GenerateDiff(args) 1287 files = vcs.GetBaseFiles(data) 1288 if verbosity >= 1: 1289 print "Upload server:", options.server, "(change with -s/--server)" 1290 if options.issue: 1291 prompt = "Message describing this patch set: " 1292 else: 1293 prompt = "New issue subject: " 1294 message = options.message or raw_input(prompt).strip() 1295 if not message: 1296 ErrorExit("A non-empty message is required") 1297 rpc_server = GetRpcServer(options) 1298 form_fields = [("subject", message)] 1299 if base: 1300 form_fields.append(("base", base)) 1301 if options.issue: 1302 form_fields.append(("issue", str(options.issue))) 1303 if options.email: 1304 form_fields.append(("user", options.email)) 1305 if options.reviewers: 1306 for reviewer in options.reviewers.split(','): 1307 if "@" in reviewer and not reviewer.split("@")[1].count(".") == 1: 1308 ErrorExit("Invalid email address: %s" % reviewer) 1309 form_fields.append(("reviewers", options.reviewers)) 1310 if options.cc: 1311 for cc in options.cc.split(','): 1312 if "@" in cc and not cc.split("@")[1].count(".") == 1: 1313 ErrorExit("Invalid email address: %s" % cc) 1314 form_fields.append(("cc", options.cc)) 1315 description = options.description 1316 if options.description_file: 1317 if options.description: 1318 ErrorExit("Can't specify description and description_file") 1319 file = open(options.description_file, 'r') 1320 description = file.read() 1321 file.close() 1322 if description: 1323 form_fields.append(("description", description)) 1324 # Send a hash of all the base file so the server can determine if a copy 1325 # already exists in an earlier patchset. 1326 base_hashes = "" 1327 for file, info in files.iteritems(): 1328 if not info[0] is None: 1329 checksum = md5.new(info[0]).hexdigest() 1330 if base_hashes: 1331 base_hashes += "|" 1332 base_hashes += checksum + ":" + file 1333 form_fields.append(("base_hashes", base_hashes)) 1334 # If we're uploading base files, don't send the email before the uploads, so 1335 # that it contains the file status. 1336 if options.send_mail and options.download_base: 1337 form_fields.append(("send_mail", "1")) 1338 if not options.download_base: 1339 form_fields.append(("content_upload", "1")) 1340 if len(data) > MAX_UPLOAD_SIZE: 1341 print "Patch is large, so uploading file patches separately." 1342 uploaded_diff_file = [] 1343 form_fields.append(("separate_patches", "1")) 1344 else: 1345 uploaded_diff_file = [("data", "data.diff", data)] 1346 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file) 1347 response_body = rpc_server.Send("/upload", body, content_type=ctype) 1348 patchset = None 1349 if not options.download_base or not uploaded_diff_file: 1350 lines = response_body.splitlines() 1351 if len(lines) >= 2: 1352 msg = lines[0] 1353 patchset = lines[1].strip() 1354 patches = [x.split(" ", 1) for x in lines[2:]] 1355 else: 1356 msg = response_body 1357 else: 1358 msg = response_body 1359 StatusUpdate(msg) 1360 if not response_body.startswith("Issue created.") and \ 1361 not response_body.startswith("Issue updated."): 1362 sys.exit(0) 1363 issue = msg[msg.rfind("/")+1:] 1364 1365 if not uploaded_diff_file: 1366 result = UploadSeparatePatches(issue, rpc_server, patchset, data, options) 1367 if not options.download_base: 1368 patches = result 1369 1370 if not options.download_base: 1371 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files) 1372 if options.send_mail: 1373 rpc_server.Send("/" + issue + "/mail", payload="") 1374 return issue, patchset 1375 1376 1377def main(): 1378 try: 1379 RealMain(sys.argv) 1380 except KeyboardInterrupt: 1381 print 1382 StatusUpdate("Interrupted.") 1383 sys.exit(1) 1384 1385 1386if __name__ == "__main__": 1387 main() 1388