Revision: 5167
Author:   [email protected]
Date:     Thu Nov 29 12:08:20 2012
Log:      Update tools/upload.py from Rietveld's copy.
https://codereview.appspot.com/6842118

codereview.appspot.com now 301 redirects to HTTPS, which is probably
a good thing, but the version of upload.py we're using doesn't like it.

* Update tools/upload.py to the latest version from the Rietveld
  repository, since we haven't done so in ages:
<http://code.google.com/p/rietveld/source/browse/upload.py?spec=svnf66cc9030489ea92a2e699a0f1a68a854f1d064c&r=f66cc9030489ea92a2e699a0f1a68a854f1d064c>
* Explicitly specify https: in appspot.py because even the new
  version of upload.py appears, from a quick review, to be defaulting to
  http:.
* upload.py has also renamed the fields of an issue; change appspot.py
  to match. 'Message' is now 'Title' and 'Description' is now 'Message'.
  Includes backwards compatibility for old .appspot-change files.

If you are reading this and it is not mangled, then uploading a review
using these changes to the tools worked.

[email protected]

http://code.google.com/p/google-caja/source/detail?r=5167

Modified:
 /trunk/tools/appspot.py
 /trunk/tools/upload.py

=======================================
--- /trunk/tools/appspot.py     Tue Aug 14 06:16:34 2012
+++ /trunk/tools/appspot.py     Thu Nov 29 12:08:20 2012
@@ -13,8 +13,8 @@
 If you're using git, the filename will be '.appspot-change.$GITBRANCH'.

 Flags:
-  -m --message : overrides the changelist message, a one line summary.
-  -d --description : overrides the changelist's detailed description.
+  -t --title : overrides the changelist title, a one line summary.
+  -m --message : overrides the changelist's detailed description.
   -r --reviewer : overrides the email of the assigned reviewer
   -c --cc : overrides the CC list.
   -p --private : mark the mail as private.
@@ -34,25 +34,28 @@
 import tempfile
 import upload

+# explicitly specify https, because upload.py doesn't default to it
+server = 'https://codereview.appspot.com'
+
 class ChangeList(object):
-  def __init__(self, issue=None, description=None, reviewer=None, cc=None,
-               message=None, private=None):
+  def __init__(self, issue=None, title=None, message=None, cc=None,
+               reviewer=None, private=None):
     """
     A value of None for any parameter means its unspecified and so will not
     be set on a merge operation.
     """
     assert issue is None or type(issue) in (str, unicode)
-    assert description is None or type(description) in (str, unicode)
+    assert title is None or type(title) in (str, unicode)
+    assert message is None or type(message) in (str, unicode)
     assert cc is None or type(cc) in (str, unicode)
     assert reviewer is None or type(reviewer) in (str, unicode)
-    assert message is None or type(message) in (str, unicode)
     assert private is None or type(private) is bool

     self.issue = issue
-    self.description = description
+    self.title = title
+    self.message = message
     self.cc = cc
     self.reviewer = reviewer
-    self.message = message
     self.private = private
# TODO(mikesamuel): add a code.google.com bug number to update when code
     # is submitted
@@ -60,12 +63,12 @@
   def get_app_spot_uri(self):
     """The URL of a change list."""
     if self.issue is not None:
-      return 'http://codereview.appspot.com/%d' % int(self.issue)
+      return '%s/%d' % (server, int(self.issue))
     return None

   def is_unspecified(self):
- return (self.issue is None and self.description is None and self.cc is None
-            and self.reviewer is None and self.message is None
+ return (self.issue is None and self.title is None and self.message is None
+            and self.cc is None and self.reviewer is None
             and self.private is None)

   def get_upload_args(self, send_mail=False):
@@ -74,10 +77,13 @@
     modify a code review.
     """
     args = []
+    args.extend(['--server', server])
     if self.issue:
       args.extend(['--issue', str(self.issue)])
-    if self.description:
-      args.extend(['--description', str(self.description)])
+    if self.title:
+      args.extend(['--title', str(self.title)])
+    if self.message:
+      args.extend(['--message', str(self.message)])
     if self.private:
       # Private issues should not go to the public list.
       cc_list = difference_of_address_lists(
@@ -98,8 +104,6 @@
     args.extend(['--cc', cc_list])
     if self.reviewer:
       args.extend(['--reviewer', str(self.reviewer)])
-    if self.message:
-      args.extend(['--message', str(self.message)])
     if self.private:
       args.append('--private');
     if send_mail:
@@ -131,10 +135,10 @@

   def merge_into(self, target):
     if self.issue is not None:        target.issue = self.issue
-    if self.description is not None:  target.description = self.description
+    if self.title is not None:        target.title = self.title
+    if self.message is not None:      target.message = self.message
     if self.cc is not None:           target.cc = self.cc
     if self.reviewer is not None:     target.reviewer = self.reviewer
-    if self.message is not None:      target.message = self.message
     if self.private is not None:      target.private = self.private


@@ -190,9 +194,9 @@
 %(url)s


-### Message:
+### Title:
 ### One-line summary of the change.
-%(message)s
+%(title)s


 ### Private:
@@ -201,9 +205,9 @@
 %(private)s


-### Description:
+### Message:
 ### Detailed description of the change.
-%(description)s
+%(message)s


 ### Reviewer:
@@ -223,10 +227,10 @@
   return ('''


-%(message)s
+%(title)s
 %(url)s

-%(description)s
+%(message)s

 R=%(reviewer)s

@@ -242,8 +246,8 @@
   return {
     'issue': changelist.issue or '<unassigned>',
     'url': changelist.get_app_spot_uri() or '<unassigned>',
+    'title': changelist.title or '',
     'message': changelist.message or '',
-    'description': changelist.description or '',
     'reviewer': changelist.reviewer or '',
     'cc': changelist.cc or '',
     'private': private_str,
@@ -279,10 +283,17 @@
   issue = fields.get('Issue')
   if issue is not None and not re.search('^\d+$', issue):
     issue = None
-  description = fields.get('Description')
+
+ # Compatibility with older field names. TODO(kpreid): Remove this after no one
+  # likely has older files.
+  if 'Description' in fields and not 'Title' in fields:
+    title = fields.get('Message')
+    message = fields.get('Description')
+  else:
+    title = fields.get('Title')
+    message = fields.get('Message')
   cc = fields.get('CC')
   reviewer = fields.get('Reviewer')
-  message = fields.get('Message')
   private = fields.get('Private')
   if private is not None:
     private = private.strip().lower()
@@ -290,8 +301,8 @@
       private = None
     else:
       private = private != 'false'
-  return ChangeList(issue=issue, description=description, cc=cc,
-                    reviewer=reviewer, message=message, private=private)
+  return ChangeList(issue=issue, title=title, message=message, cc=cc,
+                    reviewer=reviewer, private=private)


 def do_edit(given_cl, current_cl, cl_file_path):
@@ -339,7 +350,7 @@
     if current_cl.reviewer is not None:
       send_mail = True
   # If the user has not created a CL description, show an editor.
-  if not current_cl.message or not current_cl.reviewer:
+  if not current_cl.title or not current_cl.reviewer:
     do_edit(ChangeList(), current_cl, cl_file_path)
   # If the CL does not have an issue number but user specified a reviewer
   # send mail since it's the first upload.
@@ -382,8 +393,8 @@
     # Map short flag names to long flag names
     abbrevs = {
         '-i': '--issue',
+        '-t': '--title',
         '-m': '--message',
-        '-d': '--description',
         '-r': '--reviewer',
         '-c': '--cc',
         '-p': '--private',
@@ -392,8 +403,8 @@
     flag_spec = {
         '--send_mail': bool,
         '--issue': int,
+        '--title': str,
         '--message': str,
-        '--description': str,
         '--reviewer': str,
         '--cc': str,
         '--private': bool,
@@ -452,8 +463,8 @@
   if len(argv) != 0: show_help_and_exit()
   given_cl = ChangeList(
       issue=params.get('--issue'),
+      title=params.get('--title'),
       message=params.get('--message'),
-      description=params.get('--description'),
       reviewer=params.get('--reviewer'),
       cc=params.get('--cc'),
       private=params.get('--private'))
=======================================
--- /trunk/tools/upload.py      Fri Aug 13 17:09:11 2010
+++ /trunk/tools/upload.py      Thu Nov 29 12:08:20 2012
@@ -1,4 +1,5 @@
 #!/usr/bin/env python
+# coding: utf-8
 #
 # Copyright 2007 Google Inc.
 #
@@ -16,7 +17,7 @@

"""Tool for uploading diffs from a version control system to the codereview app.

-Usage summary: upload.py [options] [-- diff_options]
+Usage summary: upload.py [options] [-- diff_options] [path...]

 Diff options are passed to the diff command of the underlying system.

@@ -24,6 +25,8 @@
   Git
   Mercurial
   Subversion
+  Perforce
+  CVS

It is important for Git/Mercurial users to specify a tree/node/branch to diff
 against by using the '--rev' option.
@@ -31,9 +34,13 @@
 # This code is derived from appcfg.py in the App Engine SDK (open source),
 # and from ASPN recipe #146306.

+import ConfigParser
 import cookielib
+import errno
+import fnmatch
 import getpass
 import logging
+import marshal
 import mimetypes
 import optparse
 import os
@@ -51,11 +58,15 @@
 except ImportError:
   from md5 import md5

-if __name__ == "__main__":
-  try:
-    import readline
-  except ImportError:
-    pass
+try:
+  import readline
+except ImportError:
+  pass
+
+try:
+  import keyring
+except ImportError:
+  keyring = None

 # The logging verbosity:
 #  0: Errors only.
@@ -64,6 +75,15 @@
 #  3: Debug logs.
 verbosity = 1

+# The account type used for authentication.
+# This line could be changed by the review server (see handler for
+# upload.py).
+AUTH_ACCOUNT_TYPE = "GOOGLE"
+
+# URL of the default review server. As for AUTH_ACCOUNT_TYPE, this line could be
+# changed by the review server (see handler for upload.py).
+DEFAULT_REVIEW_SERVER = "codereview.appspot.com"
+
 # Max size of patch or base file.
 MAX_UPLOAD_SIZE = 900 * 1024

@@ -71,8 +91,23 @@
 VCS_GIT = "Git"
 VCS_MERCURIAL = "Mercurial"
 VCS_SUBVERSION = "Subversion"
+VCS_PERFORCE = "Perforce"
+VCS_CVS = "CVS"
 VCS_UNKNOWN = "Unknown"

+VCS_ABBREVIATIONS = {
+  VCS_MERCURIAL.lower(): VCS_MERCURIAL,
+  "hg": VCS_MERCURIAL,
+  VCS_SUBVERSION.lower(): VCS_SUBVERSION,
+  "svn": VCS_SUBVERSION,
+  VCS_PERFORCE.lower(): VCS_PERFORCE,
+  "p4": VCS_PERFORCE,
+  VCS_GIT.lower(): VCS_GIT,
+  VCS_CVS.lower(): VCS_CVS,
+}
+
+# The result of parsing Subversion's [auto-props] setting.
+svn_auto_props_map = None

 def GetEmail(prompt):
   """Prompts the user for their email address and returns it.
@@ -130,15 +165,23 @@
   def __init__(self, url, code, msg, headers, args):
     urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
     self.args = args
-    self.reason = args["Error"]
+    self._reason = args["Error"]
+    self.info = args.get("Info", None)

+  @property
+  def reason(self):
+    # reason is a property on python 2.7 but a member variable on <=2.6.
+    # self.args is modified so it cannot be used as-is so save the value in
+    # self._reason.
+    return self._reason
+

 class AbstractRpcServer(object):
   """Provides a common interface for a simple RPC server."""

def __init__(self, host, auth_function, host_override=None, extra_headers={},
-               save_cookies=False):
-    """Creates a new HttpRpcServer.
+               save_cookies=False, account_type=AUTH_ACCOUNT_TYPE):
+    """Creates a new AbstractRpcServer.

     Args:
       host: The host to send requests to.
@@ -150,13 +193,19 @@
       save_cookies: If True, save the authentication cookies to local disk.
         If False, use an in-memory cookiejar instead.  Subclasses must
         implement this functionality.  Defaults to False.
+      account_type: Account type used for authentication. Defaults to
+        AUTH_ACCOUNT_TYPE.
     """
     self.host = host
+    if (not self.host.startswith("http://";) and
+        not self.host.startswith("https://";)):
+      self.host = "http://"; + self.host
     self.host_override = host_override
     self.auth_function = auth_function
     self.authenticated = False
     self.extra_headers = extra_headers
     self.save_cookies = save_cookies
+    self.account_type = account_type
     self.opener = self._GetOpener()
     if self.host_override:
       logging.info("Server: %s; Host: %s", self.host, self.host_override)
@@ -174,7 +223,7 @@
   def _CreateRequest(self, url, data=None):
     """Creates a new urllib request."""
logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
-    req = urllib2.Request(url, data=data)
+    req = urllib2.Request(url, data=data, headers={"Accept": "text/plain"})
     if self.host_override:
       req.add_header("Host", self.host_override)
     for key, value in self.extra_headers.iteritems():
@@ -195,7 +244,7 @@
     Returns:
       The authentication token returned by ClientLogin.
     """
-    account_type = "GOOGLE"
+    account_type = self.account_type
     if self.host.endswith(".google.com"):
       # Needed for use inside Google.
       account_type = "HOSTED"
@@ -236,7 +285,7 @@
     # This is a dummy value to allow us to identify when we're successful.
     continue_location = "http://localhost/";
     args = {"continue": continue_location, "auth": auth_token}
-    req = self._CreateRequest("http://%s/_ah/login?%s"; %
+    req = self._CreateRequest("%s/_ah/login?%s" %
                               (self.host, urllib.urlencode(args)))
     try:
       response = self.opener.open(req)
@@ -260,49 +309,57 @@
         us to the URL we provided.

     If we attempt to access the upload API without first obtaining an
-    authentication cookie, it returns a 401 response and directs us to
-    authenticate ourselves with ClientLogin.
+    authentication cookie, it returns a 401 response (or a 302) and
+    directs us to authenticate ourselves with ClientLogin.
     """
     for i in range(3):
       credentials = self.auth_function()
       try:
         auth_token = self._GetAuthToken(credentials[0], credentials[1])
       except ClientLoginError, e:
+        print >>sys.stderr, ''
         if e.reason == "BadAuthentication":
-          print >>sys.stderr, "Invalid username or password."
-          continue
-        if e.reason == "CaptchaRequired":
+          if e.info == "InvalidSecondFactor":
+            print >>sys.stderr, (
+                "Use an application-specific password instead "
+                "of your regular account password.\n"
+                "See http://www.google.com/";
+                "support/accounts/bin/answer.py?answer=185833")
+          else:
+            print >>sys.stderr, "Invalid username or password."
+        elif e.reason == "CaptchaRequired":
           print >>sys.stderr, (
               "Please go to\n"
               "https://www.google.com/accounts/DisplayUnlockCaptcha\n";
-              "and verify you are a human.  Then try again.")
-          break
-        if e.reason == "NotVerified":
+              "and verify you are a human.  Then try again.\n"
+              "If you are using a Google Apps account the URL is:\n"
+              "https://www.google.com/a/yourdomain.com/UnlockCaptcha";)
+        elif e.reason == "NotVerified":
           print >>sys.stderr, "Account not verified."
-          break
-        if e.reason == "TermsNotAgreed":
+        elif e.reason == "TermsNotAgreed":
           print >>sys.stderr, "User has not agreed to TOS."
-          break
-        if e.reason == "AccountDeleted":
+        elif e.reason == "AccountDeleted":
           print >>sys.stderr, "The user account has been deleted."
-          break
-        if e.reason == "AccountDisabled":
+        elif e.reason == "AccountDisabled":
           print >>sys.stderr, "The user account has been disabled."
           break
-        if e.reason == "ServiceDisabled":
+        elif e.reason == "ServiceDisabled":
           print >>sys.stderr, ("The user's access to the service has been "
                                "disabled.")
-          break
-        if e.reason == "ServiceUnavailable":
+        elif e.reason == "ServiceUnavailable":
print >>sys.stderr, "The service is not available; try again later."
-          break
-        raise
+        else:
+          # Unknown error.
+          raise
+        print >>sys.stderr, ''
+        continue
       self._GetAuthCookie(auth_token)
       return

   def Send(self, request_path, payload=None,
            content_type="application/octet-stream",
            timeout=None,
+           extra_headers=None,
            **kwargs):
     """Sends an RPC and returns the response.

@@ -312,6 +369,9 @@
       content_type: The Content-Type header to use.
       timeout: timeout in seconds; default None i.e. no timeout.
         (Note: for large requests on OS X, the timeout doesn't work right.)
+      extra_headers: Dict containing additional HTTP headers that should be
+ included in the request (string header names mapped to their values),
+        or None to not include any additional headers.
kwargs: Any keyword arguments are converted into query string parameters.

     Returns:
@@ -329,11 +389,14 @@
       while True:
         tries += 1
         args = dict(kwargs)
-        url = "http://%s%s"; % (self.host, request_path)
+        url = "%s%s" % (self.host, request_path)
         if args:
           url += "?" + urllib.urlencode(args)
         req = self._CreateRequest(url=url, data=payload)
         req.add_header("Content-Type", content_type)
+        if extra_headers:
+          for header, value in extra_headers.items():
+            req.add_header(header, value)
         try:
           f = self.opener.open(req)
           response = f.read()
@@ -342,11 +405,15 @@
         except urllib2.HTTPError, e:
           if tries > 3:
             raise
-          elif e.code == 401:
+          elif e.code == 401 or e.code == 302:
             self._Authenticate()
-##           elif e.code >= 500 and e.code < 600:
-##             # Server Error - try again.
-##             continue
+          elif e.code == 301:
+            # Handle permanent redirect manually.
+            url = e.info()["location"]
+            url_loc = urlparse.urlparse(url)
+            self.host = '%s://%s' % (url_loc[0], url_loc[1])
+          elif e.code >= 500:
+            ErrorExit(e.read())
           else:
             raise
     finally:
@@ -401,7 +468,39 @@
     return opener


-parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]")
+class CondensedHelpFormatter(optparse.IndentedHelpFormatter):
+   """Frees more horizontal space by removing indentation from group
+      options and collapsing arguments between short and long, e.g.
+      '-o ARG, --opt=ARG' to -o --opt ARG"""
+
+   def format_heading(self, heading):
+     return "%s:\n" % heading
+
+   def format_option(self, option):
+     self.dedent()
+     res = optparse.HelpFormatter.format_option(self, option)
+     self.indent()
+     return res
+
+   def format_option_strings(self, option):
+     self.set_long_opt_delimiter(" ")
+     optstr = optparse.HelpFormatter.format_option_strings(self, option)
+     optlist = optstr.split(", ")
+     if len(optlist) > 1:
+       if option.takes_value():
+         # strip METAVAR from all but the last option
+         optlist = [x.split()[0] for x in optlist[:-1]] + optlist[-1:]
+       optstr = " ".join(optlist)
+     return optstr
+
+
+parser = optparse.OptionParser(
+    usage="%prog [options] [-- diff_options] [path...]",
+    add_help_option=False,
+    formatter=CondensedHelpFormatter()
+)
+parser.add_option("-h", "--help", action="store_true",
+                  help="Show this help message and exit.")
 parser.add_option("-y", "--assume_yes", action="store_true",
                   dest="assume_yes", default=False,
help="Assume that the answer to yes/no questions is 'yes'.")
@@ -411,13 +510,15 @@
                  dest="verbose", help="Print errors only.")
 group.add_option("-v", "--verbose", action="store_const", const=2,
                  dest="verbose", default=1,
-                 help="Print info level logs (default).")
+                 help="Print info level logs.")
 group.add_option("--noisy", action="store_const", const=3,
                  dest="verbose", help="Print all logs.")
+group.add_option("--print_diffs", dest="print_diffs", action="store_true",
+                 help="Print full diffs.")
 # Review server
 group = parser.add_option_group("Review server options")
 group.add_option("-s", "--server", action="store", dest="server",
-                 default="codereview.appspot.com",
+                 default=DEFAULT_REVIEW_SERVER,
                  metavar="SERVER",
help=("The server to upload to. The format is host[:port]. "
                        "Defaults to '%default'."))
@@ -430,16 +531,21 @@
 group.add_option("--no_cookies", action="store_false",
                  dest="save_cookies", default=True,
                  help="Do not save authentication cookies to local disk.")
+group.add_option("--account_type", action="store", dest="account_type",
+                 metavar="TYPE", default=AUTH_ACCOUNT_TYPE,
+                 choices=["GOOGLE", "HOSTED"],
+                 help=("Override the default account type "
+                       "(defaults to '%default', "
+                       "valid choices are 'GOOGLE' and 'HOSTED')."))
 # Issue
 group = parser.add_option_group("Issue options")
-group.add_option("-d", "--description", action="store", dest="description",
-                 metavar="DESCRIPTION", default=None,
-                 help="Optional description when creating an issue.")
-group.add_option("-f", "--description_file", action="store",
-                 dest="description_file", metavar="DESCRIPTION_FILE",
+group.add_option("-t", "--title", action="store", dest="title",
+                 help="New issue subject or new patch set title")
+group.add_option("-m", "--message", action="store", dest="message",
                  default=None,
-                 help="Optional path of a file that contains "
-                      "the description when creating an issue.")
+                 help="New issue description or new patch set message")
+group.add_option("-F", "--file", action="store", dest="file",
+                 default=None, help="Read the message above from file.")
 group.add_option("-r", "--reviewers", action="store", dest="reviewers",
                  metavar="REVIEWERS", default=None,
                  help="Add reviewers (comma separated email addresses).")
@@ -451,63 +557,142 @@
help="Make the issue restricted to reviewers and those CCed")
 # Upload options
 group = parser.add_option_group("Patch options")
-group.add_option("-m", "--message", action="store", dest="message",
-                 metavar="MESSAGE", default=None,
-                 help="A message to identify the patch. "
-                      "Will prompt if omitted.")
 group.add_option("-i", "--issue", type="int", action="store",
                  metavar="ISSUE", default=None,
help="Issue number to which to add. Defaults to new issue.") +group.add_option("--base_url", action="store", dest="base_url", default=None, + help="Base URL path for files (listed as \"Base URL\" when " + "viewing issue). If omitted, will be guessed automatically "
+                 "for SVN repos and left blank for others.")
 group.add_option("--download_base", action="store_true",
                  dest="download_base", default=False,
                  help="Base files will be downloaded by the server "
                  "(side-by-side diffs may not work on files with CRs).")
 group.add_option("--rev", action="store", dest="revision",
                  metavar="REV", default=None,
- help="Branch/tree/revision to diff against (used by DVCS).")
+                 help="Base revision/branch/tree to diff against. Use "
+ "rev1:rev2 range to review already committed changeset.")
 group.add_option("--send_mail", action="store_true",
                  dest="send_mail", default=False,
                  help="Send notification email to reviewers.")
+group.add_option("-p", "--send_patch", action="store_true",
+                 dest="send_patch", default=False,
+                 help="Same as --send_mail, but include diff as an "
+ "attachment, and prepend email subject with 'PATCH:'.")
+group.add_option("--vcs", action="store", dest="vcs",
+                 metavar="VCS", default=None,
+ help=("Version control system (optional, usually upload.py "
+                       "already guesses the right VCS)."))
+group.add_option("--emulate_svn_auto_props", action="store_true",
+                 dest="emulate_svn_auto_props", default=False,
+                 help=("Emulate Subversion's auto properties feature."))
+# Git-specific
+group = parser.add_option_group("Git-specific options")
+group.add_option("--git_similarity", action="store", dest="git_similarity",
+                 metavar="SIM", type="int", default=50,
+ help=("Set the minimum similarity index for detecting renames "
+                       "and copies. See `git diff -C`. (default 50)."))
+group.add_option("--git_no_find_copies", action="store_false", default=True,
+                 dest="git_find_copies",
+ help=("Prevents git from looking for copies (default off)."))
+# Perforce-specific
+group = parser.add_option_group("Perforce-specific options "
+                                "(overrides P4 environment variables)")
+group.add_option("--p4_port", action="store", dest="p4_port",
+                 metavar="P4_PORT", default=None,
+                 help=("Perforce server and port (optional)"))
+group.add_option("--p4_changelist", action="store", dest="p4_changelist",
+                 metavar="P4_CHANGELIST", default=None,
+                 help=("Perforce changelist id"))
+group.add_option("--p4_client", action="store", dest="p4_client",
+                 metavar="P4_CLIENT", default=None,
+                 help=("Perforce client/workspace"))
+group.add_option("--p4_user", action="store", dest="p4_user",
+                 metavar="P4_USER", default=None,
+                 help=("Perforce user"))


-def GetRpcServer(options):
-  """Returns an instance of an AbstractRpcServer.
+class KeyringCreds(object):
+  def __init__(self, server, host, email):
+    self.server = server
+    self.host = host
+    self.email = email
+    self.accounts_seen = set()

-  Returns:
-    A new AbstractRpcServer, on which RPC calls can be made.
-  """
+  def GetUserCredentials(self):
+    """Prompts the user for a username and password.

-  rpc_server_class = HttpRpcServer
-
-  def GetUserCredentials():
-    """Prompts the user for a username and password."""
-    email = options.email
+    Only use keyring on the initial call. If the keyring contains the wrong
+    password, we want to give the user a chance to enter another one.
+    """
+    # Create a local alias to the email variable to avoid Python's crazy
+    # scoping rules.
+    global keyring
+    email = self.email
     if email is None:
- email = GetEmail("Email (login for uploading to %s)" % options.server)
-    password = getpass.getpass("Password for %s: " % email)
+      email = GetEmail("Email (login for uploading to %s)" % self.server)
+    password = None
+    if keyring and not email in self.accounts_seen:
+      try:
+        password = keyring.get_password(self.host, email)
+      except:
+        # Sadly, we have to trap all errors here as
+        # gnomekeyring.IOError inherits from object. :/
+        print "Failed to get password from keyring"
+        keyring = None
+    if password is not None:
+      print "Using password from system keyring."
+      self.accounts_seen.add(email)
+    else:
+      password = getpass.getpass("Password for %s: " % email)
+      if keyring:
+ answer = raw_input("Store password in system keyring?(y/N) ").strip()
+        if answer == "y":
+          keyring.set_password(self.host, email, password)
+          self.accounts_seen.add(email)
     return (email, password)

+
+def GetRpcServer(server, email=None, host_override=None, save_cookies=True,
+                 account_type=AUTH_ACCOUNT_TYPE):
+  """Returns an instance of an AbstractRpcServer.
+
+  Args:
+    server: String containing the review server URL.
+    email: String containing user's email address.
+ host_override: If not None, string containing an alternate hostname to use
+      in the host header.
+    save_cookies: Whether authentication cookies should be saved to disk.
+    account_type: Account type for authentication, either 'GOOGLE'
+      or 'HOSTED'. Defaults to AUTH_ACCOUNT_TYPE.
+
+  Returns:
+    A new HttpRpcServer, on which RPC calls can be made.
+  """
+
   # If this is the dev_appserver, use fake authentication.
-  host = (options.host or options.server).lower()
-  if host == "localhost" or host.startswith("localhost:"):
-    email = options.email
+  host = (host_override or server).lower()
+  if re.match(r'(http://)?localhost([:/]|$)', host):
     if email is None:
       email = "[email protected]"
       logging.info("Using debug user %s.  Override with --email" % email)
-    server = rpc_server_class(
-        options.server,
+    server = HttpRpcServer(
+        server,
         lambda: (email, "password"),
-        host_override=options.host,
+        host_override=host_override,
         extra_headers={"Cookie":
                        'dev_appserver_login="%s:False"' % email},
-        save_cookies=options.save_cookies)
+        save_cookies=save_cookies,
+        account_type=account_type)
     # Don't try to talk to ClientLogin.
     server.authenticated = True
     return server

-  return rpc_server_class(options.server, GetUserCredentials,
-                          host_override=options.host,
-                          save_cookies=options.save_cookies)
+  return HttpRpcServer(server,
+ KeyringCreds(server, host, email).GetUserCredentials,
+                       host_override=host_override,
+                       save_cookies=save_cookies,
+                       account_type=account_type)


 def EncodeMultipartFormData(fields, files):
@@ -530,6 +715,8 @@
     lines.append('--' + BOUNDARY)
     lines.append('Content-Disposition: form-data; name="%s"' % key)
     lines.append('')
+    if isinstance(value, unicode):
+      value = value.encode('utf-8')
     lines.append(value)
   for (key, filename, value) in files:
     lines.append('--' + BOUNDARY)
@@ -537,6 +724,8 @@
              (key, filename))
     lines.append('Content-Type: %s' % GetContentType(filename))
     lines.append('')
+    if isinstance(value, unicode):
+      value = value.encode('utf-8')
     lines.append(value)
   lines.append('--' + BOUNDARY + '--')
   lines.append('')
@@ -553,9 +742,10 @@
 # Use a shell for subcommands on Windows to get a PATH search.
 use_shell = sys.platform.startswith("win")

-def RunShellWithReturnCode(command, print_output=False,
-                           universal_newlines=True):
- """Executes a command and returns the output from stdout and the return code.
+def RunShellWithReturnCodeAndStderr(command, print_output=False,
+                           universal_newlines=True,
+                           env=os.environ):
+ """Executes a command and returns the output from stdout, stderr and the return code.

   Args:
     command: Command to execute.
@@ -564,11 +754,14 @@
     universal_newlines: Use universal_newlines flag (default: True).

   Returns:
-    Tuple (output, return code)
+    Tuple (stdout, stderr, return code)
   """
   logging.info("Running %s", command)
+  env = env.copy()
+  env['LC_MESSAGES'] = 'C'
p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - shell=use_shell, universal_newlines=universal_newlines) + shell=use_shell, universal_newlines=universal_newlines,
+                       env=env)
   if print_output:
     output_array = []
     while True:
@@ -586,13 +779,20 @@
     print >>sys.stderr, errout
   p.stdout.close()
   p.stderr.close()
-  return output, p.returncode
+  return output, errout, p.returncode

+def RunShellWithReturnCode(command, print_output=False,
+                           universal_newlines=True,
+                           env=os.environ):
+ """Executes a command and returns the output from stdout and the return code.""" + out, err, retcode = RunShellWithReturnCodeAndStderr(command, print_output,
+                           universal_newlines, env)
+  return out, retcode

 def RunShell(command, silent_ok=False, universal_newlines=True,
-             print_output=False):
+             print_output=False, env=os.environ):
   data, retcode = RunShellWithReturnCode(command, print_output,
-                                         universal_newlines)
+                                         universal_newlines, env)
   if retcode:
     ErrorExit("Got error status from %s:\n%s" % (command, data))
   if not silent_ok and not data:
@@ -611,6 +811,17 @@
     """
     self.options = options

+  def GetGUID(self):
+ """Return string to distinguish the repository from others, for example to
+    query all opened review issues for it"""
+    raise NotImplementedError(
+        "abstract method -- subclass %s must override" % self.__class__)
+
+  def PostProcessDiff(self, diff):
+ """Return the diff with any special post processing this VCS needs, e.g.
+    to include an svn-style "Index:"."""
+    return diff
+
   def GenerateDiff(self, args):
     """Return the current diff as a string.

@@ -732,12 +943,11 @@
       return False
     return mimetype.startswith("image/")

-  def IsBinary(self, filename):
-    """Returns true if the guessed mimetyped isnt't in text group."""
-    mimetype = mimetypes.guess_type(filename)[0]
-    if not mimetype:
- return False # e.g. README, "real" binaries usually have an extension
-    return not mimetype.startswith("text/")
+  def IsBinaryData(self, data):
+    """Returns true if data contains a null byte."""
+    # Derived from how Mercurial's heuristic, see
+    # http://selenic.com/hg/file/848a6658069e/mercurial/util.py#l229
+    return bool(data and "\0" in data)


 class SubversionVCS(VersionControlSystem):
@@ -756,59 +966,58 @@
     # Cache output from "svn list -r REVNO dirname".
     # Keys: dirname, Values: 2-tuple (ouput for start rev and end rev).
     self.svnls_cache = {}
-    # SVN base URL is required to fetch files deleted in an older revision.
+    # Base URL is required to fetch files deleted in an older revision.
# Result is cached to not guess it over and over again in GetBaseFile(). required = self.options.download_base or self.options.revision is not None
     self.svn_base = self._GuessBase(required)

+  def GetGUID(self):
+    return self._GetInfo("Repository UUID")
+
   def GuessBase(self, required):
     """Wrapper for _GuessBase."""
     return self.svn_base

   def _GuessBase(self, required):
-    """Returns the SVN base URL.
+    """Returns base URL for current diff.

     Args:
required: If true, exits if the url can't be guessed, otherwise None is
         returned.
     """
-    info = RunShell(["svn", "info"])
-    for line in info.splitlines():
-      words = line.split()
-      if len(words) == 2 and words[0] == "URL:":
-        url = words[1]
+    url = self._GetInfo("URL")
+    if url:
scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
-        username, netloc = urllib.splituser(netloc)
-        if username:
-          logging.info("Removed username from base URL")
-        if netloc.endswith("svn.python.org"):
-          if netloc == "svn.python.org":
-            if path.startswith("/projects/"):
-              path = path[9:]
-          elif netloc != "[email protected]":
-            ErrorExit("Unrecognized Python URL: %s" % url)
-          base = "http://svn.python.org/view/*checkout*%s/"; % path
-          logging.info("Guessed Python base = %s", base)
-        elif netloc.endswith("svn.collab.net"):
-          if path.startswith("/repos/"):
-            path = path[6:]
-          base = "http://svn.collab.net/viewvc/*checkout*%s/"; % path
-          logging.info("Guessed CollabNet base = %s", base)
+        guess = ""
+ # TODO(anatoli) - repository specific hacks should be handled by server
+        if netloc == "svn.python.org" and scheme == "svn+ssh":
+          path = "projects" + path
+          scheme = "http"
+          guess = "Python "
         elif netloc.endswith(".googlecode.com"):
-          path = path + "/"
-          base = urlparse.urlunparse(("http", netloc, path, params,
-                                      query, fragment))
-          logging.info("Guessed Google Code base = %s", base)
-        else:
-          path = path + "/"
-          base = urlparse.urlunparse((scheme, netloc, path, params,
-                                      query, fragment))
-          logging.info("Guessed base = %s", base)
+          scheme = "http"
+          guess = "Google Code "
+        path = path + "/"
+        base = urlparse.urlunparse((scheme, netloc, path, params,
+                                    query, fragment))
+        logging.info("Guessed %sbase = %s", guess, base)
         return base
     if required:
       ErrorExit("Can't find URL in output from svn info")
     return None

+  def _GetInfo(self, key):
+    """Parses 'svn info' for current dir. Returns value for key or None"""
+    for line in RunShell(["svn", "info"]).splitlines():
+      if line.startswith(key + ": "):
+        return line.split(":", 1)[1].strip()
+
+  def _EscapeFilename(self, filename):
+    """Escapes filename for SVN commands."""
+    if "@" in filename and not filename.endswith("@"):
+      filename = "%s@" % filename
+    return filename
+
   def GenerateDiff(self, args):
     cmd = ["svn", "diff"]
     if self.options.revision:
@@ -876,7 +1085,8 @@
   def GetStatus(self, filename):
     """Returns the status of a file."""
     if not self.options.revision:
-      status = RunShell(["svn", "status", "--ignore-externals", filename])
+      status = RunShell(["svn", "status", "--ignore-externals",
+                         self._EscapeFilename(filename)])
       if not status:
         ErrorExit("svn status returned no output for %s" % filename)
       status_lines = status.splitlines()
@@ -895,15 +1105,22 @@
     else:
       dirname, relfilename = os.path.split(filename)
       if dirname not in self.svnls_cache:
-        cmd = ["svn", "list", "-r", self.rev_start, dirname or "."]
-        out, returncode = RunShellWithReturnCode(cmd)
+        cmd = ["svn", "list", "-r", self.rev_start,
+               self._EscapeFilename(dirname) or "."]
+        out, err, returncode = RunShellWithReturnCodeAndStderr(cmd)
         if returncode:
-          ErrorExit("Failed to get status for %s." % filename)
-        old_files = out.splitlines()
+          # Directory might not yet exist at start revison
+ # svn: Unable to find repository location for 'abc' in revision nnn + if re.match('^svn: Unable to find repository location for .+ in revision \d+', err):
+            old_files = ()
+          else:
+            ErrorExit("Failed to get status for %s:\n%s" % (filename, err))
+        else:
+          old_files = out.splitlines()
         args = ["svn", "list"]
         if self.rev_end:
           args += ["-r", self.rev_end]
-        cmd = args + [dirname or "."]
+        cmd = args + [self._EscapeFilename(dirname) or "."]
         out, returncode = RunShellWithReturnCode(cmd)
         if returncode:
           ErrorExit("Failed to run command %s" % cmd)
@@ -929,17 +1146,18 @@
     if status[0] == "A" and status[3] != "+":
       # We'll need to upload the new content if we're adding a binary file
       # since diff's output won't contain it.
-      mimetype = RunShell(["svn", "propget", "svn:mime-type", filename],
-                          silent_ok=True)
+      mimetype = RunShell(["svn", "propget", "svn:mime-type",
+                           self._EscapeFilename(filename)], silent_ok=True)
       base_content = ""
       is_binary = bool(mimetype) and not mimetype.startswith("text/")
-      if is_binary and self.IsImage(filename):
+      if is_binary:
         new_content = self.ReadFile(filename)
     elif (status[0] in ("M", "D", "R") or
           (status[0] == "A" and status[3] == "+") or  # Copied file.
           (status[0] == " " and status[1] == "M")):  # Property change.
       args = []
       if self.options.revision:
+        # filename must not be escaped. We already add an ampersand here.
         url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
       else:
         # Don't change filename, it's needed later.
@@ -951,23 +1169,27 @@
         # File does not exist in the requested revision.
         # Reset mimetype, it contains an error message.
         mimetype = ""
+      else:
+        mimetype = mimetype.strip()
       get_base = False
-      is_binary = bool(mimetype) and not mimetype.startswith("text/")
+      # this test for binary is exactly the test prescribed by the
+      # official SVN docs at
+      # http://subversion.apache.org/faq.html#binary-files
+      is_binary = (bool(mimetype) and
+        not mimetype.startswith("text/") and
+        mimetype not in ("image/x-xbitmap", "image/x-xpixmap"))
       if status[0] == " ":
         # Empty base content just to force an upload.
         base_content = ""
       elif is_binary:
-        if self.IsImage(filename):
-          get_base = True
-          if status[0] == "M":
-            if not self.rev_end:
-              new_content = self.ReadFile(filename)
-            else:
-              url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
-              new_content = RunShell(["svn", "cat", url],
- universal_newlines=True, silent_ok=True)
-        else:
-          base_content = ""
+        get_base = True
+        if status[0] == "M":
+          if not self.rev_end:
+            new_content = self.ReadFile(filename)
+          else:
+            url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
+            new_content = RunShell(["svn", "cat", url],
+                                   universal_newlines=True, silent_ok=True)
       else:
         get_base = True

@@ -984,9 +1206,18 @@
                                   universal_newlines=universal_newlines,
                                   silent_ok=True)
         else:
-          base_content = RunShell(["svn", "cat", filename],
-                                  universal_newlines=universal_newlines,
-                                  silent_ok=True)
+          base_content, ret_code = RunShellWithReturnCode(
+            ["svn", "cat", self._EscapeFilename(filename)],
+            universal_newlines=universal_newlines)
+          if ret_code and status[0] == "R":
+            # It's a replaced file without local history (see issue208).
+            # The base file needs to be fetched from the server.
+            url = "%s/%s" % (self.svn_base, filename)
+            base_content = RunShell(["svn", "cat", url],
+                                    universal_newlines=universal_newlines,
+                                    silent_ok=True)
+          elif ret_code:
+            ErrorExit("Got error status from 'svn cat %s'" % filename)
         if not is_binary:
           args = []
           if self.rev_start:
@@ -1007,41 +1238,121 @@
 class GitVCS(VersionControlSystem):
   """Implementation of the VersionControlSystem interface for Git."""

-  NULL_HASH = "0"*40
-
   def __init__(self, options):
     super(GitVCS, self).__init__(options)
     # Map of filename -> (hash before, hash after) of base file.
-    self.base_hashes = {}
+    # Hashes for "no such file" are represented as None.
+    self.hashes = {}
+    # Map of new filename -> old filename for renames.
+    self.renames = {}

-  def GenerateDiff(self, extra_args):
- # This is more complicated than svn's GenerateDiff because we must convert - # the diff output to include an svn-style "Index:" line as well as record - # the hashes of the base files, so we can upload them along with our diff.
-    if self.options.revision:
-      extra_args = [self.options.revision] + extra_args
-    gitdiff = RunShell(["git", "diff", "--full-index"] + extra_args)
+  def GetGUID(self):
+    revlist = RunShell("git rev-list --parents HEAD".split()).splitlines()
+    # M-A: Return the 1st root hash, there could be multiple when a
+    # subtree is merged. In that case, more analysis would need to
+    # be done to figure out which HEAD is the 'most representative'.
+    for r in revlist:
+      if ' ' not in r:
+        return r
+
+  def PostProcessDiff(self, gitdiff):
+ """Converts the diff output to include an svn-style "Index:" line as well
+    as record the hashes of the files, so we can upload them along with our
+    diff."""
+    # Special used by git to indicate "no such content".
+    NULL_HASH = "0"*40
+
+    def IsFileNew(filename):
+      return filename in self.hashes and self.hashes[filename][0] is None
+
+    def AddSubversionPropertyChange(filename):
+ """Add svn's property change information into the patch if given file is
+      new file.
+
+      We use Subversion's auto-props setting to retrieve its property.
+ See http://svnbook.red-bean.com/en/1.1/ch07.html#svn-ch-7-sect-1.3.2 for
+      Subversion's [auto-props] setting.
+      """
+      if self.options.emulate_svn_auto_props and IsFileNew(filename):
+        svnprops = GetSubversionPropertyChanges(filename)
+        if svnprops:
+          svndiff.append("\n" + svnprops + "\n")
+
     svndiff = []
     filecount = 0
     filename = None
     for line in gitdiff.splitlines():
-      match = re.match(r"diff --git a/(.*) b/.*$", line)
+      match = re.match(r"diff --git a/(.*) b/(.*)$", line)
       if match:
+        # Add auto property here for previously seen file.
+        if filename is not None:
+          AddSubversionPropertyChange(filename)
         filecount += 1
-        filename = match.group(1)
+        # Intentionally use the "after" filename so we can show renames.
+        filename = match.group(2)
         svndiff.append("Index: %s\n" % filename)
+        if match.group(1) != match.group(2):
+          self.renames[match.group(2)] = match.group(1)
       else:
# The "index" line in a git diff looks like this (long hashes elided):
         #   index 82c0d44..b2cee3f 100755
         # We want to save the left hash, as that identifies the base file.
         match = re.match(r"index (\w+)\.\.(\w+)", line)
         if match:
-          self.base_hashes[filename] = (match.group(1), match.group(2))
+          before, after = (match.group(1), match.group(2))
+          if before == NULL_HASH:
+            before = None
+          if after == NULL_HASH:
+            after = None
+          self.hashes[filename] = (before, after)
       svndiff.append(line + "\n")
     if not filecount:
       ErrorExit("No valid patches found in output from git diff")
+    # Add auto property for the last seen file.
+    assert filename is not None
+    AddSubversionPropertyChange(filename)
     return "".join(svndiff)

+  def GenerateDiff(self, extra_args):
+    extra_args = extra_args[:]
+    if self.options.revision:
+      if ":" in self.options.revision:
+        extra_args = self.options.revision.split(":", 1) + extra_args
+      else:
+        extra_args = [self.options.revision] + extra_args
+
+ # --no-ext-diff is broken in some versions of Git, so try to work around + # this by overriding the environment (but there is still a problem if the
+    # git config key "diff.external" is used).
+    env = os.environ.copy()
+    if "GIT_EXTERNAL_DIFF" in env:
+      del env["GIT_EXTERNAL_DIFF"]
+ # -M/-C will not print the diff for the deleted file when a file is renamed.
+    # This is confusing because the original file will not be shown on the
+    # review when a file is renamed. So, get a diff with ONLY deletes, then
+    # append a diff (with rename detection), without deletes.
+    cmd = [
+        "git", "diff", "--no-color", "--no-ext-diff", "--full-index",
+        "--ignore-submodules",
+    ]
+    diff = RunShell(
+        cmd + ["--no-renames", "--diff-filter=D"] + extra_args,
+        env=env, silent_ok=True)
+    if self.options.git_find_copies:
+      similarity_options = ["--find-copies-harder", "-l100000",
+                            "-C%s" % self.options.git_similarity ]
+    else:
+      similarity_options = ["-M%s" % self.options.git_similarity ]
+    diff += RunShell(
***The diff for this file has been truncated for email.***

Reply via email to