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