initial import
[vuplus_webkit] / Source / ThirdParty / gtest / scripts / upload.py
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
19 Usage summary: upload.py [options] [-- diff_options]
20
21 Diff options are passed to the diff command of the underlying system.
22
23 Supported version control systems:
24   Git
25   Mercurial
26   Subversion
27
28 It is important for Git/Mercurial users to specify a tree/node/branch to diff
29 against 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
34 import cookielib
35 import getpass
36 import logging
37 import md5
38 import mimetypes
39 import optparse
40 import os
41 import re
42 import socket
43 import subprocess
44 import sys
45 import urllib
46 import urllib2
47 import urlparse
48
49 try:
50   import readline
51 except ImportError:
52   pass
53
54 # The logging verbosity:
55 #  0: Errors only.
56 #  1: Status messages.
57 #  2: Info logs.
58 #  3: Debug logs.
59 verbosity = 1
60
61 # Max size of patch or base file.
62 MAX_UPLOAD_SIZE = 900 * 1024
63
64
65 def 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
97 def 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
109 def ErrorExit(msg):
110   """Print an error message to stderr and exit."""
111   print >>sys.stderr, msg
112   sys.exit(1)
113
114
115 class 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
124 class 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
344 class 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
392 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]")
393 parser.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
397 group = parser.add_option_group("Logging options")
398 group.add_option("-q", "--quiet", action="store_const", const=0,
399                  dest="verbose", help="Print errors only.")
400 group.add_option("-v", "--verbose", action="store_const", const=2,
401                  dest="verbose", default=1,
402                  help="Print info level logs (default).")
403 group.add_option("--noisy", action="store_const", const=3,
404                  dest="verbose", help="Print all logs.")
405 # Review server
406 group = parser.add_option_group("Review server options")
407 group.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'."))
412 group.add_option("-e", "--email", action="store", dest="email",
413                  metavar="EMAIL", default=None,
414                  help="The username to use. Will prompt if omitted.")
415 group.add_option("-H", "--host", action="store", dest="host",
416                  metavar="HOST", default=None,
417                  help="Overrides the Host header sent with all RPCs.")
418 group.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
422 group = parser.add_option_group("Issue options")
423 group.add_option("-d", "--description", action="store", dest="description",
424                  metavar="DESCRIPTION", default=None,
425                  help="Optional description when creating an issue.")
426 group.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.")
431 group.add_option("-r", "--reviewers", action="store", dest="reviewers",
432                  metavar="REVIEWERS", default=None,
433                  help="Add reviewers (comma separated email addresses).")
434 group.add_option("--cc", action="store", dest="cc",
435                  metavar="CC", default=None,
436                  help="Add CC (comma separated email addresses).")
437 # Upload options
438 group = parser.add_option_group("Patch options")
439 group.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.")
443 group.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.")
446 group.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).")
450 group.add_option("--rev", action="store", dest="revision",
451                  metavar="REV", default=None,
452                  help="Branch/tree/revision to diff against (used by DVCS).")
453 group.add_option("--send_mail", action="store_true",
454                  dest="send_mail", default=False,
455                  help="Send notification email to reviewers.")
456
457
458 def 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
498 def 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
533 def 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.
539 use_shell = sys.platform.startswith("win")
540
541 def 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
577 def 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
588 class 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
721 class 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
985 class 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
1042 class 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.
1141 def 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
1181 def 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
1209 def 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
1250 def 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
1377 def main():
1378   try:
1379     RealMain(sys.argv)
1380   except KeyboardInterrupt:
1381     print
1382     StatusUpdate("Interrupted.")
1383     sys.exit(1)
1384
1385
1386 if __name__ == "__main__":
1387   main()