Analysis Software
Documentation for sPHENIX simulation software
 All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Groups Pages
upload.py
Go to the documentation of this file. Or view the newest version in sPHENIX GitHub for file 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 
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 
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 
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 
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 
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()