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