00001
00002
00003
00004
00005
00006
00007
00008
00009
00010
00011
00012
00013
00014
00015
00016
00017 """Tool for uploading diffs from a version control system to the codereview app.
00018
00019 Usage summary: upload.py [options] [-- diff_options]
00020
00021 Diff options are passed to the diff command of the underlying system.
00022
00023 Supported version control systems:
00024 Git
00025 Mercurial
00026 Subversion
00027
00028 It is important for Git/Mercurial users to specify a tree/node/branch to diff
00029 against by using the '--rev' option.
00030 """
00031
00032
00033
00034 import cookielib
00035 import getpass
00036 import logging
00037 import md5
00038 import mimetypes
00039 import optparse
00040 import os
00041 import re
00042 import socket
00043 import subprocess
00044 import sys
00045 import urllib
00046 import urllib2
00047 import urlparse
00048
00049 try:
00050 import readline
00051 except ImportError:
00052 pass
00053
00054
00055
00056
00057
00058
00059 verbosity = 1
00060
00061
00062 MAX_UPLOAD_SIZE = 900 * 1024
00063
00064
00065 def GetEmail(prompt):
00066 """Prompts the user for their email address and returns it.
00067
00068 The last used email address is saved to a file and offered up as a suggestion
00069 to the user. If the user presses enter without typing in anything the last
00070 used email address is used. If the user enters a new address, it is saved
00071 for next time we prompt.
00072
00073 """
00074 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
00075 last_email = ""
00076 if os.path.exists(last_email_file_name):
00077 try:
00078 last_email_file = open(last_email_file_name, "r")
00079 last_email = last_email_file.readline().strip("\n")
00080 last_email_file.close()
00081 prompt += " [%s]" % last_email
00082 except IOError, e:
00083 pass
00084 email = raw_input(prompt + ": ").strip()
00085 if email:
00086 try:
00087 last_email_file = open(last_email_file_name, "w")
00088 last_email_file.write(email)
00089 last_email_file.close()
00090 except IOError, e:
00091 pass
00092 else:
00093 email = last_email
00094 return email
00095
00096
00097 def StatusUpdate(msg):
00098 """Print a status message to stdout.
00099
00100 If 'verbosity' is greater than 0, print the message.
00101
00102 Args:
00103 msg: The string to print.
00104 """
00105 if verbosity > 0:
00106 print msg
00107
00108
00109 def ErrorExit(msg):
00110 """Print an error message to stderr and exit."""
00111 print >>sys.stderr, msg
00112 sys.exit(1)
00113
00114
00115 class ClientLoginError(urllib2.HTTPError):
00116 """Raised to indicate there was an error authenticating with ClientLogin."""
00117
00118 def __init__(self, url, code, msg, headers, args):
00119 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
00120 self.args = args
00121 self.reason = args["Error"]
00122
00123
00124 class AbstractRpcServer(object):
00125 """Provides a common interface for a simple RPC server."""
00126
00127 def __init__(self, host, auth_function, host_override=None, extra_headers={},
00128 save_cookies=False):
00129 """Creates a new HttpRpcServer.
00130
00131 Args:
00132 host: The host to send requests to.
00133 auth_function: A function that takes no arguments and returns an
00134 (email, password) tuple when called. Will be called if authentication
00135 is required.
00136 host_override: The host header to send to the server (defaults to host).
00137 extra_headers: A dict of extra headers to append to every request.
00138 save_cookies: If True, save the authentication cookies to local disk.
00139 If False, use an in-memory cookiejar instead. Subclasses must
00140 implement this functionality. Defaults to False.
00141 """
00142 self.host = host
00143 self.host_override = host_override
00144 self.auth_function = auth_function
00145 self.authenticated = False
00146 self.extra_headers = extra_headers
00147 self.save_cookies = save_cookies
00148 self.opener = self._GetOpener()
00149 if self.host_override:
00150 logging.info("Server: %s; Host: %s", self.host, self.host_override)
00151 else:
00152 logging.info("Server: %s", self.host)
00153
00154 def _GetOpener(self):
00155 """Returns an OpenerDirector for making HTTP requests.
00156
00157 Returns:
00158 A urllib2.OpenerDirector object.
00159 """
00160 raise NotImplementedError()
00161
00162 def _CreateRequest(self, url, data=None):
00163 """Creates a new urllib request."""
00164 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
00165 req = urllib2.Request(url, data=data)
00166 if self.host_override:
00167 req.add_header("Host", self.host_override)
00168 for key, value in self.extra_headers.iteritems():
00169 req.add_header(key, value)
00170 return req
00171
00172 def _GetAuthToken(self, email, password):
00173 """Uses ClientLogin to authenticate the user, returning an auth token.
00174
00175 Args:
00176 email: The user's email address
00177 password: The user's password
00178
00179 Raises:
00180 ClientLoginError: If there was an error authenticating with ClientLogin.
00181 HTTPError: If there was some other form of HTTP error.
00182
00183 Returns:
00184 The authentication token returned by ClientLogin.
00185 """
00186 account_type = "GOOGLE"
00187 if self.host.endswith(".google.com"):
00188
00189 account_type = "HOSTED"
00190 req = self._CreateRequest(
00191 url="https://www.google.com/accounts/ClientLogin",
00192 data=urllib.urlencode({
00193 "Email": email,
00194 "Passwd": password,
00195 "service": "ah",
00196 "source": "rietveld-codereview-upload",
00197 "accountType": account_type,
00198 }),
00199 )
00200 try:
00201 response = self.opener.open(req)
00202 response_body = response.read()
00203 response_dict = dict(x.split("=")
00204 for x in response_body.split("\n") if x)
00205 return response_dict["Auth"]
00206 except urllib2.HTTPError, e:
00207 if e.code == 403:
00208 body = e.read()
00209 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
00210 raise ClientLoginError(req.get_full_url(), e.code, e.msg,
00211 e.headers, response_dict)
00212 else:
00213 raise
00214
00215 def _GetAuthCookie(self, auth_token):
00216 """Fetches authentication cookies for an authentication token.
00217
00218 Args:
00219 auth_token: The authentication token returned by ClientLogin.
00220
00221 Raises:
00222 HTTPError: If there was an error fetching the authentication cookies.
00223 """
00224
00225 continue_location = "http://localhost/"
00226 args = {"continue": continue_location, "auth": auth_token}
00227 req = self._CreateRequest("http://%s/_ah/login?%s" %
00228 (self.host, urllib.urlencode(args)))
00229 try:
00230 response = self.opener.open(req)
00231 except urllib2.HTTPError, e:
00232 response = e
00233 if (response.code != 302 or
00234 response.info()["location"] != continue_location):
00235 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg,
00236 response.headers, response.fp)
00237 self.authenticated = True
00238
00239 def _Authenticate(self):
00240 """Authenticates the user.
00241
00242 The authentication process works as follows:
00243 1) We get a username and password from the user
00244 2) We use ClientLogin to obtain an AUTH token for the user
00245 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
00246 3) We pass the auth token to /_ah/login on the server to obtain an
00247 authentication cookie. If login was successful, it tries to redirect
00248 us to the URL we provided.
00249
00250 If we attempt to access the upload API without first obtaining an
00251 authentication cookie, it returns a 401 response and directs us to
00252 authenticate ourselves with ClientLogin.
00253 """
00254 for i in range(3):
00255 credentials = self.auth_function()
00256 try:
00257 auth_token = self._GetAuthToken(credentials[0], credentials[1])
00258 except ClientLoginError, e:
00259 if e.reason == "BadAuthentication":
00260 print >>sys.stderr, "Invalid username or password."
00261 continue
00262 if e.reason == "CaptchaRequired":
00263 print >>sys.stderr, (
00264 "Please go to\n"
00265 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
00266 "and verify you are a human. Then try again.")
00267 break
00268 if e.reason == "NotVerified":
00269 print >>sys.stderr, "Account not verified."
00270 break
00271 if e.reason == "TermsNotAgreed":
00272 print >>sys.stderr, "User has not agreed to TOS."
00273 break
00274 if e.reason == "AccountDeleted":
00275 print >>sys.stderr, "The user account has been deleted."
00276 break
00277 if e.reason == "AccountDisabled":
00278 print >>sys.stderr, "The user account has been disabled."
00279 break
00280 if e.reason == "ServiceDisabled":
00281 print >>sys.stderr, ("The user's access to the service has been "
00282 "disabled.")
00283 break
00284 if e.reason == "ServiceUnavailable":
00285 print >>sys.stderr, "The service is not available; try again later."
00286 break
00287 raise
00288 self._GetAuthCookie(auth_token)
00289 return
00290
00291 def Send(self, request_path, payload=None,
00292 content_type="application/octet-stream",
00293 timeout=None,
00294 **kwargs):
00295 """Sends an RPC and returns the response.
00296
00297 Args:
00298 request_path: The path to send the request to, eg /api/appversion/create.
00299 payload: The body of the request, or None to send an empty request.
00300 content_type: The Content-Type header to use.
00301 timeout: timeout in seconds; default None i.e. no timeout.
00302 (Note: for large requests on OS X, the timeout doesn't work right.)
00303 kwargs: Any keyword arguments are converted into query string parameters.
00304
00305 Returns:
00306 The response body, as a string.
00307 """
00308
00309
00310 if not self.authenticated:
00311 self._Authenticate()
00312
00313 old_timeout = socket.getdefaulttimeout()
00314 socket.setdefaulttimeout(timeout)
00315 try:
00316 tries = 0
00317 while True:
00318 tries += 1
00319 args = dict(kwargs)
00320 url = "http://%s%s" % (self.host, request_path)
00321 if args:
00322 url += "?" + urllib.urlencode(args)
00323 req = self._CreateRequest(url=url, data=payload)
00324 req.add_header("Content-Type", content_type)
00325 try:
00326 f = self.opener.open(req)
00327 response = f.read()
00328 f.close()
00329 return response
00330 except urllib2.HTTPError, e:
00331 if tries > 3:
00332 raise
00333 elif e.code == 401:
00334 self._Authenticate()
00335
00336
00337
00338 else:
00339 raise
00340 finally:
00341 socket.setdefaulttimeout(old_timeout)
00342
00343
00344 class HttpRpcServer(AbstractRpcServer):
00345 """Provides a simplified RPC-style interface for HTTP requests."""
00346
00347 def _Authenticate(self):
00348 """Save the cookie jar after authentication."""
00349 super(HttpRpcServer, self)._Authenticate()
00350 if self.save_cookies:
00351 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
00352 self.cookie_jar.save()
00353
00354 def _GetOpener(self):
00355 """Returns an OpenerDirector that supports cookies and ignores redirects.
00356
00357 Returns:
00358 A urllib2.OpenerDirector object.
00359 """
00360 opener = urllib2.OpenerDirector()
00361 opener.add_handler(urllib2.ProxyHandler())
00362 opener.add_handler(urllib2.UnknownHandler())
00363 opener.add_handler(urllib2.HTTPHandler())
00364 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
00365 opener.add_handler(urllib2.HTTPSHandler())
00366 opener.add_handler(urllib2.HTTPErrorProcessor())
00367 if self.save_cookies:
00368 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies")
00369 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
00370 if os.path.exists(self.cookie_file):
00371 try:
00372 self.cookie_jar.load()
00373 self.authenticated = True
00374 StatusUpdate("Loaded authentication cookies from %s" %
00375 self.cookie_file)
00376 except (cookielib.LoadError, IOError):
00377
00378 pass
00379 else:
00380
00381 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
00382 os.close(fd)
00383
00384 os.chmod(self.cookie_file, 0600)
00385 else:
00386
00387 self.cookie_jar = cookielib.CookieJar()
00388 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
00389 return opener
00390
00391
00392 parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]")
00393 parser.add_option("-y", "--assume_yes", action="store_true",
00394 dest="assume_yes", default=False,
00395 help="Assume that the answer to yes/no questions is 'yes'.")
00396
00397 group = parser.add_option_group("Logging options")
00398 group.add_option("-q", "--quiet", action="store_const", const=0,
00399 dest="verbose", help="Print errors only.")
00400 group.add_option("-v", "--verbose", action="store_const", const=2,
00401 dest="verbose", default=1,
00402 help="Print info level logs (default).")
00403 group.add_option("--noisy", action="store_const", const=3,
00404 dest="verbose", help="Print all logs.")
00405
00406 group = parser.add_option_group("Review server options")
00407 group.add_option("-s", "--server", action="store", dest="server",
00408 default="codereview.appspot.com",
00409 metavar="SERVER",
00410 help=("The server to upload to. The format is host[:port]. "
00411 "Defaults to 'codereview.appspot.com'."))
00412 group.add_option("-e", "--email", action="store", dest="email",
00413 metavar="EMAIL", default=None,
00414 help="The username to use. Will prompt if omitted.")
00415 group.add_option("-H", "--host", action="store", dest="host",
00416 metavar="HOST", default=None,
00417 help="Overrides the Host header sent with all RPCs.")
00418 group.add_option("--no_cookies", action="store_false",
00419 dest="save_cookies", default=True,
00420 help="Do not save authentication cookies to local disk.")
00421
00422 group = parser.add_option_group("Issue options")
00423 group.add_option("-d", "--description", action="store", dest="description",
00424 metavar="DESCRIPTION", default=None,
00425 help="Optional description when creating an issue.")
00426 group.add_option("-f", "--description_file", action="store",
00427 dest="description_file", metavar="DESCRIPTION_FILE",
00428 default=None,
00429 help="Optional path of a file that contains "
00430 "the description when creating an issue.")
00431 group.add_option("-r", "--reviewers", action="store", dest="reviewers",
00432 metavar="REVIEWERS", default=None,
00433 help="Add reviewers (comma separated email addresses).")
00434 group.add_option("--cc", action="store", dest="cc",
00435 metavar="CC", default=None,
00436 help="Add CC (comma separated email addresses).")
00437
00438 group = parser.add_option_group("Patch options")
00439 group.add_option("-m", "--message", action="store", dest="message",
00440 metavar="MESSAGE", default=None,
00441 help="A message to identify the patch. "
00442 "Will prompt if omitted.")
00443 group.add_option("-i", "--issue", type="int", action="store",
00444 metavar="ISSUE", default=None,
00445 help="Issue number to which to add. Defaults to new issue.")
00446 group.add_option("--download_base", action="store_true",
00447 dest="download_base", default=False,
00448 help="Base files will be downloaded by the server "
00449 "(side-by-side diffs may not work on files with CRs).")
00450 group.add_option("--rev", action="store", dest="revision",
00451 metavar="REV", default=None,
00452 help="Branch/tree/revision to diff against (used by DVCS).")
00453 group.add_option("--send_mail", action="store_true",
00454 dest="send_mail", default=False,
00455 help="Send notification email to reviewers.")
00456
00457
00458 def GetRpcServer(options):
00459 """Returns an instance of an AbstractRpcServer.
00460
00461 Returns:
00462 A new AbstractRpcServer, on which RPC calls can be made.
00463 """
00464
00465 rpc_server_class = HttpRpcServer
00466
00467 def GetUserCredentials():
00468 """Prompts the user for a username and password."""
00469 email = options.email
00470 if email is None:
00471 email = GetEmail("Email (login for uploading to %s)" % options.server)
00472 password = getpass.getpass("Password for %s: " % email)
00473 return (email, password)
00474
00475
00476 host = (options.host or options.server).lower()
00477 if host == "localhost" or host.startswith("localhost:"):
00478 email = options.email
00479 if email is None:
00480 email = "test@example.com"
00481 logging.info("Using debug user %s. Override with --email" % email)
00482 server = rpc_server_class(
00483 options.server,
00484 lambda: (email, "password"),
00485 host_override=options.host,
00486 extra_headers={"Cookie":
00487 'dev_appserver_login="%s:False"' % email},
00488 save_cookies=options.save_cookies)
00489
00490 server.authenticated = True
00491 return server
00492
00493 return rpc_server_class(options.server, GetUserCredentials,
00494 host_override=options.host,
00495 save_cookies=options.save_cookies)
00496
00497
00498 def EncodeMultipartFormData(fields, files):
00499 """Encode form fields for multipart/form-data.
00500
00501 Args:
00502 fields: A sequence of (name, value) elements for regular form fields.
00503 files: A sequence of (name, filename, value) elements for data to be
00504 uploaded as files.
00505 Returns:
00506 (content_type, body) ready for httplib.HTTP instance.
00507
00508 Source:
00509 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
00510 """
00511 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
00512 CRLF = '\r\n'
00513 lines = []
00514 for (key, value) in fields:
00515 lines.append('--' + BOUNDARY)
00516 lines.append('Content-Disposition: form-data; name="%s"' % key)
00517 lines.append('')
00518 lines.append(value)
00519 for (key, filename, value) in files:
00520 lines.append('--' + BOUNDARY)
00521 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
00522 (key, filename))
00523 lines.append('Content-Type: %s' % GetContentType(filename))
00524 lines.append('')
00525 lines.append(value)
00526 lines.append('--' + BOUNDARY + '--')
00527 lines.append('')
00528 body = CRLF.join(lines)
00529 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
00530 return content_type, body
00531
00532
00533 def GetContentType(filename):
00534 """Helper to guess the content-type from the filename."""
00535 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
00536
00537
00538
00539 use_shell = sys.platform.startswith("win")
00540
00541 def RunShellWithReturnCode(command, print_output=False,
00542 universal_newlines=True):
00543 """Executes a command and returns the output from stdout and the return code.
00544
00545 Args:
00546 command: Command to execute.
00547 print_output: If True, the output is printed to stdout.
00548 If False, both stdout and stderr are ignored.
00549 universal_newlines: Use universal_newlines flag (default: True).
00550
00551 Returns:
00552 Tuple (output, return code)
00553 """
00554 logging.info("Running %s", command)
00555 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
00556 shell=use_shell, universal_newlines=universal_newlines)
00557 if print_output:
00558 output_array = []
00559 while True:
00560 line = p.stdout.readline()
00561 if not line:
00562 break
00563 print line.strip("\n")
00564 output_array.append(line)
00565 output = "".join(output_array)
00566 else:
00567 output = p.stdout.read()
00568 p.wait()
00569 errout = p.stderr.read()
00570 if print_output and errout:
00571 print >>sys.stderr, errout
00572 p.stdout.close()
00573 p.stderr.close()
00574 return output, p.returncode
00575
00576
00577 def RunShell(command, silent_ok=False, universal_newlines=True,
00578 print_output=False):
00579 data, retcode = RunShellWithReturnCode(command, print_output,
00580 universal_newlines)
00581 if retcode:
00582 ErrorExit("Got error status from %s:\n%s" % (command, data))
00583 if not silent_ok and not data:
00584 ErrorExit("No output from %s" % command)
00585 return data
00586
00587
00588 class VersionControlSystem(object):
00589 """Abstract base class providing an interface to the VCS."""
00590
00591 def __init__(self, options):
00592 """Constructor.
00593
00594 Args:
00595 options: Command line options.
00596 """
00597 self.options = options
00598
00599 def GenerateDiff(self, args):
00600 """Return the current diff as a string.
00601
00602 Args:
00603 args: Extra arguments to pass to the diff command.
00604 """
00605 raise NotImplementedError(
00606 "abstract method -- subclass %s must override" % self.__class__)
00607
00608 def GetUnknownFiles(self):
00609 """Return a list of files unknown to the VCS."""
00610 raise NotImplementedError(
00611 "abstract method -- subclass %s must override" % self.__class__)
00612
00613 def CheckForUnknownFiles(self):
00614 """Show an "are you sure?" prompt if there are unknown files."""
00615 unknown_files = self.GetUnknownFiles()
00616 if unknown_files:
00617 print "The following files are not added to version control:"
00618 for line in unknown_files:
00619 print line
00620 prompt = "Are you sure to continue?(y/N) "
00621 answer = raw_input(prompt).strip()
00622 if answer != "y":
00623 ErrorExit("User aborted")
00624
00625 def GetBaseFile(self, filename):
00626 """Get the content of the upstream version of a file.
00627
00628 Returns:
00629 A tuple (base_content, new_content, is_binary, status)
00630 base_content: The contents of the base file.
00631 new_content: For text files, this is empty. For binary files, this is
00632 the contents of the new file, since the diff output won't contain
00633 information to reconstruct the current file.
00634 is_binary: True iff the file is binary.
00635 status: The status of the file.
00636 """
00637
00638 raise NotImplementedError(
00639 "abstract method -- subclass %s must override" % self.__class__)
00640
00641
00642 def GetBaseFiles(self, diff):
00643 """Helper that calls GetBase file for each file in the patch.
00644
00645 Returns:
00646 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
00647 are retrieved based on lines that start with "Index:" or
00648 "Property changes on:".
00649 """
00650 files = {}
00651 for line in diff.splitlines(True):
00652 if line.startswith('Index:') or line.startswith('Property changes on:'):
00653 unused, filename = line.split(':', 1)
00654
00655
00656 filename = filename.strip().replace('\\', '/')
00657 files[filename] = self.GetBaseFile(filename)
00658 return files
00659
00660
00661 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
00662 files):
00663 """Uploads the base files (and if necessary, the current ones as well)."""
00664
00665 def UploadFile(filename, file_id, content, is_binary, status, is_base):
00666 """Uploads a file to the server."""
00667 file_too_large = False
00668 if is_base:
00669 type = "base"
00670 else:
00671 type = "current"
00672 if len(content) > MAX_UPLOAD_SIZE:
00673 print ("Not uploading the %s file for %s because it's too large." %
00674 (type, filename))
00675 file_too_large = True
00676 content = ""
00677 checksum = md5.new(content).hexdigest()
00678 if options.verbose > 0 and not file_too_large:
00679 print "Uploading %s file for %s" % (type, filename)
00680 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
00681 form_fields = [("filename", filename),
00682 ("status", status),
00683 ("checksum", checksum),
00684 ("is_binary", str(is_binary)),
00685 ("is_current", str(not is_base)),
00686 ]
00687 if file_too_large:
00688 form_fields.append(("file_too_large", "1"))
00689 if options.email:
00690 form_fields.append(("user", options.email))
00691 ctype, body = EncodeMultipartFormData(form_fields,
00692 [("data", filename, content)])
00693 response_body = rpc_server.Send(url, body,
00694 content_type=ctype)
00695 if not response_body.startswith("OK"):
00696 StatusUpdate(" --> %s" % response_body)
00697 sys.exit(1)
00698
00699 patches = dict()
00700 [patches.setdefault(v, k) for k, v in patch_list]
00701 for filename in patches.keys():
00702 base_content, new_content, is_binary, status = files[filename]
00703 file_id_str = patches.get(filename)
00704 if file_id_str.find("nobase") != -1:
00705 base_content = None
00706 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
00707 file_id = int(file_id_str)
00708 if base_content != None:
00709 UploadFile(filename, file_id, base_content, is_binary, status, True)
00710 if new_content != None:
00711 UploadFile(filename, file_id, new_content, is_binary, status, False)
00712
00713 def IsImage(self, filename):
00714 """Returns true if the filename has an image extension."""
00715 mimetype = mimetypes.guess_type(filename)[0]
00716 if not mimetype:
00717 return False
00718 return mimetype.startswith("image/")
00719
00720
00721 class SubversionVCS(VersionControlSystem):
00722 """Implementation of the VersionControlSystem interface for Subversion."""
00723
00724 def __init__(self, options):
00725 super(SubversionVCS, self).__init__(options)
00726 if self.options.revision:
00727 match = re.match(r"(\d+)(:(\d+))?", self.options.revision)
00728 if not match:
00729 ErrorExit("Invalid Subversion revision %s." % self.options.revision)
00730 self.rev_start = match.group(1)
00731 self.rev_end = match.group(3)
00732 else:
00733 self.rev_start = self.rev_end = None
00734
00735
00736 self.svnls_cache = {}
00737
00738
00739 required = self.options.download_base or self.options.revision is not None
00740 self.svn_base = self._GuessBase(required)
00741
00742 def GuessBase(self, required):
00743 """Wrapper for _GuessBase."""
00744 return self.svn_base
00745
00746 def _GuessBase(self, required):
00747 """Returns the SVN base URL.
00748
00749 Args:
00750 required: If true, exits if the url can't be guessed, otherwise None is
00751 returned.
00752 """
00753 info = RunShell(["svn", "info"])
00754 for line in info.splitlines():
00755 words = line.split()
00756 if len(words) == 2 and words[0] == "URL:":
00757 url = words[1]
00758 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
00759 username, netloc = urllib.splituser(netloc)
00760 if username:
00761 logging.info("Removed username from base URL")
00762 if netloc.endswith("svn.python.org"):
00763 if netloc == "svn.python.org":
00764 if path.startswith("/projects/"):
00765 path = path[9:]
00766 elif netloc != "pythondev@svn.python.org":
00767 ErrorExit("Unrecognized Python URL: %s" % url)
00768 base = "http://svn.python.org/view/*checkout*%s/" % path
00769 logging.info("Guessed Python base = %s", base)
00770 elif netloc.endswith("svn.collab.net"):
00771 if path.startswith("/repos/"):
00772 path = path[6:]
00773 base = "http://svn.collab.net/viewvc/*checkout*%s/" % path
00774 logging.info("Guessed CollabNet base = %s", base)
00775 elif netloc.endswith(".googlecode.com"):
00776 path = path + "/"
00777 base = urlparse.urlunparse(("http", netloc, path, params,
00778 query, fragment))
00779 logging.info("Guessed Google Code base = %s", base)
00780 else:
00781 path = path + "/"
00782 base = urlparse.urlunparse((scheme, netloc, path, params,
00783 query, fragment))
00784 logging.info("Guessed base = %s", base)
00785 return base
00786 if required:
00787 ErrorExit("Can't find URL in output from svn info")
00788 return None
00789
00790 def GenerateDiff(self, args):
00791 cmd = ["svn", "diff"]
00792 if self.options.revision:
00793 cmd += ["-r", self.options.revision]
00794 cmd.extend(args)
00795 data = RunShell(cmd)
00796 count = 0
00797 for line in data.splitlines():
00798 if line.startswith("Index:") or line.startswith("Property changes on:"):
00799 count += 1
00800 logging.info(line)
00801 if not count:
00802 ErrorExit("No valid patches found in output from svn diff")
00803 return data
00804
00805 def _CollapseKeywords(self, content, keyword_str):
00806 """Collapses SVN keywords."""
00807
00808
00809
00810
00811
00812 svn_keywords = {
00813
00814 'Date': ['Date', 'LastChangedDate'],
00815 'Revision': ['Revision', 'LastChangedRevision', 'Rev'],
00816 'Author': ['Author', 'LastChangedBy'],
00817 'HeadURL': ['HeadURL', 'URL'],
00818 'Id': ['Id'],
00819
00820
00821 'LastChangedDate': ['LastChangedDate', 'Date'],
00822 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'],
00823 'LastChangedBy': ['LastChangedBy', 'Author'],
00824 'URL': ['URL', 'HeadURL'],
00825 }
00826
00827 def repl(m):
00828 if m.group(2):
00829 return "$%s::%s$" % (m.group(1), " " * len(m.group(3)))
00830 return "$%s$" % m.group(1)
00831 keywords = [keyword
00832 for name in keyword_str.split(" ")
00833 for keyword in svn_keywords.get(name, [])]
00834 return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content)
00835
00836 def GetUnknownFiles(self):
00837 status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True)
00838 unknown_files = []
00839 for line in status.split("\n"):
00840 if line and line[0] == "?":
00841 unknown_files.append(line)
00842 return unknown_files
00843
00844 def ReadFile(self, filename):
00845 """Returns the contents of a file."""
00846 file = open(filename, 'rb')
00847 result = ""
00848 try:
00849 result = file.read()
00850 finally:
00851 file.close()
00852 return result
00853
00854 def GetStatus(self, filename):
00855 """Returns the status of a file."""
00856 if not self.options.revision:
00857 status = RunShell(["svn", "status", "--ignore-externals", filename])
00858 if not status:
00859 ErrorExit("svn status returned no output for %s" % filename)
00860 status_lines = status.splitlines()
00861
00862
00863
00864 if (len(status_lines) == 3 and
00865 not status_lines[0] and
00866 status_lines[1].startswith("--- Changelist")):
00867 status = status_lines[2]
00868 else:
00869 status = status_lines[0]
00870
00871
00872
00873 else:
00874 dirname, relfilename = os.path.split(filename)
00875 if dirname not in self.svnls_cache:
00876 cmd = ["svn", "list", "-r", self.rev_start, dirname or "."]
00877 out, returncode = RunShellWithReturnCode(cmd)
00878 if returncode:
00879 ErrorExit("Failed to get status for %s." % filename)
00880 old_files = out.splitlines()
00881 args = ["svn", "list"]
00882 if self.rev_end:
00883 args += ["-r", self.rev_end]
00884 cmd = args + [dirname or "."]
00885 out, returncode = RunShellWithReturnCode(cmd)
00886 if returncode:
00887 ErrorExit("Failed to run command %s" % cmd)
00888 self.svnls_cache[dirname] = (old_files, out.splitlines())
00889 old_files, new_files = self.svnls_cache[dirname]
00890 if relfilename in old_files and relfilename not in new_files:
00891 status = "D "
00892 elif relfilename in old_files and relfilename in new_files:
00893 status = "M "
00894 else:
00895 status = "A "
00896 return status
00897
00898 def GetBaseFile(self, filename):
00899 status = self.GetStatus(filename)
00900 base_content = None
00901 new_content = None
00902
00903
00904
00905
00906
00907 if status[0] == "A" and status[3] != "+":
00908
00909
00910 mimetype = RunShell(["svn", "propget", "svn:mime-type", filename],
00911 silent_ok=True)
00912 base_content = ""
00913 is_binary = mimetype and not mimetype.startswith("text/")
00914 if is_binary and self.IsImage(filename):
00915 new_content = self.ReadFile(filename)
00916 elif (status[0] in ("M", "D", "R") or
00917 (status[0] == "A" and status[3] == "+") or
00918 (status[0] == " " and status[1] == "M")):
00919 args = []
00920 if self.options.revision:
00921 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
00922 else:
00923
00924 url = filename
00925 args += ["-r", "BASE"]
00926 cmd = ["svn"] + args + ["propget", "svn:mime-type", url]
00927 mimetype, returncode = RunShellWithReturnCode(cmd)
00928 if returncode:
00929
00930
00931 mimetype = ""
00932 get_base = False
00933 is_binary = mimetype and not mimetype.startswith("text/")
00934 if status[0] == " ":
00935
00936 base_content = ""
00937 elif is_binary:
00938 if self.IsImage(filename):
00939 get_base = True
00940 if status[0] == "M":
00941 if not self.rev_end:
00942 new_content = self.ReadFile(filename)
00943 else:
00944 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end)
00945 new_content = RunShell(["svn", "cat", url],
00946 universal_newlines=True, silent_ok=True)
00947 else:
00948 base_content = ""
00949 else:
00950 get_base = True
00951
00952 if get_base:
00953 if is_binary:
00954 universal_newlines = False
00955 else:
00956 universal_newlines = True
00957 if self.rev_start:
00958
00959
00960 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
00961 base_content = RunShell(["svn", "cat", url],
00962 universal_newlines=universal_newlines,
00963 silent_ok=True)
00964 else:
00965 base_content = RunShell(["svn", "cat", filename],
00966 universal_newlines=universal_newlines,
00967 silent_ok=True)
00968 if not is_binary:
00969 args = []
00970 if self.rev_start:
00971 url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start)
00972 else:
00973 url = filename
00974 args += ["-r", "BASE"]
00975 cmd = ["svn"] + args + ["propget", "svn:keywords", url]
00976 keywords, returncode = RunShellWithReturnCode(cmd)
00977 if keywords and not returncode:
00978 base_content = self._CollapseKeywords(base_content, keywords)
00979 else:
00980 StatusUpdate("svn status returned unexpected output: %s" % status)
00981 sys.exit(1)
00982 return base_content, new_content, is_binary, status[0:5]
00983
00984
00985 class GitVCS(VersionControlSystem):
00986 """Implementation of the VersionControlSystem interface for Git."""
00987
00988 def __init__(self, options):
00989 super(GitVCS, self).__init__(options)
00990
00991 self.base_hashes = {}
00992
00993 def GenerateDiff(self, extra_args):
00994
00995
00996
00997 if self.options.revision:
00998 extra_args = [self.options.revision] + extra_args
00999 gitdiff = RunShell(["git", "diff", "--full-index"] + extra_args)
01000 svndiff = []
01001 filecount = 0
01002 filename = None
01003 for line in gitdiff.splitlines():
01004 match = re.match(r"diff --git a/(.*) b/.*$", line)
01005 if match:
01006 filecount += 1
01007 filename = match.group(1)
01008 svndiff.append("Index: %s\n" % filename)
01009 else:
01010
01011
01012
01013 match = re.match(r"index (\w+)\.\.", line)
01014 if match:
01015 self.base_hashes[filename] = match.group(1)
01016 svndiff.append(line + "\n")
01017 if not filecount:
01018 ErrorExit("No valid patches found in output from git diff")
01019 return "".join(svndiff)
01020
01021 def GetUnknownFiles(self):
01022 status = RunShell(["git", "ls-files", "--exclude-standard", "--others"],
01023 silent_ok=True)
01024 return status.splitlines()
01025
01026 def GetBaseFile(self, filename):
01027 hash = self.base_hashes[filename]
01028 base_content = None
01029 new_content = None
01030 is_binary = False
01031 if hash == "0" * 40:
01032 status = "A"
01033 base_content = ""
01034 else:
01035 status = "M"
01036 base_content, returncode = RunShellWithReturnCode(["git", "show", hash])
01037 if returncode:
01038 ErrorExit("Got error status from 'git show %s'" % hash)
01039 return (base_content, new_content, is_binary, status)
01040
01041
01042 class MercurialVCS(VersionControlSystem):
01043 """Implementation of the VersionControlSystem interface for Mercurial."""
01044
01045 def __init__(self, options, repo_dir):
01046 super(MercurialVCS, self).__init__(options)
01047
01048 self.repo_dir = os.path.normpath(repo_dir)
01049
01050 cwd = os.path.normpath(os.getcwd())
01051 assert cwd.startswith(self.repo_dir)
01052 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
01053 if self.options.revision:
01054 self.base_rev = self.options.revision
01055 else:
01056 self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip()
01057
01058 def _GetRelPath(self, filename):
01059 """Get relative path of a file according to the current directory,
01060 given its logical path in the repo."""
01061 assert filename.startswith(self.subdir), filename
01062 return filename[len(self.subdir):].lstrip(r"\/")
01063
01064 def GenerateDiff(self, extra_args):
01065
01066 extra_args = extra_args or ["."]
01067 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
01068 data = RunShell(cmd, silent_ok=True)
01069 svndiff = []
01070 filecount = 0
01071 for line in data.splitlines():
01072 m = re.match("diff --git a/(\S+) b/(\S+)", line)
01073 if m:
01074
01075
01076
01077
01078
01079 filename = m.group(2)
01080 svndiff.append("Index: %s" % filename)
01081 svndiff.append("=" * 67)
01082 filecount += 1
01083 logging.info(line)
01084 else:
01085 svndiff.append(line)
01086 if not filecount:
01087 ErrorExit("No valid patches found in output from hg diff")
01088 return "\n".join(svndiff) + "\n"
01089
01090 def GetUnknownFiles(self):
01091 """Return a list of files unknown to the VCS."""
01092 args = []
01093 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
01094 silent_ok=True)
01095 unknown_files = []
01096 for line in status.splitlines():
01097 st, fn = line.split(" ", 1)
01098 if st == "?":
01099 unknown_files.append(fn)
01100 return unknown_files
01101
01102 def GetBaseFile(self, filename):
01103
01104
01105
01106 base_content = ""
01107 new_content = None
01108 is_binary = False
01109 oldrelpath = relpath = self._GetRelPath(filename)
01110
01111 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
01112 out = out.splitlines()
01113
01114
01115 if out[0].startswith('%s: ' % relpath):
01116 out = out[1:]
01117 if len(out) > 1:
01118
01119
01120 oldrelpath = out[1].strip()
01121 status = "M"
01122 else:
01123 status, _ = out[0].split(' ', 1)
01124 if status != "A":
01125 base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath],
01126 silent_ok=True)
01127 is_binary = "\0" in base_content
01128 if status != "R":
01129 new_content = open(relpath, "rb").read()
01130 is_binary = is_binary or "\0" in new_content
01131 if is_binary and base_content:
01132
01133 base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath],
01134 silent_ok=True, universal_newlines=False)
01135 if not is_binary or not self.IsImage(relpath):
01136 new_content = None
01137 return base_content, new_content, is_binary, status
01138
01139
01140
01141 def SplitPatch(data):
01142 """Splits a patch into separate pieces for each file.
01143
01144 Args:
01145 data: A string containing the output of svn diff.
01146
01147 Returns:
01148 A list of 2-tuple (filename, text) where text is the svn diff output
01149 pertaining to filename.
01150 """
01151 patches = []
01152 filename = None
01153 diff = []
01154 for line in data.splitlines(True):
01155 new_filename = None
01156 if line.startswith('Index:'):
01157 unused, new_filename = line.split(':', 1)
01158 new_filename = new_filename.strip()
01159 elif line.startswith('Property changes on:'):
01160 unused, temp_filename = line.split(':', 1)
01161
01162
01163
01164 temp_filename = temp_filename.strip().replace('\\', '/')
01165 if temp_filename != filename:
01166
01167 new_filename = temp_filename
01168 if new_filename:
01169 if filename and diff:
01170 patches.append((filename, ''.join(diff)))
01171 filename = new_filename
01172 diff = [line]
01173 continue
01174 if diff is not None:
01175 diff.append(line)
01176 if filename and diff:
01177 patches.append((filename, ''.join(diff)))
01178 return patches
01179
01180
01181 def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
01182 """Uploads a separate patch for each file in the diff output.
01183
01184 Returns a list of [patch_key, filename] for each file.
01185 """
01186 patches = SplitPatch(data)
01187 rv = []
01188 for patch in patches:
01189 if len(patch[1]) > MAX_UPLOAD_SIZE:
01190 print ("Not uploading the patch for " + patch[0] +
01191 " because the file is too large.")
01192 continue
01193 form_fields = [("filename", patch[0])]
01194 if not options.download_base:
01195 form_fields.append(("content_upload", "1"))
01196 files = [("data", "data.diff", patch[1])]
01197 ctype, body = EncodeMultipartFormData(form_fields, files)
01198 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
01199 print "Uploading patch for " + patch[0]
01200 response_body = rpc_server.Send(url, body, content_type=ctype)
01201 lines = response_body.splitlines()
01202 if not lines or lines[0] != "OK":
01203 StatusUpdate(" --> %s" % response_body)
01204 sys.exit(1)
01205 rv.append([lines[1], patch[0]])
01206 return rv
01207
01208
01209 def GuessVCS(options):
01210 """Helper to guess the version control system.
01211
01212 This examines the current directory, guesses which VersionControlSystem
01213 we're using, and returns an instance of the appropriate class. Exit with an
01214 error if we can't figure it out.
01215
01216 Returns:
01217 A VersionControlSystem instance. Exits if the VCS can't be guessed.
01218 """
01219
01220
01221
01222 try:
01223 out, returncode = RunShellWithReturnCode(["hg", "root"])
01224 if returncode == 0:
01225 return MercurialVCS(options, out.strip())
01226 except OSError, (errno, message):
01227 if errno != 2:
01228 raise
01229
01230
01231 if os.path.isdir('.svn'):
01232 logging.info("Guessed VCS = Subversion")
01233 return SubversionVCS(options)
01234
01235
01236
01237 try:
01238 out, returncode = RunShellWithReturnCode(["git", "rev-parse",
01239 "--is-inside-work-tree"])
01240 if returncode == 0:
01241 return GitVCS(options)
01242 except OSError, (errno, message):
01243 if errno != 2:
01244 raise
01245
01246 ErrorExit(("Could not guess version control system. "
01247 "Are you in a working copy directory?"))
01248
01249
01250 def RealMain(argv, data=None):
01251 """The real main function.
01252
01253 Args:
01254 argv: Command line arguments.
01255 data: Diff contents. If None (default) the diff is generated by
01256 the VersionControlSystem implementation returned by GuessVCS().
01257
01258 Returns:
01259 A 2-tuple (issue id, patchset id).
01260 The patchset id is None if the base files are not uploaded by this
01261 script (applies only to SVN checkouts).
01262 """
01263 logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
01264 "%(lineno)s %(message)s "))
01265 os.environ['LC_ALL'] = 'C'
01266 options, args = parser.parse_args(argv[1:])
01267 global verbosity
01268 verbosity = options.verbose
01269 if verbosity >= 3:
01270 logging.getLogger().setLevel(logging.DEBUG)
01271 elif verbosity >= 2:
01272 logging.getLogger().setLevel(logging.INFO)
01273 vcs = GuessVCS(options)
01274 if isinstance(vcs, SubversionVCS):
01275
01276
01277 base = vcs.GuessBase(options.download_base)
01278 else:
01279 base = None
01280 if not base and options.download_base:
01281 options.download_base = True
01282 logging.info("Enabled upload of base file")
01283 if not options.assume_yes:
01284 vcs.CheckForUnknownFiles()
01285 if data is None:
01286 data = vcs.GenerateDiff(args)
01287 files = vcs.GetBaseFiles(data)
01288 if verbosity >= 1:
01289 print "Upload server:", options.server, "(change with -s/--server)"
01290 if options.issue:
01291 prompt = "Message describing this patch set: "
01292 else:
01293 prompt = "New issue subject: "
01294 message = options.message or raw_input(prompt).strip()
01295 if not message:
01296 ErrorExit("A non-empty message is required")
01297 rpc_server = GetRpcServer(options)
01298 form_fields = [("subject", message)]
01299 if base:
01300 form_fields.append(("base", base))
01301 if options.issue:
01302 form_fields.append(("issue", str(options.issue)))
01303 if options.email:
01304 form_fields.append(("user", options.email))
01305 if options.reviewers:
01306 for reviewer in options.reviewers.split(','):
01307 if "@" in reviewer and not reviewer.split("@")[1].count(".") == 1:
01308 ErrorExit("Invalid email address: %s" % reviewer)
01309 form_fields.append(("reviewers", options.reviewers))
01310 if options.cc:
01311 for cc in options.cc.split(','):
01312 if "@" in cc and not cc.split("@")[1].count(".") == 1:
01313 ErrorExit("Invalid email address: %s" % cc)
01314 form_fields.append(("cc", options.cc))
01315 description = options.description
01316 if options.description_file:
01317 if options.description:
01318 ErrorExit("Can't specify description and description_file")
01319 file = open(options.description_file, 'r')
01320 description = file.read()
01321 file.close()
01322 if description:
01323 form_fields.append(("description", description))
01324
01325
01326 base_hashes = ""
01327 for file, info in files.iteritems():
01328 if not info[0] is None:
01329 checksum = md5.new(info[0]).hexdigest()
01330 if base_hashes:
01331 base_hashes += "|"
01332 base_hashes += checksum + ":" + file
01333 form_fields.append(("base_hashes", base_hashes))
01334
01335
01336 if options.send_mail and options.download_base:
01337 form_fields.append(("send_mail", "1"))
01338 if not options.download_base:
01339 form_fields.append(("content_upload", "1"))
01340 if len(data) > MAX_UPLOAD_SIZE:
01341 print "Patch is large, so uploading file patches separately."
01342 uploaded_diff_file = []
01343 form_fields.append(("separate_patches", "1"))
01344 else:
01345 uploaded_diff_file = [("data", "data.diff", data)]
01346 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
01347 response_body = rpc_server.Send("/upload", body, content_type=ctype)
01348 patchset = None
01349 if not options.download_base or not uploaded_diff_file:
01350 lines = response_body.splitlines()
01351 if len(lines) >= 2:
01352 msg = lines[0]
01353 patchset = lines[1].strip()
01354 patches = [x.split(" ", 1) for x in lines[2:]]
01355 else:
01356 msg = response_body
01357 else:
01358 msg = response_body
01359 StatusUpdate(msg)
01360 if not response_body.startswith("Issue created.") and \
01361 not response_body.startswith("Issue updated."):
01362 sys.exit(0)
01363 issue = msg[msg.rfind("/")+1:]
01364
01365 if not uploaded_diff_file:
01366 result = UploadSeparatePatches(issue, rpc_server, patchset, data, options)
01367 if not options.download_base:
01368 patches = result
01369
01370 if not options.download_base:
01371 vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files)
01372 if options.send_mail:
01373 rpc_server.Send("/" + issue + "/mail", payload="")
01374 return issue, patchset
01375
01376
01377 def main():
01378 try:
01379 RealMain(sys.argv)
01380 except KeyboardInterrupt:
01381 print
01382 StatusUpdate("Interrupted.")
01383 sys.exit(1)
01384
01385
01386 if __name__ == "__main__":
01387 main()