File indexing completed on 2025-08-06 08:19:54
0001
0002
0003
0004
0005
0006
0007
0008
0009
0010
0011
0012
0013
0014
0015
0016
0017 """Tool for uploading diffs from a version control system to the codereview app.
0018
0019 Usage summary: upload.py [options] [-- diff_options]
0020
0021 Diff options are passed to the diff command of the underlying system.
0022
0023 Supported version control systems:
0024 Git
0025 Mercurial
0026 Subversion
0027
0028 It is important for Git/Mercurial users to specify a tree/node/branch to diff
0029 against by using the '--rev' option.
0030 """
0031
0032
0033
0034 import cookielib
0035 import getpass
0036 import logging
0037 import md5
0038 import mimetypes
0039 import optparse
0040 import os
0041 import re
0042 import socket
0043 import subprocess
0044 import sys
0045 import urllib
0046 import urllib2
0047 import urlparse
0048
0049 try:
0050 import readline
0051 except ImportError:
0052 pass
0053
0054
0055
0056
0057
0058
0059 verbosity = 1
0060
0061
0062 MAX_UPLOAD_SIZE = 900 * 1024
0063
0064
0065 def GetEmail(prompt):
0066 """Prompts the user for their email address and returns it.
0067
0068 The last used email address is saved to a file and offered up as a suggestion
0069 to the user. If the user presses enter without typing in anything the last
0070 used email address is used. If the user enters a new address, it is saved
0071 for next time we prompt.
0072
0073 """
0074 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
0075 last_email = ""
0076 if os.path.exists(last_email_file_name):
0077 try:
0078 last_email_file = open(last_email_file_name, "r")
0079 last_email = last_email_file.readline().strip("\n")
0080 last_email_file.close()
0081 prompt += " [%s]" % last_email
0082 except IOError, e:
0083 pass
0084 email = raw_input(prompt + ": ").strip()
0085 if email:
0086 try:
0087 last_email_file = open(last_email_file_name, "w")
0088 last_email_file.write(email)
0089 last_email_file.close()
0090 except IOError, e:
0091 pass
0092 else:
0093 email = last_email
0094 return email
0095
0096
0097 def StatusUpdate(msg):
0098 """Print a status message to stdout.
0099
0100 If 'verbosity' is greater than 0, print the message.
0101
0102 Args:
0103 msg: The string to print.
0104 """
0105 if verbosity > 0:
0106 print msg
0107
0108
0109 def ErrorExit(msg):
0110 """Print an error message to stderr and exit."""
0111 print >>sys.stderr, msg
0112 sys.exit(1)
0113
0114
0115 class ClientLoginError(urllib2.HTTPError):
0116 """Raised to indicate there was an error authenticating with ClientLogin."""
0117
0118 def __init__(self, url, code, msg, headers, args):
0119 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
0120 self.args = args
0121 self.reason = args["Error"]
0122
0123
0124 class AbstractRpcServer(object):
0125 """Provides a common interface for a simple RPC server."""
0126
0127 def __init__(self, host, auth_function, host_override=None, extra_headers={},
0128 save_cookies=False):
0129 """Creates a new HttpRpcServer.
0130
0131 Args:
0132 host: The host to send requests to.
0133 auth_function: A function that takes no arguments and returns an
0134 (email, password) tuple when called. Will be called if authentication
0135 is required.
0136 host_override: The host header to send to the server (defaults to host).
0137 extra_headers: A dict of extra headers to append to every request.
0138 save_cookies: If True, save the authentication cookies to local disk.
0139 If False, use an in-memory cookiejar instead. Subclasses must
0140 implement this functionality. Defaults to False.
0141 """
0142 self.host = host
0143 self.host_override = host_override
0144 self.auth_function = auth_function
0145 self.authenticated = False
0146 self.extra_headers = extra_headers
0147 self.save_cookies = save_cookies
0148 self.opener = self._GetOpener()
0149 if self.host_override:
0150 logging.info("Server: %s; Host: %s", self.host, self.host_override)
0151 else:
0152 logging.info("Server: %s", self.host)
0153
0154 def _GetOpener(self):
0155 """Returns an OpenerDirector for making HTTP requests.
0156
0157 Returns:
0158 A urllib2.OpenerDirector object.
0159 """
0160 raise NotImplementedError()
0161
0162 def _CreateRequest(self, url, data=None):
0163 """Creates a new urllib request."""
0164 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
0165 req = urllib2.Request(url, data=data)
0166 if self.host_override:
0167 req.add_header("Host", self.host_override)
0168 for key, value in self.extra_headers.iteritems():
0169 req.add_header(key, value)
0170 return req
0171
0172 def _GetAuthToken(self, email, password):
0173 """Uses ClientLogin to authenticate the user, returning an auth token.
0174
0175 Args:
0176 email: The user's email address
0177 password: The user's password
0178
0179 Raises:
0180 ClientLoginError: If there was an error authenticating with ClientLogin.
0181 HTTPError: If there was some other form of HTTP error.
0182
0183 Returns:
0184 The authentication token returned by ClientLogin.
0185 """
0186 account_type = "GOOGLE"
0187 if self.host.endswith(".google.com"):
0188
0189 account_type = "HOSTED"
0190 req = self._CreateRequest(
0191 url="https://www.google.com/accounts/ClientLogin",
0192 data=urllib.urlencode({
0193 "Email": email,
0194 "Passwd": password,
0195 "service": "ah",
0196 "source": "rietveld-codereview-upload",
0197 "accountType": account_type,
0198 }),
0199 )
0200 try:
0201 response = self.opener.open(req)
0202 response_body = response.read()
0203 response_dict = dict(x.split("=")
0204 for x in response_body.split("\n") if x)
0205 return response_dict["Auth"]
0206 except urllib2.HTTPError, e:
0207 if e.code == 403:
0208 body = e.read()
0209 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
0210 raise ClientLoginError(req.get_full_url(), e.code, e.msg,
0211 e.headers, response_dict)
0212 else:
0213 raise
0214
0215 def _GetAuthCookie(self, auth_token):
0216 """Fetches authentication cookies for an authentication token.
0217
0218 Args:
0219 auth_token: The authentication token returned by ClientLogin.
0220
0221 Raises:
0222 HTTPError: If there was an error fetching the authentication cookies.
0223 """
0224
0225 continue_location = "http://localhost/"
0226 args = {"continue": continue_location, "auth": auth_token}
0227 req = self._CreateRequest("http://%s/_ah/login?%s" %
0228 (self.host, urllib.urlencode(args)))
0229 try:
0230 response = self.opener.open(req)
0231 except urllib2.HTTPError, e:
0232 response = e
0233 if (response.code != 302 or
0234 response.info()["location"] != continue_location):
0235 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg,
0236 response.headers, response.fp)
0237 self.authenticated = True
0238
0239 def _Authenticate(self):
0240 """Authenticates the user.
0241
0242 The authentication process works as follows:
0243 1) We get a username and password from the user
0244 2) We use ClientLogin to obtain an AUTH token for the user
0245 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
0246 3) We pass the auth token to /_ah/login on the server to obtain an
0247 authentication cookie. If login was successful, it tries to redirect
0248 us to the URL we provided.
0249
0250 If we attempt to access the upload API without first obtaining an
0251 authentication cookie, it returns a 401 response and directs us to
0252 authenticate ourselves with ClientLogin.
0253 """
0254 for i in range(3):
0255 credentials = self.auth_function()
0256 try:
0257 auth_token = self._GetAuthToken(credentials[0], credentials[1])
0258 except ClientLoginError, e:
0259 if e.reason == "BadAuthentication":
0260 print >>sys.stderr, "Invalid username or password."
0261 continue
0262 if e.reason == "CaptchaRequired":
0263 print >>sys.stderr, (
0264 "Please go to\n"
0265 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
0266 "and verify you are a human. Then try again.")
0267 break
0268 if e.reason == "NotVerified":
0269 print >>sys.stderr, "Account not verified."
0270 break
0271 if e.reason == "TermsNotAgreed":
0272 print >>sys.stderr, "User has not agreed to TOS."
0273 break
0274 if e.reason == "AccountDeleted":
0275 print >>sys.stderr, "The user account has been deleted."
0276 break
0277 if e.reason == "AccountDisabled":
0278 print >>sys.stderr, "The user account has been disabled."
0279 break
0280 if e.reason == "ServiceDisabled":
0281 print >>sys.stderr, ("The user's access to the service has been "
0282 "disabled.")
0283 break
0284 if e.reason == "ServiceUnavailable":
0285 print >>sys.stderr, "The service is not available; try again later."
0286 break
0287 raise
0288 self._GetAuthCookie(auth_token)
0289 return
0290
0291 def Send(self, request_path, payload=None,
0292 content_type="application/octet-stream",
0293 timeout=None,
0294 **kwargs):
0295 """Sends an RPC and returns the response.
0296
0297 Args:
0298 request_path: The path to send the request to, eg /api/appversion/create.
0299 payload: The body of the request, or None to send an empty request.
0300 content_type: The Content-Type header to use.
0301 timeout: timeout in seconds; default None i.e. no timeout.
0302 (Note: for large requests on OS X, the timeout doesn't work right.)
0303 kwargs: Any keyword arguments are converted into query string parameters.
0304
0305 Returns:
0306 The response body, as a string.
0307 """
0308
0309
0310 if not self.authenticated:
0311 self._Authenticate()
0312
0313 old_timeout = socket.getdefaulttimeout()
0314 socket.setdefaulttimeout(timeout)
0315 try:
0316 tries = 0
0317 while True:
0318 tries += 1
0319 args = dict(kwargs)
0320 url = "http://%s%s" % (self.host, request_path)
0321 if args:
0322 url += "?" + urllib.urlencode(args)
0323 req = self._CreateRequest(url=url, data=payload)
0324 req.add_header("Content-Type", content_type)
0325 try:
0326 f = self.opener.open(req)
0327 response = f.read()
0328 f.close()
0329 return response
0330 except urllib2.HTTPError, e:
0331 if tries > 3:
0332 raise
0333 elif e.code == 401:
0334 self._Authenticate()
0335
0336
0337
0338 else:
0339 raise
0340 finally:
0341 socket.setdefaulttimeout(old_timeout)
0342
0343
0344 class HttpRpcServer(AbstractRpcServer):
0345 """Provides a simplified RPC-style interface for HTTP requests."""
0346
0347 def _Authenticate(self):
0348 """Save the cookie jar after authentication."""
0349 super(HttpRpcServer, self)._Authenticate()
0350 if self.save_cookies:
0351 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
0352 self.cookie_jar.save()
0353
0354 def _GetOpener(self):
0355 """Returns an OpenerDirector that supports cookies and ignores redirects.
0356
0357 Returns:
0358 A urllib2.OpenerDirector object.
0359 """
0360 opener = urllib2.OpenerDirector()
0361 opener.add_handler(urllib2.ProxyHandler())
0362 opener.add_handler(urllib2.UnknownHandler())
0363 opener.add_handler(urllib2.HTTPHandler())
0364 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
0365 opener.add_handler(urllib2.HTTPSHandler())
0366 opener.add_handler(urllib2.HTTPErrorProcessor())
0367 if self.save_cookies:
0368 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
0369 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
0370 if os.path.exists(self.cookie_file):
0371 try:
0372 self.cookie_jar.load()
0373 self.authenticated = True
0374 StatusUpdate("Loaded authentication cookies from %s" %
0375 self.cookie_file)
0376 except (cookielib.LoadError, IOError):
0377
0378 pass
0379 else:
0380
0381 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
0382 os.close(fd)
0383
0384 os.chmod(self.cookie_file, 0600)
0385 else:
0386
0387 self.cookie_jar = cookielib.CookieJar()
0388 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
0389 return opener
0390
0391
0392 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]")
0393 parser.add_option("-y", "--assume_yes", action="store_true",
0394 dest="assume_yes", default=False,
0395 help="Assume that the answer to yes/no questions is 'yes'.")
0396
0397 group = parser.add_option_group("Logging options")
0398 group.add_option("-q", "--quiet", action="store_const", const=0,
0399 dest="verbose", help="Print errors only.")
0400 group.add_option("-v", "--verbose", action="store_const", const=2,
0401 dest="verbose", default=1,
0402 help="Print info level logs (default).")
0403 group.add_option("--noisy", action="store_const", const=3,
0404 dest="verbose", help="Print all logs.")
0405
0406 group = parser.add_option_group("Review server options")
0407 group.add_option("-s", "--server", action="store", dest="server",
0408 default="codereview.appspot.com",
0409 metavar="SERVER",
0410 help=("The server to upload to. The format is host[:port]. "
0411 "Defaults to 'codereview.appspot.com'."))
0412 group.add_option("-e", "--email", action="store", dest="email",
0413 metavar="EMAIL", default=None,
0414 help="The username to use. Will prompt if omitted.")
0415 group.add_option("-H", "--host", action="store", dest="host",
0416 metavar="HOST", default=None,
0417 help="Overrides the Host header sent with all RPCs.")
0418 group.add_option("--no_cookies", action="store_false",
0419 dest="save_cookies", default=True,
0420 help="Do not save authentication cookies to local disk.")
0421
0422 group = parser.add_option_group("Issue options")
0423 group.add_option("-d", "--description", action="store", dest="description",
0424 metavar="DESCRIPTION", default=None,
0425 help="Optional description when creating an issue.")
0426 group.add_option("-f", "--description_file", action="store",
0427 dest="description_file", metavar="DESCRIPTION_FILE",
0428 default=None,
0429 help="Optional path of a file that contains "
0430 "the description when creating an issue.")
0431 group.add_option("-r", "--reviewers", action="store", dest="reviewers",
0432 metavar="REVIEWERS", default=None,
0433 help="Add reviewers (comma separated email addresses).")
0434 group.add_option("--cc", action="store", dest="cc",
0435 metavar="CC", default=None,
0436 help="Add CC (comma separated email addresses).")
0437
0438 group = parser.add_option_group("Patch options")
0439 group.add_option("-m", "--message", action="store", dest="message",
0440 metavar="MESSAGE", default=None,
0441 help="A message to identify the patch. "
0442 "Will prompt if omitted.")
0443 group.add_option("-i", "--issue", type="int", action="store",
0444 metavar="ISSUE", default=None,
0445 help="Issue number to which to add. Defaults to new issue.")
0446 group.add_option("--download_base", action="store_true",
0447 dest="download_base", default=False,
0448 help="Base files will be downloaded by the server "
0449 "(side-by-side diffs may not work on files with CRs).")
0450 group.add_option("--rev", action="store", dest="revision",
0451 metavar="REV", default=None,
0452 help="Branch/tree/revision to diff against (used by DVCS).")
0453 group.add_option("--send_mail", action="store_true",
0454 dest="send_mail", default=False,
0455 help="Send notification email to reviewers.")
0456
0457
0458 def GetRpcServer(options):
0459 """Returns an instance of an AbstractRpcServer.
0460
0461 Returns:
0462 A new AbstractRpcServer, on which RPC calls can be made.
0463 """
0464
0465 rpc_server_class = HttpRpcServer
0466
0467 def GetUserCredentials():
0468 """Prompts the user for a username and password."""
0469 email = options.email
0470 if email is None:
0471 email = GetEmail("Email (login for uploading to %s)" % options.server)
0472 password = getpass.getpass("Password for %s: " % email)
0473 return (email, password)
0474
0475
0476 host = (options.host or options.server).lower()
0477 if host == "localhost" or host.startswith("localhost:"):
0478 email = options.email
0479 if email is None:
0480 email = "test@example.com"
0481 logging.info("Using debug user %s. Override with --email" % email)
0482 server = rpc_server_class(
0483 options.server,
0484 lambda: (email, "password"),
0485 host_override=options.host,
0486 extra_headers={"Cookie":
0487 'dev_appserver_login="%s:False"' % email},
0488 save_cookies=options.save_cookies)
0489
0490 server.authenticated = True
0491 return server
0492
0493 return rpc_server_class(options.server, GetUserCredentials,
0494 host_override=options.host,
0495 save_cookies=options.save_cookies)
0496
0497
0498 def EncodeMultipartFormData(fields, files):
0499 """Encode form fields for multipart/form-data.
0500
0501 Args:
0502 fields: A sequence of (name, value) elements for regular form fields.
0503 files: A sequence of (name, filename, value) elements for data to be
0504 uploaded as files.
0505 Returns:
0506 (content_type, body) ready for httplib.HTTP instance.
0507
0508 Source:
0509 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
0510 """
0511 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
0512 CRLF = '\r\n'
0513 lines = []
0514 for (key, value) in fields:
0515 lines.append('--' + BOUNDARY)
0516 lines.append('Content-Disposition: form-data; name="%s"' % key)
0517 lines.append('')
0518 lines.append(value)
0519 for (key, filename, value) in files:
0520 lines.append('--' + BOUNDARY)
0521 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
0522 (key, filename))
0523 lines.append('Content-Type: %s' % GetContentType(filename))
0524 lines.append('')
0525 lines.append(value)
0526 lines.append('--' + BOUNDARY + '--')
0527 lines.append('')
0528 body = CRLF.join(lines)
0529 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
0530 return content_type, body
0531
0532
0533 def GetContentType(filename):
0534 """Helper to guess the content-type from the filename."""
0535 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
0536
0537
0538
0539 use_shell = sys.platform.startswith("win")
0540
0541 def RunShellWithReturnCode(command, print_output=False,
0542 universal_newlines=True):
0543 """Executes a command and returns the output from stdout and the return code.
0544
0545 Args:
0546 command: Command to execute.
0547 print_output: If True, the output is printed to stdout.
0548 If False, both stdout and stderr are ignored.
0549 universal_newlines: Use universal_newlines flag (default: True).
0550
0551 Returns:
0552 Tuple (output, return code)
0553 """
0554 logging.info("Running %s", command)
0555 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
0556 shell=use_shell, universal_newlines=universal_newlines)
0557 if print_output:
0558 output_array = []
0559 while True:
0560 line = p.stdout.readline()
0561 if not line:
0562 break
0563 print line.strip("\n")
0564 output_array.append(line)
0565 output = "".join(output_array)
0566 else:
0567 output = p.stdout.read()
0568 p.wait()
0569 errout = p.stderr.read()
0570 if print_output and errout:
0571 print >>sys.stderr, errout
0572 p.stdout.close()
0573 p.stderr.close()
0574 return output, p.returncode
0575
0576
0577 def RunShell(command, silent_ok=False, universal_newlines=True,
0578 print_output=False):
0579 data, retcode = RunShellWithReturnCode(command, print_output,
0580 universal_newlines)
0581 if retcode:
0582 ErrorExit("Got error status from %s:\n%s" % (command, data))
0583 if not silent_ok and not data:
0584 ErrorExit("No output from %s" % command)
0585 return data
0586
0587
0588 class VersionControlSystem(object):
0589 """Abstract base class providing an interface to the VCS."""
0590
0591 def __init__(self, options):
0592 """Constructor.
0593
0594 Args:
0595 options: Command line options.
0596 """
0597 self.options = options
0598
0599 def GenerateDiff(self, args):
0600 """Return the current diff as a string.
0601
0602 Args:
0603 args: Extra arguments to pass to the diff command.
0604 """
0605 raise NotImplementedError(
0606 "abstract method -- subclass %s must override" % self.__class__)
0607
0608 def GetUnknownFiles(self):
0609 """Return a list of files unknown to the VCS."""
0610 raise NotImplementedError(
0611 "abstract method -- subclass %s must override" % self.__class__)
0612
0613 def CheckForUnknownFiles(self):
0614 """Show an "are you sure?" prompt if there are unknown files."""
0615 unknown_files = self.GetUnknownFiles()
0616 if unknown_files:
0617 print "The following files are not added to version control:"
0618 for line in unknown_files:
0619 print line
0620 prompt = "Are you sure to continue?(y/N) "
0621 answer = raw_input(prompt).strip()
0622 if answer != "y":
0623 ErrorExit("User aborted")
0624
0625 def GetBaseFile(self, filename):
0626 """Get the content of the upstream version of a file.
0627
0628 Returns:
0629 A tuple (base_content, new_content, is_binary, status)
0630 base_content: The contents of the base file.
0631 new_content: For text files, this is empty. For binary files, this is
0632 the contents of the new file, since the diff output won't contain
0633 information to reconstruct the current file.
0634 is_binary: True iff the file is binary.
0635 status: The status of the file.
0636 """
0637
0638 raise NotImplementedError(
0639 "abstract method -- subclass %s must override" % self.__class__)
0640
0641
0642 def GetBaseFiles(self, diff):
0643 """Helper that calls GetBase file for each file in the patch.
0644
0645 Returns:
0646 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
0647 are retrieved based on lines that start with "Index:" or
0648 "Property changes on:".
0649 """
0650 files = {}
0651 for line in diff.splitlines(True):
0652 if line.startswith('Index:') or line.startswith('Property changes on:'):
0653 unused, filename = line.split(':', 1)
0654
0655
0656 filename = filename.strip().replace('\\', '/')
0657 files[filename] = self.GetBaseFile(filename)
0658 return files
0659
0660
0661 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
0662 files):
0663 """Uploads the base files (and if necessary, the current ones as well)."""
0664
0665 def UploadFile(filename, file_id, content, is_binary, status, is_base):
0666 """Uploads a file to the server."""
0667 file_too_large = False
0668 if is_base:
0669 type = "base"
0670 else:
0671 type = "current"
0672 if len(content) > MAX_UPLOAD_SIZE:
0673 print ("Not uploading the %s file for %s because it's too large." %
0674 (type, filename))
0675 file_too_large = True
0676 content = ""
0677 checksum = md5.new(content).hexdigest()
0678 if options.verbose > 0 and not file_too_large:
0679 print "Uploading %s file for %s" % (type, filename)
0680 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
0681 form_fields = [("filename", filename),
0682 ("status", status),
0683 ("checksum", checksum),
0684 ("is_binary", str(is_binary)),
0685 ("is_current", str(not is_base)),
0686 ]
0687 if file_too_large:
0688 form_fields.append(("file_too_large", "1"))
0689 if options.email:
0690 form_fields.append(("user", options.email))
0691 ctype, body = EncodeMultipartFormData(form_fields,
0692 [("data", filename, content)])
0693 response_body = rpc_server.Send(url, body,
0694 content_type=ctype)
0695 if not response_body.startswith("OK"):
0696 StatusUpdate(" --> %s" % response_body)
0697 sys.exit(1)
0698
0699 patches = dict()
0700 [patches.setdefault(v, k) for k, v in patch_list]
0701 for filename in patches.keys():
0702 base_content, new_content, is_binary, status = files[filename]
0703 file_id_str = patches.get(filename)
0704 if file_id_str.find("nobase") != -1:
0705 base_content = None
0706 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
0707 file_id = int(file_id_str)
0708 if base_content != None:
0709 UploadFile(filename, file_id, base_content, is_binary, status, True)
0710 if new_content != None:
0711 UploadFile(filename, file_id, new_content, is_binary, status, False)
0712
0713 def IsImage(self, filename):
0714 """Returns true if the filename has an image extension."""
0715 mimetype = mimetypes.guess_type(filename)[0]
0716 if not mimetype:
0717 return False
0718 return mimetype.startswith("image/")
0719
0720
0721 class SubversionVCS(VersionControlSystem):
0722 """Implementation of the VersionControlSystem interface for Subversion."""
0723
0724 def __init__(self, options):
0725 super(SubversionVCS, self).__init__(options)
0726 if self.options.revision:
0727 match = re.match(r"(\d+)(:(\d+))?", self.options.revision)
0728 if not match:
0729 ErrorExit("Invalid Subversion revision %s." % self.options.revision)
0730 self.rev_start = match.group(1)
0731 self.rev_end = match.group(3)
0732 else:
0733 self.rev_start = self.rev_end = None
0734
0735
0736 self.svnls_cache = {}
0737
0738
0739 required = self.options.download_base or self.options.revision is not None
0740 self.svn_base = self._GuessBase(required)
0741
0742 def GuessBase(self, required):
0743 """Wrapper for _GuessBase."""
0744 return self.svn_base
0745
0746 def _GuessBase(self, required):
0747 """Returns the SVN base URL.
0748
0749 Args:
0750 required: If true, exits if the url can't be guessed, otherwise None is
0751 returned.
0752 """
0753 info = RunShell(["svn", "info"])
0754 for line in info.splitlines():
0755 words = line.split()
0756 if len(words) == 2 and words[0] == "URL:":
0757 url = words[1]
0758 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
0759 username, netloc = urllib.splituser(netloc)
0760 if username:
0761 logging.info("Removed username from base URL")
0762 if netloc.endswith("svn.python.org"):
0763 if netloc == "svn.python.org":
0764 if path.startswith("/projects/"):
0765 path = path[9:]
0766 elif netloc != "pythondev@svn.python.org":
0767 ErrorExit("Unrecognized Python URL: %s" % url)
0768 base = "http://svn.python.org/view/*checkout*%s/" % path
0769 logging.info("Guessed Python base = %s", base)
0770 elif netloc.endswith("svn.collab.net"):
0771 if path.startswith("/repos/"):
0772 path = path[6:]
0773 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path
0774 logging.info("Guessed CollabNet base = %s", base)
0775 elif netloc.endswith(".googlecode.com"):
0776 path = path + "/"
0777 base = urlparse.urlunparse(("http", netloc, path, params,
0778 query, fragment))
0779 logging.info("Guessed Google Code base = %s", base)
0780 else:
0781 path = path + "/"
0782 base = urlparse.urlunparse((scheme, netloc, path, params,
0783 query, fragment))
0784 logging.info("Guessed base = %s", base)
0785 return base
0786 if required:
0787 ErrorExit("Can't find URL in output from svn info")
0788 return None
0789
0790 def GenerateDiff(self, args):
0791 cmd = ["svn", "diff"]
0792 if self.options.revision:
0793 cmd += ["-r", self.options.revision]
0794 cmd.extend(args)
0795 data = RunShell(cmd)
0796 count = 0
0797 for line in data.splitlines():
0798 if line.startswith("Index:") or line.startswith("Property changes on:"):
0799 count += 1
0800 logging.info(line)
0801 if not count:
0802 ErrorExit("No valid patches found in output from svn diff")
0803 return data
0804
0805 def _CollapseKeywords(self, content, keyword_str):
0806 """Collapses SVN keywords."""
0807
0808
0809
0810
0811
0812 svn_keywords = {
0813
0814 'Date': ['Date', 'LastChangedDate'],
0815 'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
0816 'Author': ['Author', 'LastChangedBy'],
0817 'HeadURL': ['HeadURL', 'URL'],
0818 'Id': ['Id'],
0819
0820
0821 'LastChangedDate': ['LastChangedDate', 'Date'],
0822 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
0823 'LastChangedBy': ['LastChangedBy', 'Author'],
0824 'URL': ['URL', 'HeadURL'],
0825 }
0826
0827 def repl(m):
0828 if m.group(2):
0829 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
0830 return "$%s$" % m.group(1)
0831 keywords = [keyword
0832 for name in keyword_str.split(" ")
0833 for keyword in svn_keywords.get(name, [])]
0834 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
0835
0836 def GetUnknownFiles(self):
0837 status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
0838 unknown_files = []
0839 for line in status.split("\n"):
0840 if line and line[0] == "?":
0841 unknown_files.append(line)
0842 return unknown_files
0843
0844 def ReadFile(self, filename):
0845 """Returns the contents of a file."""
0846 file = open(filename, 'rb')
0847 result = ""
0848 try:
0849 result = file.read()
0850 finally:
0851 file.close()
0852 return result
0853
0854 def GetStatus(self, filename):
0855 """Returns the status of a file."""
0856 if not self.options.revision:
0857 status = RunShell(["svn", "status", "--ignore-externals", filename])
0858 if not status:
0859 ErrorExit("svn status returned no output for %s" % filename)
0860 status_lines = status.splitlines()
0861
0862
0863
0864 if (len(status_lines) == 3 and
0865 not status_lines[0] and
0866 status_lines[1].startswith("--- Changelist")):
0867 status = status_lines[2]
0868 else:
0869 status = status_lines[0]
0870
0871
0872
0873 else:
0874 dirname, relfilename = os.path.split(filename)
0875 if dirname not in self.svnls_cache:
0876 cmd = ["svn", "list", "-r", self.rev_start, dirname or "."]
0877 out, returncode = RunShellWithReturnCode(cmd)
0878 if returncode:
0879 ErrorExit("Failed to get status for %s." % filename)
0880 old_files = out.splitlines()
0881 args = ["svn", "list"]
0882 if self.rev_end:
0883 args += ["-r", self.rev_end]
0884 cmd = args + [dirname or "."]
0885 out, returncode = RunShellWithReturnCode(cmd)
0886 if returncode:
0887 ErrorExit("Failed to run command %s" % cmd)
0888 self.svnls_cache[dirname] = (old_files, out.splitlines())
0889 old_files, new_files = self.svnls_cache[dirname]
0890 if relfilename in old_files and relfilename not in new_files:
0891 status = "D "
0892 elif relfilename in old_files and relfilename in new_files:
0893 status = "M "
0894 else:
0895 status = "A "
0896 return status
0897
0898 def GetBaseFile(self, filename):
0899 status = self.GetStatus(filename)
0900 base_content = None
0901 new_content = None
0902
0903
0904
0905
0906
0907 if status[0] == "A" and status[3] != "+":
0908
0909
0910 mimetype = RunShell(["svn", "propget", "svn:mime-type", filename],
0911 silent_ok=True)
0912 base_content = ""
0913 is_binary = mimetype and not mimetype.startswith("text/")
0914 if is_binary and self.IsImage(filename):
0915 new_content = self.ReadFile(filename)
0916 elif (status[0] in ("M", "D", "R") or
0917 (status[0] == "A" and status[3] == "+") or
0918 (status[0] == " " and status[1] == "M")):
0919 args = []
0920 if self.options.revision:
0921 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
0922 else:
0923
0924 url = filename
0925 args += ["-r", "BASE"]
0926 cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
0927 mimetype, returncode = RunShellWithReturnCode(cmd)
0928 if returncode:
0929
0930
0931 mimetype = ""
0932 get_base = False
0933 is_binary = mimetype and not mimetype.startswith("text/")
0934 if status[0] == " ":
0935
0936 base_content = ""
0937 elif is_binary:
0938 if self.IsImage(filename):
0939 get_base = True
0940 if status[0] == "M":
0941 if not self.rev_end:
0942 new_content = self.ReadFile(filename)
0943 else:
0944 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
0945 new_content = RunShell(["svn", "cat", url],
0946 universal_newlines=True, silent_ok=True)
0947 else:
0948 base_content = ""
0949 else:
0950 get_base = True
0951
0952 if get_base:
0953 if is_binary:
0954 universal_newlines = False
0955 else:
0956 universal_newlines = True
0957 if self.rev_start:
0958
0959
0960 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
0961 base_content = RunShell(["svn", "cat", url],
0962 universal_newlines=universal_newlines,
0963 silent_ok=True)
0964 else:
0965 base_content = RunShell(["svn", "cat", filename],
0966 universal_newlines=universal_newlines,
0967 silent_ok=True)
0968 if not is_binary:
0969 args = []
0970 if self.rev_start:
0971 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
0972 else:
0973 url = filename
0974 args += ["-r", "BASE"]
0975 cmd = ["svn"] + args + ["propget", "svn:keywords", url]
0976 keywords, returncode = RunShellWithReturnCode(cmd)
0977 if keywords and not returncode:
0978 base_content = self._CollapseKeywords(base_content, keywords)
0979 else:
0980 StatusUpdate("svn status returned unexpected output: %s" % status)
0981 sys.exit(1)
0982 return base_content, new_content, is_binary, status[0:5]
0983
0984
0985 class GitVCS(VersionControlSystem):
0986 """Implementation of the VersionControlSystem interface for Git."""
0987
0988 def __init__(self, options):
0989 super(GitVCS, self).__init__(options)
0990
0991 self.base_hashes = {}
0992
0993 def GenerateDiff(self, extra_args):
0994
0995
0996
0997 if self.options.revision:
0998 extra_args = [self.options.revision] + extra_args
0999 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
1011
1012
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:
1032 status = "A"
1033 base_content = ""
1034 else:
1035 status = "M"
1036 base_content, returncode = RunShellWithReturnCode(["git", "show", hash])
1037 if returncode:
1038 ErrorExit("Got error status from 'git show %s'" % hash)
1039 return (base_content, new_content, is_binary, status)
1040
1041
1042 class MercurialVCS(VersionControlSystem):
1043 """Implementation of the VersionControlSystem interface for Mercurial."""
1044
1045 def __init__(self, options, repo_dir):
1046 super(MercurialVCS, self).__init__(options)
1047
1048 self.repo_dir = os.path.normpath(repo_dir)
1049
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
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
1075
1076
1077
1078
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
1104
1105
1106 base_content = ""
1107 new_content = None
1108 is_binary = False
1109 oldrelpath = relpath = self._GetRelPath(filename)
1110
1111 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
1112 out = out.splitlines()
1113
1114
1115 if out[0].startswith('%s: ' % relpath):
1116 out = out[1:]
1117 if len(out) > 1:
1118
1119
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
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
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
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
1162
1163
1164 temp_filename = temp_filename.strip().replace('\\', '/')
1165 if temp_filename != filename:
1166
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
1220
1221
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:
1228 raise
1229
1230
1231 if os.path.isdir('.svn'):
1232 logging.info("Guessed VCS = Subversion")
1233 return SubversionVCS(options)
1234
1235
1236
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:
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
1276
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
1325
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
1335
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()