Index: upload.py |
diff --git a/upload.py b/upload.py |
index 971c51b0f92fa6f2da041cc105bada6b9f96d2a4..d85fea7427b3884706a08996cebace168b6d814b 100755 |
--- a/upload.py |
+++ b/upload.py |
@@ -45,13 +45,11 @@ import urllib |
import urllib2 |
import urlparse |
-# Work-around for md5 module deprecation warning in python 2.5+: |
-try: |
- # Try to load hashlib (python 2.5+) |
+# The md5 module was deprecated in Python 2.5. |
+try: |
from hashlib import md5 |
except ImportError: |
- # If hashlib cannot be imported, load md5.new instead. |
- from md5 import new as md5 |
+ from md5 import md5 |
try: |
import readline |
@@ -68,8 +66,27 @@ verbosity = 1 |
# Max size of patch or base file. |
MAX_UPLOAD_SIZE = 900 * 1024 |
+# Constants for version control names. Used by GuessVCSName. |
+VCS_GIT = "Git" |
+VCS_MERCURIAL = "Mercurial" |
+VCS_SUBVERSION = "Subversion" |
+VCS_UNKNOWN = "Unknown" |
+ |
+# whitelist for non-binary filetypes which do not start with "text/" |
+# .mm (Objective-C) shows up as application/x-freemind on my Linux box. |
+TEXT_MIMETYPES = ['application/javascript', 'application/x-javascript', |
+ 'application/x-freemind'] |
+ |
+VCS_ABBREVIATIONS = { |
+ VCS_MERCURIAL.lower(): VCS_MERCURIAL, |
+ "hg": VCS_MERCURIAL, |
+ VCS_SUBVERSION.lower(): VCS_SUBVERSION, |
+ "svn": VCS_SUBVERSION, |
+ VCS_GIT.lower(): VCS_GIT, |
+} |
-def GetEmail(): |
+ |
+def GetEmail(prompt): |
"""Prompts the user for their email address and returns it. |
The last used email address is saved to a file and offered up as a suggestion |
@@ -78,19 +95,17 @@ def GetEmail(): |
for next time we prompt. |
""" |
- last_email_file_name = os.path.expanduser( |
- os.path.join("~", ".last_codereview_email_address")) |
+ last_email_file_name = os.path.expanduser("~/.last_codereview_email_address") |
last_email = "" |
- prompt = "Email: " |
if os.path.exists(last_email_file_name): |
try: |
last_email_file = open(last_email_file_name, "r") |
last_email = last_email_file.readline().strip("\n") |
last_email_file.close() |
- prompt = "Email [%s]: " % last_email |
+ prompt += " [%s]" % last_email |
except IOError, e: |
pass |
- email = raw_input(prompt).strip() |
+ email = raw_input(prompt + ": ").strip() |
if email: |
try: |
last_email_file = open(last_email_file_name, "w") |
@@ -193,9 +208,6 @@ class AbstractRpcServer(object): |
The authentication token returned by ClientLogin. |
""" |
account_type = "GOOGLE" |
- if self.host.endswith(".google.com"): |
- # Needed for use inside Google. |
- account_type = "HOSTED" |
req = self._CreateRequest( |
url="https://www.google.com/accounts/ClientLogin", |
data=urllib.urlencode({ |
@@ -257,8 +269,8 @@ class AbstractRpcServer(object): |
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() |
@@ -339,7 +351,7 @@ class AbstractRpcServer(object): |
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. |
@@ -374,8 +386,7 @@ class HttpRpcServer(AbstractRpcServer): |
opener.add_handler(urllib2.HTTPSHandler()) |
opener.add_handler(urllib2.HTTPErrorProcessor()) |
if self.save_cookies: |
- self.cookie_file = os.path.expanduser( |
- os.path.join("~", ".codereview_upload_cookies")) |
+ self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies") |
self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file) |
if os.path.exists(self.cookie_file): |
try: |
@@ -418,7 +429,7 @@ group.add_option("-s", "--server", action="store", dest="server", |
default="codereview.appspot.com", |
metavar="SERVER", |
help=("The server to upload to. The format is host[:port]. " |
- "Defaults to 'codereview.appspot.com'.")) |
+ "Defaults to '%default'.")) |
group.add_option("-e", "--email", action="store", dest="email", |
metavar="EMAIL", default=None, |
help="The username to use. Will prompt if omitted.") |
@@ -444,6 +455,9 @@ group.add_option("-r", "--reviewers", action="store", dest="reviewers", |
group.add_option("--cc", action="store", dest="cc", |
metavar="CC", default=None, |
help="Add CC (comma separated email addresses).") |
+group.add_option("--private", action="store_true", dest="private", |
+ default=False, |
+ 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", |
@@ -463,6 +477,10 @@ group.add_option("--rev", action="store", dest="revision", |
group.add_option("--send_mail", action="store_true", |
dest="send_mail", default=False, |
help="Send notification email to reviewers.") |
+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).")) |
def GetRpcServer(options): |
@@ -478,7 +496,7 @@ def GetRpcServer(options): |
"""Prompts the user for a username and password.""" |
email = options.email |
if email is None: |
- email = GetEmail() |
+ email = GetEmail("Email (login for uploading to %s)" % options.server) |
password = getpass.getpass("Password for %s: " % email) |
return (email, password) |
@@ -549,7 +567,8 @@ def GetContentType(filename): |
use_shell = sys.platform.startswith("win") |
def RunShellWithReturnCode(command, print_output=False, |
- universal_newlines=True): |
+ universal_newlines=True, |
+ env=os.environ): |
"""Executes a command and returns the output from stdout and the return code. |
Args: |
@@ -563,7 +582,8 @@ def RunShellWithReturnCode(command, print_output=False, |
""" |
logging.info("Running %s", command) |
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: |
@@ -585,9 +605,9 @@ def RunShellWithReturnCode(command, print_output=False, |
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: |
@@ -727,6 +747,16 @@ class VersionControlSystem(object): |
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 |
+ # special case for text files which don't start with text/ |
+ if mimetype in TEXT_MIMETYPES: |
+ return False |
+ return not mimetype.startswith("text/") |
+ |
class SubversionVCS(VersionControlSystem): |
"""Implementation of the VersionControlSystem interface for Subversion.""" |
@@ -920,7 +950,7 @@ class SubversionVCS(VersionControlSystem): |
mimetype = RunShell(["svn", "propget", "svn:mime-type", filename], |
silent_ok=True) |
base_content = "" |
- is_binary = mimetype and not mimetype.startswith("text/") |
+ is_binary = bool(mimetype) and not mimetype.startswith("text/") |
if is_binary and self.IsImage(filename): |
new_content = self.ReadFile(filename) |
elif (status[0] in ("M", "D", "R") or |
@@ -940,7 +970,7 @@ class SubversionVCS(VersionControlSystem): |
# Reset mimetype, it contains an error message. |
mimetype = "" |
get_base = False |
- is_binary = mimetype and not mimetype.startswith("text/") |
+ is_binary = bool(mimetype) and not mimetype.startswith("text/") |
if status[0] == " ": |
# Empty base content just to force an upload. |
base_content = "" |
@@ -997,33 +1027,55 @@ class GitVCS(VersionControlSystem): |
def __init__(self, options): |
super(GitVCS, self).__init__(options) |
- # Map of filename -> hash of base file. |
- self.base_hashes = {} |
+ # Map of filename -> (hash before, hash after) of base file. |
+ # 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. |
+ # 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 |
+ |
+ extra_args = extra_args[:] |
if self.options.revision: |
extra_args = [self.options.revision] + extra_args |
- gitdiff = RunShell(["git", "diff", "--no-ext-diff", "--full-index"] + |
- 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'] |
+ gitdiff = RunShell(["git", "diff", "--no-ext-diff", "--full-index", "-M"] |
+ + extra_args, env=env) |
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: |
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+)\.\.", line) |
+ match = re.match(r"index (\w+)\.\.(\w+)", line) |
if match: |
- self.base_hashes[filename] = match.group(1) |
+ 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") |
@@ -1034,17 +1086,47 @@ class GitVCS(VersionControlSystem): |
silent_ok=True) |
return status.splitlines() |
+ def GetFileContent(self, file_hash, is_binary): |
+ """Returns the content of a file identified by its git hash.""" |
+ data, retcode = RunShellWithReturnCode(["git", "show", file_hash], |
+ universal_newlines=not is_binary) |
+ if retcode: |
+ ErrorExit("Got error status from 'git show %s'" % file_hash) |
+ return data |
+ |
def GetBaseFile(self, filename): |
- hash = self.base_hashes[filename] |
+ hash_before, hash_after = self.hashes.get(filename, (None,None)) |
base_content = None |
new_content = None |
- is_binary = False |
- if hash == "0" * 40: # All-zero hash indicates no base file. |
+ is_binary = self.IsBinary(filename) |
+ status = None |
+ |
+ if filename in self.renames: |
+ status = "A +" # Match svn attribute name for renames. |
+ if filename not in self.hashes: |
+ # If a rename doesn't change the content, we never get a hash. |
+ base_content = RunShell(["git", "show", filename]) |
+ elif not hash_before: |
status = "A" |
base_content = "" |
+ elif not hash_after: |
+ status = "D" |
else: |
status = "M" |
- base_content = RunShell(["git", "show", hash]) |
+ |
+ is_image = self.IsImage(filename) |
+ |
+ # Grab the before/after content if we need it. |
+ # We should include file contents if it's text or it's an image. |
+ if not is_binary or is_image: |
+ # Grab the base content if we don't have it already. |
+ if base_content is None and hash_before: |
+ base_content = self.GetFileContent(hash_before, is_binary) |
+ # Only include the "after" file if it's an image; otherwise it |
+ # it is reconstructed from the diff. |
+ if is_image and hash_after: |
+ new_content = self.GetFileContent(hash_after, is_binary) |
+ |
return (base_content, new_content, is_binary, status) |
@@ -1067,7 +1149,7 @@ class MercurialVCS(VersionControlSystem): |
def _GetRelPath(self, filename): |
"""Get relative path of a file according to the current directory, |
given its logical path in the repo.""" |
- assert filename.startswith(self.subdir), filename |
+ assert filename.startswith(self.subdir), (filename, self.subdir) |
return filename[len(self.subdir):].lstrip(r"\/") |
def GenerateDiff(self, extra_args): |
@@ -1130,8 +1212,12 @@ class MercurialVCS(VersionControlSystem): |
status = "M" |
else: |
status, _ = out[0].split(' ', 1) |
+ if ":" in self.base_rev: |
+ base_rev = self.base_rev.split(":", 1)[0] |
+ else: |
+ base_rev = self.base_rev |
if status != "A": |
- base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath], |
+ base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], |
silent_ok=True) |
is_binary = "\0" in base_content # Mercurial's heuristic |
if status != "R": |
@@ -1139,7 +1225,7 @@ class MercurialVCS(VersionControlSystem): |
is_binary = is_binary or "\0" in new_content |
if is_binary and base_content: |
# Fetch again without converting newlines |
- base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath], |
+ base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], |
silent_ok=True, universal_newlines=False) |
if not is_binary or not self.IsImage(relpath): |
new_content = None |
@@ -1215,15 +1301,17 @@ def UploadSeparatePatches(issue, rpc_server, patchset, data, options): |
return rv |
-def GuessVCS(options): |
+def GuessVCSName(): |
"""Helper to guess the version control system. |
This examines the current directory, guesses which VersionControlSystem |
- we're using, and returns an instance of the appropriate class. Exit with an |
- error if we can't figure it out. |
+ we're using, and returns an string indicating which VCS is detected. |
Returns: |
- A VersionControlSystem instance. Exits if the VCS can't be guessed. |
+ A pair (vcs, output). vcs is a string indicating which VCS was detected |
+ and is one of VCS_GIT, VCS_MERCURIAL, VCS_SUBVERSION, or VCS_UNKNOWN. |
+ output is a string containing any interesting output from the vcs |
+ detection routine, or None if there is nothing interesting. |
""" |
# Mercurial has a command to get the base directory of a repository |
# Try running it, but don't die if we don't have hg installed. |
@@ -1231,7 +1319,7 @@ def GuessVCS(options): |
try: |
out, returncode = RunShellWithReturnCode(["hg", "root"]) |
if returncode == 0: |
- return MercurialVCS(options, out.strip()) |
+ return (VCS_MERCURIAL, out.strip()) |
except OSError, (errno, message): |
if errno != 2: # ENOENT -- they don't have hg installed. |
raise |
@@ -1239,7 +1327,7 @@ def GuessVCS(options): |
# Subversion has a .svn in all working directories. |
if os.path.isdir('.svn'): |
logging.info("Guessed VCS = Subversion") |
- return SubversionVCS(options) |
+ return (VCS_SUBVERSION, None) |
# Git has a command to test if you're in a git tree. |
# Try running it, but don't die if we don't have git installed. |
@@ -1247,16 +1335,81 @@ def GuessVCS(options): |
out, returncode = RunShellWithReturnCode(["git", "rev-parse", |
"--is-inside-work-tree"]) |
if returncode == 0: |
- return GitVCS(options) |
+ return (VCS_GIT, None) |
except OSError, (errno, message): |
if errno != 2: # ENOENT -- they don't have git installed. |
raise |
+ return (VCS_UNKNOWN, None) |
+ |
+ |
+def GuessVCS(options): |
+ """Helper to guess the version control system. |
+ |
+ This verifies any user-specified VersionControlSystem (by command line |
+ or environment variable). If the user didn't specify one, this examines |
+ the current directory, guesses which VersionControlSystem we're using, |
+ and returns an instance of the appropriate class. Exit with an error |
+ if we can't figure it out. |
+ |
+ Returns: |
+ A VersionControlSystem instance. Exits if the VCS can't be guessed. |
+ """ |
+ vcs = options.vcs |
+ if not vcs: |
+ vcs = os.environ.get("CODEREVIEW_VCS") |
+ if vcs: |
+ v = VCS_ABBREVIATIONS.get(vcs.lower()) |
+ if v is None: |
+ ErrorExit("Unknown version control system %r specified." % vcs) |
+ (vcs, extra_output) = (v, None) |
+ else: |
+ (vcs, extra_output) = GuessVCSName() |
+ |
+ if vcs == VCS_MERCURIAL: |
+ if extra_output is None: |
+ extra_output = RunShell(["hg", "root"]).strip() |
+ return MercurialVCS(options, extra_output) |
+ elif vcs == VCS_SUBVERSION: |
+ return SubversionVCS(options) |
+ elif vcs == VCS_GIT: |
+ return GitVCS(options) |
+ |
ErrorExit(("Could not guess version control system. " |
"Are you in a working copy directory?")) |
+def CheckReviewer(reviewer): |
+ """Validate a reviewer -- either a nickname or an email addres. |
+ |
+ Args: |
+ reviewer: A nickname or an email address. |
+ |
+ Calls ErrorExit() if it is an invalid email address. |
+ """ |
+ if "@" not in reviewer: |
+ return # Assume nickname |
+ parts = reviewer.split("@") |
+ if len(parts) > 2: |
+ ErrorExit("Invalid email address: %r" % reviewer) |
+ assert len(parts) == 2 |
+ if "." not in parts[1]: |
+ ErrorExit("Invalid email address: %r" % reviewer) |
+ |
+ |
def RealMain(argv, data=None): |
+ """The real main function. |
+ |
+ Args: |
+ argv: Command line arguments. |
+ data: Diff contents. If None (default) the diff is generated by |
+ the VersionControlSystem implementation returned by GuessVCS(). |
+ |
+ Returns: |
+ A 2-tuple (issue id, patchset id). |
+ The patchset id is None if the base files are not uploaded by this |
+ script (applies only to SVN checkouts). |
+ """ |
logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:" |
"%(lineno)s %(message)s ")) |
os.environ['LC_ALL'] = 'C' |
@@ -1301,13 +1454,11 @@ def RealMain(argv, data=None): |
form_fields.append(("user", options.email)) |
if options.reviewers: |
for reviewer in options.reviewers.split(','): |
- if "@" in reviewer and not reviewer.split("@")[1].count(".") == 1: |
- ErrorExit("Invalid email address: %s" % reviewer) |
+ CheckReviewer(reviewer) |
form_fields.append(("reviewers", options.reviewers)) |
if options.cc: |
for cc in options.cc.split(','): |
- if "@" in cc and not cc.split("@")[1].count(".") == 1: |
- ErrorExit("Invalid email address: %s" % cc) |
+ CheckReviewer(cc) |
form_fields.append(("cc", options.cc)) |
description = options.description |
if options.description_file: |
@@ -1328,6 +1479,11 @@ def RealMain(argv, data=None): |
base_hashes += "|" |
base_hashes += checksum + ":" + file |
form_fields.append(("base_hashes", base_hashes)) |
+ if options.private: |
+ if options.issue: |
+ print "Warning: Private flag ignored when updating an existing issue." |
+ else: |
+ form_fields.append(("private", "1")) |
# If we're uploading base files, don't send the email before the uploads, so |
# that it contains the file status. |
if options.send_mail and options.download_base: |
@@ -1342,18 +1498,13 @@ def RealMain(argv, data=None): |
uploaded_diff_file = [("data", "data.diff", data)] |
ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file) |
response_body = rpc_server.Send("/upload", body, content_type=ctype) |
+ patchset = None |
if not options.download_base or not uploaded_diff_file: |
lines = response_body.splitlines() |
if len(lines) >= 2: |
msg = lines[0] |
patchset = lines[1].strip() |
patches = [x.split(" ", 1) for x in lines[2:]] |
- for patch_pair in patches: |
- # On Windows if a file has property changes its filename uses '\' |
- # instead of '/'. Perhaps this change should be made (also) on the |
- # server when it is decoding the patch file sent by the client, but |
- # we do it here as well to be safe. |
- patch_pair[1] = patch_pair[1].replace('\\', '/') |
else: |
msg = response_body |
else: |