michael@0: #!/usr/bin/env python michael@0: # michael@0: # Copyright 2007 Google Inc. michael@0: # michael@0: # Licensed under the Apache License, Version 2.0 (the "License"); michael@0: # you may not use this file except in compliance with the License. michael@0: # You may obtain a copy of the License at michael@0: # michael@0: # http://www.apache.org/licenses/LICENSE-2.0 michael@0: # michael@0: # Unless required by applicable law or agreed to in writing, software michael@0: # distributed under the License is distributed on an "AS IS" BASIS, michael@0: # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. michael@0: # See the License for the specific language governing permissions and michael@0: # limitations under the License. michael@0: michael@0: """Tool for uploading diffs from a version control system to the codereview app. michael@0: michael@0: Usage summary: upload.py [options] [-- diff_options] michael@0: michael@0: Diff options are passed to the diff command of the underlying system. michael@0: michael@0: Supported version control systems: michael@0: Git michael@0: Mercurial michael@0: Subversion michael@0: michael@0: It is important for Git/Mercurial users to specify a tree/node/branch to diff michael@0: against by using the '--rev' option. michael@0: """ michael@0: # This code is derived from appcfg.py in the App Engine SDK (open source), michael@0: # and from ASPN recipe #146306. michael@0: michael@0: import cookielib michael@0: import getpass michael@0: import logging michael@0: import md5 michael@0: import mimetypes michael@0: import optparse michael@0: import os michael@0: import re michael@0: import socket michael@0: import subprocess michael@0: import sys michael@0: import urllib michael@0: import urllib2 michael@0: import urlparse michael@0: michael@0: try: michael@0: import readline michael@0: except ImportError: michael@0: pass michael@0: michael@0: # The logging verbosity: michael@0: # 0: Errors only. michael@0: # 1: Status messages. michael@0: # 2: Info logs. michael@0: # 3: Debug logs. michael@0: verbosity = 1 michael@0: michael@0: # Max size of patch or base file. michael@0: MAX_UPLOAD_SIZE = 900 * 1024 michael@0: michael@0: michael@0: def GetEmail(prompt): michael@0: """Prompts the user for their email address and returns it. michael@0: michael@0: The last used email address is saved to a file and offered up as a suggestion michael@0: to the user. If the user presses enter without typing in anything the last michael@0: used email address is used. If the user enters a new address, it is saved michael@0: for next time we prompt. michael@0: michael@0: """ michael@0: last_email_file_name = os.path.expanduser("~/.last_codereview_email_address") michael@0: last_email = "" michael@0: if os.path.exists(last_email_file_name): michael@0: try: michael@0: last_email_file = open(last_email_file_name, "r") michael@0: last_email = last_email_file.readline().strip("\n") michael@0: last_email_file.close() michael@0: prompt += " [%s]" % last_email michael@0: except IOError, e: michael@0: pass michael@0: email = raw_input(prompt + ": ").strip() michael@0: if email: michael@0: try: michael@0: last_email_file = open(last_email_file_name, "w") michael@0: last_email_file.write(email) michael@0: last_email_file.close() michael@0: except IOError, e: michael@0: pass michael@0: else: michael@0: email = last_email michael@0: return email michael@0: michael@0: michael@0: def StatusUpdate(msg): michael@0: """Print a status message to stdout. michael@0: michael@0: If 'verbosity' is greater than 0, print the message. michael@0: michael@0: Args: michael@0: msg: The string to print. michael@0: """ michael@0: if verbosity > 0: michael@0: print msg michael@0: michael@0: michael@0: def ErrorExit(msg): michael@0: """Print an error message to stderr and exit.""" michael@0: print >>sys.stderr, msg michael@0: sys.exit(1) michael@0: michael@0: michael@0: class ClientLoginError(urllib2.HTTPError): michael@0: """Raised to indicate there was an error authenticating with ClientLogin.""" michael@0: michael@0: def __init__(self, url, code, msg, headers, args): michael@0: urllib2.HTTPError.__init__(self, url, code, msg, headers, None) michael@0: self.args = args michael@0: self.reason = args["Error"] michael@0: michael@0: michael@0: class AbstractRpcServer(object): michael@0: """Provides a common interface for a simple RPC server.""" michael@0: michael@0: def __init__(self, host, auth_function, host_override=None, extra_headers={}, michael@0: save_cookies=False): michael@0: """Creates a new HttpRpcServer. michael@0: michael@0: Args: michael@0: host: The host to send requests to. michael@0: auth_function: A function that takes no arguments and returns an michael@0: (email, password) tuple when called. Will be called if authentication michael@0: is required. michael@0: host_override: The host header to send to the server (defaults to host). michael@0: extra_headers: A dict of extra headers to append to every request. michael@0: save_cookies: If True, save the authentication cookies to local disk. michael@0: If False, use an in-memory cookiejar instead. Subclasses must michael@0: implement this functionality. Defaults to False. michael@0: """ michael@0: self.host = host michael@0: self.host_override = host_override michael@0: self.auth_function = auth_function michael@0: self.authenticated = False michael@0: self.extra_headers = extra_headers michael@0: self.save_cookies = save_cookies michael@0: self.opener = self._GetOpener() michael@0: if self.host_override: michael@0: logging.info("Server: %s; Host: %s", self.host, self.host_override) michael@0: else: michael@0: logging.info("Server: %s", self.host) michael@0: michael@0: def _GetOpener(self): michael@0: """Returns an OpenerDirector for making HTTP requests. michael@0: michael@0: Returns: michael@0: A urllib2.OpenerDirector object. michael@0: """ michael@0: raise NotImplementedError() michael@0: michael@0: def _CreateRequest(self, url, data=None): michael@0: """Creates a new urllib request.""" michael@0: logging.debug("Creating request for: '%s' with payload:\n%s", url, data) michael@0: req = urllib2.Request(url, data=data) michael@0: if self.host_override: michael@0: req.add_header("Host", self.host_override) michael@0: for key, value in self.extra_headers.iteritems(): michael@0: req.add_header(key, value) michael@0: return req michael@0: michael@0: def _GetAuthToken(self, email, password): michael@0: """Uses ClientLogin to authenticate the user, returning an auth token. michael@0: michael@0: Args: michael@0: email: The user's email address michael@0: password: The user's password michael@0: michael@0: Raises: michael@0: ClientLoginError: If there was an error authenticating with ClientLogin. michael@0: HTTPError: If there was some other form of HTTP error. michael@0: michael@0: Returns: michael@0: The authentication token returned by ClientLogin. michael@0: """ michael@0: account_type = "GOOGLE" michael@0: if self.host.endswith(".google.com"): michael@0: # Needed for use inside Google. michael@0: account_type = "HOSTED" michael@0: req = self._CreateRequest( michael@0: url="https://www.google.com/accounts/ClientLogin", michael@0: data=urllib.urlencode({ michael@0: "Email": email, michael@0: "Passwd": password, michael@0: "service": "ah", michael@0: "source": "rietveld-codereview-upload", michael@0: "accountType": account_type, michael@0: }), michael@0: ) michael@0: try: michael@0: response = self.opener.open(req) michael@0: response_body = response.read() michael@0: response_dict = dict(x.split("=") michael@0: for x in response_body.split("\n") if x) michael@0: return response_dict["Auth"] michael@0: except urllib2.HTTPError, e: michael@0: if e.code == 403: michael@0: body = e.read() michael@0: response_dict = dict(x.split("=", 1) for x in body.split("\n") if x) michael@0: raise ClientLoginError(req.get_full_url(), e.code, e.msg, michael@0: e.headers, response_dict) michael@0: else: michael@0: raise michael@0: michael@0: def _GetAuthCookie(self, auth_token): michael@0: """Fetches authentication cookies for an authentication token. michael@0: michael@0: Args: michael@0: auth_token: The authentication token returned by ClientLogin. michael@0: michael@0: Raises: michael@0: HTTPError: If there was an error fetching the authentication cookies. michael@0: """ michael@0: # This is a dummy value to allow us to identify when we're successful. michael@0: continue_location = "http://localhost/" michael@0: args = {"continue": continue_location, "auth": auth_token} michael@0: req = self._CreateRequest("http://%s/_ah/login?%s" % michael@0: (self.host, urllib.urlencode(args))) michael@0: try: michael@0: response = self.opener.open(req) michael@0: except urllib2.HTTPError, e: michael@0: response = e michael@0: if (response.code != 302 or michael@0: response.info()["location"] != continue_location): michael@0: raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, michael@0: response.headers, response.fp) michael@0: self.authenticated = True michael@0: michael@0: def _Authenticate(self): michael@0: """Authenticates the user. michael@0: michael@0: The authentication process works as follows: michael@0: 1) We get a username and password from the user michael@0: 2) We use ClientLogin to obtain an AUTH token for the user michael@0: (see http://code.google.com/apis/accounts/AuthForInstalledApps.html). michael@0: 3) We pass the auth token to /_ah/login on the server to obtain an michael@0: authentication cookie. If login was successful, it tries to redirect michael@0: us to the URL we provided. michael@0: michael@0: If we attempt to access the upload API without first obtaining an michael@0: authentication cookie, it returns a 401 response and directs us to michael@0: authenticate ourselves with ClientLogin. michael@0: """ michael@0: for i in range(3): michael@0: credentials = self.auth_function() michael@0: try: michael@0: auth_token = self._GetAuthToken(credentials[0], credentials[1]) michael@0: except ClientLoginError, e: michael@0: if e.reason == "BadAuthentication": michael@0: print >>sys.stderr, "Invalid username or password." michael@0: continue michael@0: if e.reason == "CaptchaRequired": michael@0: print >>sys.stderr, ( michael@0: "Please go to\n" michael@0: "https://www.google.com/accounts/DisplayUnlockCaptcha\n" michael@0: "and verify you are a human. Then try again.") michael@0: break michael@0: if e.reason == "NotVerified": michael@0: print >>sys.stderr, "Account not verified." michael@0: break michael@0: if e.reason == "TermsNotAgreed": michael@0: print >>sys.stderr, "User has not agreed to TOS." michael@0: break michael@0: if e.reason == "AccountDeleted": michael@0: print >>sys.stderr, "The user account has been deleted." michael@0: break michael@0: if e.reason == "AccountDisabled": michael@0: print >>sys.stderr, "The user account has been disabled." michael@0: break michael@0: if e.reason == "ServiceDisabled": michael@0: print >>sys.stderr, ("The user's access to the service has been " michael@0: "disabled.") michael@0: break michael@0: if e.reason == "ServiceUnavailable": michael@0: print >>sys.stderr, "The service is not available; try again later." michael@0: break michael@0: raise michael@0: self._GetAuthCookie(auth_token) michael@0: return michael@0: michael@0: def Send(self, request_path, payload=None, michael@0: content_type="application/octet-stream", michael@0: timeout=None, michael@0: **kwargs): michael@0: """Sends an RPC and returns the response. michael@0: michael@0: Args: michael@0: request_path: The path to send the request to, eg /api/appversion/create. michael@0: payload: The body of the request, or None to send an empty request. michael@0: content_type: The Content-Type header to use. michael@0: timeout: timeout in seconds; default None i.e. no timeout. michael@0: (Note: for large requests on OS X, the timeout doesn't work right.) michael@0: kwargs: Any keyword arguments are converted into query string parameters. michael@0: michael@0: Returns: michael@0: The response body, as a string. michael@0: """ michael@0: # TODO: Don't require authentication. Let the server say michael@0: # whether it is necessary. michael@0: if not self.authenticated: michael@0: self._Authenticate() michael@0: michael@0: old_timeout = socket.getdefaulttimeout() michael@0: socket.setdefaulttimeout(timeout) michael@0: try: michael@0: tries = 0 michael@0: while True: michael@0: tries += 1 michael@0: args = dict(kwargs) michael@0: url = "http://%s%s" % (self.host, request_path) michael@0: if args: michael@0: url += "?" + urllib.urlencode(args) michael@0: req = self._CreateRequest(url=url, data=payload) michael@0: req.add_header("Content-Type", content_type) michael@0: try: michael@0: f = self.opener.open(req) michael@0: response = f.read() michael@0: f.close() michael@0: return response michael@0: except urllib2.HTTPError, e: michael@0: if tries > 3: michael@0: raise michael@0: elif e.code == 401: michael@0: self._Authenticate() michael@0: ## elif e.code >= 500 and e.code < 600: michael@0: ## # Server Error - try again. michael@0: ## continue michael@0: else: michael@0: raise michael@0: finally: michael@0: socket.setdefaulttimeout(old_timeout) michael@0: michael@0: michael@0: class HttpRpcServer(AbstractRpcServer): michael@0: """Provides a simplified RPC-style interface for HTTP requests.""" michael@0: michael@0: def _Authenticate(self): michael@0: """Save the cookie jar after authentication.""" michael@0: super(HttpRpcServer, self)._Authenticate() michael@0: if self.save_cookies: michael@0: StatusUpdate("Saving authentication cookies to %s" % self.cookie_file) michael@0: self.cookie_jar.save() michael@0: michael@0: def _GetOpener(self): michael@0: """Returns an OpenerDirector that supports cookies and ignores redirects. michael@0: michael@0: Returns: michael@0: A urllib2.OpenerDirector object. michael@0: """ michael@0: opener = urllib2.OpenerDirector() michael@0: opener.add_handler(urllib2.ProxyHandler()) michael@0: opener.add_handler(urllib2.UnknownHandler()) michael@0: opener.add_handler(urllib2.HTTPHandler()) michael@0: opener.add_handler(urllib2.HTTPDefaultErrorHandler()) michael@0: opener.add_handler(urllib2.HTTPSHandler()) michael@0: opener.add_handler(urllib2.HTTPErrorProcessor()) michael@0: if self.save_cookies: michael@0: self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies") michael@0: self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file) michael@0: if os.path.exists(self.cookie_file): michael@0: try: michael@0: self.cookie_jar.load() michael@0: self.authenticated = True michael@0: StatusUpdate("Loaded authentication cookies from %s" % michael@0: self.cookie_file) michael@0: except (cookielib.LoadError, IOError): michael@0: # Failed to load cookies - just ignore them. michael@0: pass michael@0: else: michael@0: # Create an empty cookie file with mode 600 michael@0: fd = os.open(self.cookie_file, os.O_CREAT, 0600) michael@0: os.close(fd) michael@0: # Always chmod the cookie file michael@0: os.chmod(self.cookie_file, 0600) michael@0: else: michael@0: # Don't save cookies across runs of update.py. michael@0: self.cookie_jar = cookielib.CookieJar() michael@0: opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar)) michael@0: return opener michael@0: michael@0: michael@0: parser = optparse.OptionParser(usage="%prog [options] [-- diff_options]") michael@0: parser.add_option("-y", "--assume_yes", action="store_true", michael@0: dest="assume_yes", default=False, michael@0: help="Assume that the answer to yes/no questions is 'yes'.") michael@0: # Logging michael@0: group = parser.add_option_group("Logging options") michael@0: group.add_option("-q", "--quiet", action="store_const", const=0, michael@0: dest="verbose", help="Print errors only.") michael@0: group.add_option("-v", "--verbose", action="store_const", const=2, michael@0: dest="verbose", default=1, michael@0: help="Print info level logs (default).") michael@0: group.add_option("--noisy", action="store_const", const=3, michael@0: dest="verbose", help="Print all logs.") michael@0: # Review server michael@0: group = parser.add_option_group("Review server options") michael@0: group.add_option("-s", "--server", action="store", dest="server", michael@0: default="codereview.appspot.com", michael@0: metavar="SERVER", michael@0: help=("The server to upload to. The format is host[:port]. " michael@0: "Defaults to 'codereview.appspot.com'.")) michael@0: group.add_option("-e", "--email", action="store", dest="email", michael@0: metavar="EMAIL", default=None, michael@0: help="The username to use. Will prompt if omitted.") michael@0: group.add_option("-H", "--host", action="store", dest="host", michael@0: metavar="HOST", default=None, michael@0: help="Overrides the Host header sent with all RPCs.") michael@0: group.add_option("--no_cookies", action="store_false", michael@0: dest="save_cookies", default=True, michael@0: help="Do not save authentication cookies to local disk.") michael@0: # Issue michael@0: group = parser.add_option_group("Issue options") michael@0: group.add_option("-d", "--description", action="store", dest="description", michael@0: metavar="DESCRIPTION", default=None, michael@0: help="Optional description when creating an issue.") michael@0: group.add_option("-f", "--description_file", action="store", michael@0: dest="description_file", metavar="DESCRIPTION_FILE", michael@0: default=None, michael@0: help="Optional path of a file that contains " michael@0: "the description when creating an issue.") michael@0: group.add_option("-r", "--reviewers", action="store", dest="reviewers", michael@0: metavar="REVIEWERS", default=None, michael@0: help="Add reviewers (comma separated email addresses).") michael@0: group.add_option("--cc", action="store", dest="cc", michael@0: metavar="CC", default=None, michael@0: help="Add CC (comma separated email addresses).") michael@0: # Upload options michael@0: group = parser.add_option_group("Patch options") michael@0: group.add_option("-m", "--message", action="store", dest="message", michael@0: metavar="MESSAGE", default=None, michael@0: help="A message to identify the patch. " michael@0: "Will prompt if omitted.") michael@0: group.add_option("-i", "--issue", type="int", action="store", michael@0: metavar="ISSUE", default=None, michael@0: help="Issue number to which to add. Defaults to new issue.") michael@0: group.add_option("--download_base", action="store_true", michael@0: dest="download_base", default=False, michael@0: help="Base files will be downloaded by the server " michael@0: "(side-by-side diffs may not work on files with CRs).") michael@0: group.add_option("--rev", action="store", dest="revision", michael@0: metavar="REV", default=None, michael@0: help="Branch/tree/revision to diff against (used by DVCS).") michael@0: group.add_option("--send_mail", action="store_true", michael@0: dest="send_mail", default=False, michael@0: help="Send notification email to reviewers.") michael@0: michael@0: michael@0: def GetRpcServer(options): michael@0: """Returns an instance of an AbstractRpcServer. michael@0: michael@0: Returns: michael@0: A new AbstractRpcServer, on which RPC calls can be made. michael@0: """ michael@0: michael@0: rpc_server_class = HttpRpcServer michael@0: michael@0: def GetUserCredentials(): michael@0: """Prompts the user for a username and password.""" michael@0: email = options.email michael@0: if email is None: michael@0: email = GetEmail("Email (login for uploading to %s)" % options.server) michael@0: password = getpass.getpass("Password for %s: " % email) michael@0: return (email, password) michael@0: michael@0: # If this is the dev_appserver, use fake authentication. michael@0: host = (options.host or options.server).lower() michael@0: if host == "localhost" or host.startswith("localhost:"): michael@0: email = options.email michael@0: if email is None: michael@0: email = "test@example.com" michael@0: logging.info("Using debug user %s. Override with --email" % email) michael@0: server = rpc_server_class( michael@0: options.server, michael@0: lambda: (email, "password"), michael@0: host_override=options.host, michael@0: extra_headers={"Cookie": michael@0: 'dev_appserver_login="%s:False"' % email}, michael@0: save_cookies=options.save_cookies) michael@0: # Don't try to talk to ClientLogin. michael@0: server.authenticated = True michael@0: return server michael@0: michael@0: return rpc_server_class(options.server, GetUserCredentials, michael@0: host_override=options.host, michael@0: save_cookies=options.save_cookies) michael@0: michael@0: michael@0: def EncodeMultipartFormData(fields, files): michael@0: """Encode form fields for multipart/form-data. michael@0: michael@0: Args: michael@0: fields: A sequence of (name, value) elements for regular form fields. michael@0: files: A sequence of (name, filename, value) elements for data to be michael@0: uploaded as files. michael@0: Returns: michael@0: (content_type, body) ready for httplib.HTTP instance. michael@0: michael@0: Source: michael@0: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306 michael@0: """ michael@0: BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-' michael@0: CRLF = '\r\n' michael@0: lines = [] michael@0: for (key, value) in fields: michael@0: lines.append('--' + BOUNDARY) michael@0: lines.append('Content-Disposition: form-data; name="%s"' % key) michael@0: lines.append('') michael@0: lines.append(value) michael@0: for (key, filename, value) in files: michael@0: lines.append('--' + BOUNDARY) michael@0: lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % michael@0: (key, filename)) michael@0: lines.append('Content-Type: %s' % GetContentType(filename)) michael@0: lines.append('') michael@0: lines.append(value) michael@0: lines.append('--' + BOUNDARY + '--') michael@0: lines.append('') michael@0: body = CRLF.join(lines) michael@0: content_type = 'multipart/form-data; boundary=%s' % BOUNDARY michael@0: return content_type, body michael@0: michael@0: michael@0: def GetContentType(filename): michael@0: """Helper to guess the content-type from the filename.""" michael@0: return mimetypes.guess_type(filename)[0] or 'application/octet-stream' michael@0: michael@0: michael@0: # Use a shell for subcommands on Windows to get a PATH search. michael@0: use_shell = sys.platform.startswith("win") michael@0: michael@0: def RunShellWithReturnCode(command, print_output=False, michael@0: universal_newlines=True): michael@0: """Executes a command and returns the output from stdout and the return code. michael@0: michael@0: Args: michael@0: command: Command to execute. michael@0: print_output: If True, the output is printed to stdout. michael@0: If False, both stdout and stderr are ignored. michael@0: universal_newlines: Use universal_newlines flag (default: True). michael@0: michael@0: Returns: michael@0: Tuple (output, return code) michael@0: """ michael@0: logging.info("Running %s", command) michael@0: p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, michael@0: shell=use_shell, universal_newlines=universal_newlines) michael@0: if print_output: michael@0: output_array = [] michael@0: while True: michael@0: line = p.stdout.readline() michael@0: if not line: michael@0: break michael@0: print line.strip("\n") michael@0: output_array.append(line) michael@0: output = "".join(output_array) michael@0: else: michael@0: output = p.stdout.read() michael@0: p.wait() michael@0: errout = p.stderr.read() michael@0: if print_output and errout: michael@0: print >>sys.stderr, errout michael@0: p.stdout.close() michael@0: p.stderr.close() michael@0: return output, p.returncode michael@0: michael@0: michael@0: def RunShell(command, silent_ok=False, universal_newlines=True, michael@0: print_output=False): michael@0: data, retcode = RunShellWithReturnCode(command, print_output, michael@0: universal_newlines) michael@0: if retcode: michael@0: ErrorExit("Got error status from %s:\n%s" % (command, data)) michael@0: if not silent_ok and not data: michael@0: ErrorExit("No output from %s" % command) michael@0: return data michael@0: michael@0: michael@0: class VersionControlSystem(object): michael@0: """Abstract base class providing an interface to the VCS.""" michael@0: michael@0: def __init__(self, options): michael@0: """Constructor. michael@0: michael@0: Args: michael@0: options: Command line options. michael@0: """ michael@0: self.options = options michael@0: michael@0: def GenerateDiff(self, args): michael@0: """Return the current diff as a string. michael@0: michael@0: Args: michael@0: args: Extra arguments to pass to the diff command. michael@0: """ michael@0: raise NotImplementedError( michael@0: "abstract method -- subclass %s must override" % self.__class__) michael@0: michael@0: def GetUnknownFiles(self): michael@0: """Return a list of files unknown to the VCS.""" michael@0: raise NotImplementedError( michael@0: "abstract method -- subclass %s must override" % self.__class__) michael@0: michael@0: def CheckForUnknownFiles(self): michael@0: """Show an "are you sure?" prompt if there are unknown files.""" michael@0: unknown_files = self.GetUnknownFiles() michael@0: if unknown_files: michael@0: print "The following files are not added to version control:" michael@0: for line in unknown_files: michael@0: print line michael@0: prompt = "Are you sure to continue?(y/N) " michael@0: answer = raw_input(prompt).strip() michael@0: if answer != "y": michael@0: ErrorExit("User aborted") michael@0: michael@0: def GetBaseFile(self, filename): michael@0: """Get the content of the upstream version of a file. michael@0: michael@0: Returns: michael@0: A tuple (base_content, new_content, is_binary, status) michael@0: base_content: The contents of the base file. michael@0: new_content: For text files, this is empty. For binary files, this is michael@0: the contents of the new file, since the diff output won't contain michael@0: information to reconstruct the current file. michael@0: is_binary: True iff the file is binary. michael@0: status: The status of the file. michael@0: """ michael@0: michael@0: raise NotImplementedError( michael@0: "abstract method -- subclass %s must override" % self.__class__) michael@0: michael@0: michael@0: def GetBaseFiles(self, diff): michael@0: """Helper that calls GetBase file for each file in the patch. michael@0: michael@0: Returns: michael@0: A dictionary that maps from filename to GetBaseFile's tuple. Filenames michael@0: are retrieved based on lines that start with "Index:" or michael@0: "Property changes on:". michael@0: """ michael@0: files = {} michael@0: for line in diff.splitlines(True): michael@0: if line.startswith('Index:') or line.startswith('Property changes on:'): michael@0: unused, filename = line.split(':', 1) michael@0: # On Windows if a file has property changes its filename uses '\' michael@0: # instead of '/'. michael@0: filename = filename.strip().replace('\\', '/') michael@0: files[filename] = self.GetBaseFile(filename) michael@0: return files michael@0: michael@0: michael@0: def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options, michael@0: files): michael@0: """Uploads the base files (and if necessary, the current ones as well).""" michael@0: michael@0: def UploadFile(filename, file_id, content, is_binary, status, is_base): michael@0: """Uploads a file to the server.""" michael@0: file_too_large = False michael@0: if is_base: michael@0: type = "base" michael@0: else: michael@0: type = "current" michael@0: if len(content) > MAX_UPLOAD_SIZE: michael@0: print ("Not uploading the %s file for %s because it's too large." % michael@0: (type, filename)) michael@0: file_too_large = True michael@0: content = "" michael@0: checksum = md5.new(content).hexdigest() michael@0: if options.verbose > 0 and not file_too_large: michael@0: print "Uploading %s file for %s" % (type, filename) michael@0: url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id) michael@0: form_fields = [("filename", filename), michael@0: ("status", status), michael@0: ("checksum", checksum), michael@0: ("is_binary", str(is_binary)), michael@0: ("is_current", str(not is_base)), michael@0: ] michael@0: if file_too_large: michael@0: form_fields.append(("file_too_large", "1")) michael@0: if options.email: michael@0: form_fields.append(("user", options.email)) michael@0: ctype, body = EncodeMultipartFormData(form_fields, michael@0: [("data", filename, content)]) michael@0: response_body = rpc_server.Send(url, body, michael@0: content_type=ctype) michael@0: if not response_body.startswith("OK"): michael@0: StatusUpdate(" --> %s" % response_body) michael@0: sys.exit(1) michael@0: michael@0: patches = dict() michael@0: [patches.setdefault(v, k) for k, v in patch_list] michael@0: for filename in patches.keys(): michael@0: base_content, new_content, is_binary, status = files[filename] michael@0: file_id_str = patches.get(filename) michael@0: if file_id_str.find("nobase") != -1: michael@0: base_content = None michael@0: file_id_str = file_id_str[file_id_str.rfind("_") + 1:] michael@0: file_id = int(file_id_str) michael@0: if base_content != None: michael@0: UploadFile(filename, file_id, base_content, is_binary, status, True) michael@0: if new_content != None: michael@0: UploadFile(filename, file_id, new_content, is_binary, status, False) michael@0: michael@0: def IsImage(self, filename): michael@0: """Returns true if the filename has an image extension.""" michael@0: mimetype = mimetypes.guess_type(filename)[0] michael@0: if not mimetype: michael@0: return False michael@0: return mimetype.startswith("image/") michael@0: michael@0: michael@0: class SubversionVCS(VersionControlSystem): michael@0: """Implementation of the VersionControlSystem interface for Subversion.""" michael@0: michael@0: def __init__(self, options): michael@0: super(SubversionVCS, self).__init__(options) michael@0: if self.options.revision: michael@0: match = re.match(r"(\d+)(:(\d+))?", self.options.revision) michael@0: if not match: michael@0: ErrorExit("Invalid Subversion revision %s." % self.options.revision) michael@0: self.rev_start = match.group(1) michael@0: self.rev_end = match.group(3) michael@0: else: michael@0: self.rev_start = self.rev_end = None michael@0: # Cache output from "svn list -r REVNO dirname". michael@0: # Keys: dirname, Values: 2-tuple (ouput for start rev and end rev). michael@0: self.svnls_cache = {} michael@0: # SVN base URL is required to fetch files deleted in an older revision. michael@0: # Result is cached to not guess it over and over again in GetBaseFile(). michael@0: required = self.options.download_base or self.options.revision is not None michael@0: self.svn_base = self._GuessBase(required) michael@0: michael@0: def GuessBase(self, required): michael@0: """Wrapper for _GuessBase.""" michael@0: return self.svn_base michael@0: michael@0: def _GuessBase(self, required): michael@0: """Returns the SVN base URL. michael@0: michael@0: Args: michael@0: required: If true, exits if the url can't be guessed, otherwise None is michael@0: returned. michael@0: """ michael@0: info = RunShell(["svn", "info"]) michael@0: for line in info.splitlines(): michael@0: words = line.split() michael@0: if len(words) == 2 and words[0] == "URL:": michael@0: url = words[1] michael@0: scheme, netloc, path, params, query, fragment = urlparse.urlparse(url) michael@0: username, netloc = urllib.splituser(netloc) michael@0: if username: michael@0: logging.info("Removed username from base URL") michael@0: if netloc.endswith("svn.python.org"): michael@0: if netloc == "svn.python.org": michael@0: if path.startswith("/projects/"): michael@0: path = path[9:] michael@0: elif netloc != "pythondev@svn.python.org": michael@0: ErrorExit("Unrecognized Python URL: %s" % url) michael@0: base = "http://svn.python.org/view/*checkout*%s/" % path michael@0: logging.info("Guessed Python base = %s", base) michael@0: elif netloc.endswith("svn.collab.net"): michael@0: if path.startswith("/repos/"): michael@0: path = path[6:] michael@0: base = "http://svn.collab.net/viewvc/*checkout*%s/" % path michael@0: logging.info("Guessed CollabNet base = %s", base) michael@0: elif netloc.endswith(".googlecode.com"): michael@0: path = path + "/" michael@0: base = urlparse.urlunparse(("http", netloc, path, params, michael@0: query, fragment)) michael@0: logging.info("Guessed Google Code base = %s", base) michael@0: else: michael@0: path = path + "/" michael@0: base = urlparse.urlunparse((scheme, netloc, path, params, michael@0: query, fragment)) michael@0: logging.info("Guessed base = %s", base) michael@0: return base michael@0: if required: michael@0: ErrorExit("Can't find URL in output from svn info") michael@0: return None michael@0: michael@0: def GenerateDiff(self, args): michael@0: cmd = ["svn", "diff"] michael@0: if self.options.revision: michael@0: cmd += ["-r", self.options.revision] michael@0: cmd.extend(args) michael@0: data = RunShell(cmd) michael@0: count = 0 michael@0: for line in data.splitlines(): michael@0: if line.startswith("Index:") or line.startswith("Property changes on:"): michael@0: count += 1 michael@0: logging.info(line) michael@0: if not count: michael@0: ErrorExit("No valid patches found in output from svn diff") michael@0: return data michael@0: michael@0: def _CollapseKeywords(self, content, keyword_str): michael@0: """Collapses SVN keywords.""" michael@0: # svn cat translates keywords but svn diff doesn't. As a result of this michael@0: # behavior patching.PatchChunks() fails with a chunk mismatch error. michael@0: # This part was originally written by the Review Board development team michael@0: # who had the same problem (http://reviews.review-board.org/r/276/). michael@0: # Mapping of keywords to known aliases michael@0: svn_keywords = { michael@0: # Standard keywords michael@0: 'Date': ['Date', 'LastChangedDate'], michael@0: 'Revision': ['Revision', 'LastChangedRevision', 'Rev'], michael@0: 'Author': ['Author', 'LastChangedBy'], michael@0: 'HeadURL': ['HeadURL', 'URL'], michael@0: 'Id': ['Id'], michael@0: michael@0: # Aliases michael@0: 'LastChangedDate': ['LastChangedDate', 'Date'], michael@0: 'LastChangedRevision': ['LastChangedRevision', 'Rev', 'Revision'], michael@0: 'LastChangedBy': ['LastChangedBy', 'Author'], michael@0: 'URL': ['URL', 'HeadURL'], michael@0: } michael@0: michael@0: def repl(m): michael@0: if m.group(2): michael@0: return "$%s::%s$" % (m.group(1), " " * len(m.group(3))) michael@0: return "$%s$" % m.group(1) michael@0: keywords = [keyword michael@0: for name in keyword_str.split(" ") michael@0: for keyword in svn_keywords.get(name, [])] michael@0: return re.sub(r"\$(%s):(:?)([^\$]+)\$" % '|'.join(keywords), repl, content) michael@0: michael@0: def GetUnknownFiles(self): michael@0: status = RunShell(["svn", "status", "--ignore-externals"], silent_ok=True) michael@0: unknown_files = [] michael@0: for line in status.split("\n"): michael@0: if line and line[0] == "?": michael@0: unknown_files.append(line) michael@0: return unknown_files michael@0: michael@0: def ReadFile(self, filename): michael@0: """Returns the contents of a file.""" michael@0: file = open(filename, 'rb') michael@0: result = "" michael@0: try: michael@0: result = file.read() michael@0: finally: michael@0: file.close() michael@0: return result michael@0: michael@0: def GetStatus(self, filename): michael@0: """Returns the status of a file.""" michael@0: if not self.options.revision: michael@0: status = RunShell(["svn", "status", "--ignore-externals", filename]) michael@0: if not status: michael@0: ErrorExit("svn status returned no output for %s" % filename) michael@0: status_lines = status.splitlines() michael@0: # If file is in a cl, the output will begin with michael@0: # "\n--- Changelist 'cl_name':\n". See michael@0: # http://svn.collab.net/repos/svn/trunk/notes/changelist-design.txt michael@0: if (len(status_lines) == 3 and michael@0: not status_lines[0] and michael@0: status_lines[1].startswith("--- Changelist")): michael@0: status = status_lines[2] michael@0: else: michael@0: status = status_lines[0] michael@0: # If we have a revision to diff against we need to run "svn list" michael@0: # for the old and the new revision and compare the results to get michael@0: # the correct status for a file. michael@0: else: michael@0: dirname, relfilename = os.path.split(filename) michael@0: if dirname not in self.svnls_cache: michael@0: cmd = ["svn", "list", "-r", self.rev_start, dirname or "."] michael@0: out, returncode = RunShellWithReturnCode(cmd) michael@0: if returncode: michael@0: ErrorExit("Failed to get status for %s." % filename) michael@0: old_files = out.splitlines() michael@0: args = ["svn", "list"] michael@0: if self.rev_end: michael@0: args += ["-r", self.rev_end] michael@0: cmd = args + [dirname or "."] michael@0: out, returncode = RunShellWithReturnCode(cmd) michael@0: if returncode: michael@0: ErrorExit("Failed to run command %s" % cmd) michael@0: self.svnls_cache[dirname] = (old_files, out.splitlines()) michael@0: old_files, new_files = self.svnls_cache[dirname] michael@0: if relfilename in old_files and relfilename not in new_files: michael@0: status = "D " michael@0: elif relfilename in old_files and relfilename in new_files: michael@0: status = "M " michael@0: else: michael@0: status = "A " michael@0: return status michael@0: michael@0: def GetBaseFile(self, filename): michael@0: status = self.GetStatus(filename) michael@0: base_content = None michael@0: new_content = None michael@0: michael@0: # If a file is copied its status will be "A +", which signifies michael@0: # "addition-with-history". See "svn st" for more information. We need to michael@0: # upload the original file or else diff parsing will fail if the file was michael@0: # edited. michael@0: if status[0] == "A" and status[3] != "+": michael@0: # We'll need to upload the new content if we're adding a binary file michael@0: # since diff's output won't contain it. michael@0: mimetype = RunShell(["svn", "propget", "svn:mime-type", filename], michael@0: silent_ok=True) michael@0: base_content = "" michael@0: is_binary = mimetype and not mimetype.startswith("text/") michael@0: if is_binary and self.IsImage(filename): michael@0: new_content = self.ReadFile(filename) michael@0: elif (status[0] in ("M", "D", "R") or michael@0: (status[0] == "A" and status[3] == "+") or # Copied file. michael@0: (status[0] == " " and status[1] == "M")): # Property change. michael@0: args = [] michael@0: if self.options.revision: michael@0: url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start) michael@0: else: michael@0: # Don't change filename, it's needed later. michael@0: url = filename michael@0: args += ["-r", "BASE"] michael@0: cmd = ["svn"] + args + ["propget", "svn:mime-type", url] michael@0: mimetype, returncode = RunShellWithReturnCode(cmd) michael@0: if returncode: michael@0: # File does not exist in the requested revision. michael@0: # Reset mimetype, it contains an error message. michael@0: mimetype = "" michael@0: get_base = False michael@0: is_binary = mimetype and not mimetype.startswith("text/") michael@0: if status[0] == " ": michael@0: # Empty base content just to force an upload. michael@0: base_content = "" michael@0: elif is_binary: michael@0: if self.IsImage(filename): michael@0: get_base = True michael@0: if status[0] == "M": michael@0: if not self.rev_end: michael@0: new_content = self.ReadFile(filename) michael@0: else: michael@0: url = "%s/%s@%s" % (self.svn_base, filename, self.rev_end) michael@0: new_content = RunShell(["svn", "cat", url], michael@0: universal_newlines=True, silent_ok=True) michael@0: else: michael@0: base_content = "" michael@0: else: michael@0: get_base = True michael@0: michael@0: if get_base: michael@0: if is_binary: michael@0: universal_newlines = False michael@0: else: michael@0: universal_newlines = True michael@0: if self.rev_start: michael@0: # "svn cat -r REV delete_file.txt" doesn't work. cat requires michael@0: # the full URL with "@REV" appended instead of using "-r" option. michael@0: url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start) michael@0: base_content = RunShell(["svn", "cat", url], michael@0: universal_newlines=universal_newlines, michael@0: silent_ok=True) michael@0: else: michael@0: base_content = RunShell(["svn", "cat", filename], michael@0: universal_newlines=universal_newlines, michael@0: silent_ok=True) michael@0: if not is_binary: michael@0: args = [] michael@0: if self.rev_start: michael@0: url = "%s/%s@%s" % (self.svn_base, filename, self.rev_start) michael@0: else: michael@0: url = filename michael@0: args += ["-r", "BASE"] michael@0: cmd = ["svn"] + args + ["propget", "svn:keywords", url] michael@0: keywords, returncode = RunShellWithReturnCode(cmd) michael@0: if keywords and not returncode: michael@0: base_content = self._CollapseKeywords(base_content, keywords) michael@0: else: michael@0: StatusUpdate("svn status returned unexpected output: %s" % status) michael@0: sys.exit(1) michael@0: return base_content, new_content, is_binary, status[0:5] michael@0: michael@0: michael@0: class GitVCS(VersionControlSystem): michael@0: """Implementation of the VersionControlSystem interface for Git.""" michael@0: michael@0: def __init__(self, options): michael@0: super(GitVCS, self).__init__(options) michael@0: # Map of filename -> hash of base file. michael@0: self.base_hashes = {} michael@0: michael@0: def GenerateDiff(self, extra_args): michael@0: # This is more complicated than svn's GenerateDiff because we must convert michael@0: # the diff output to include an svn-style "Index:" line as well as record michael@0: # the hashes of the base files, so we can upload them along with our diff. michael@0: if self.options.revision: michael@0: extra_args = [self.options.revision] + extra_args michael@0: gitdiff = RunShell(["git", "diff", "--full-index"] + extra_args) michael@0: svndiff = [] michael@0: filecount = 0 michael@0: filename = None michael@0: for line in gitdiff.splitlines(): michael@0: match = re.match(r"diff --git a/(.*) b/.*$", line) michael@0: if match: michael@0: filecount += 1 michael@0: filename = match.group(1) michael@0: svndiff.append("Index: %s\n" % filename) michael@0: else: michael@0: # The "index" line in a git diff looks like this (long hashes elided): michael@0: # index 82c0d44..b2cee3f 100755 michael@0: # We want to save the left hash, as that identifies the base file. michael@0: match = re.match(r"index (\w+)\.\.", line) michael@0: if match: michael@0: self.base_hashes[filename] = match.group(1) michael@0: svndiff.append(line + "\n") michael@0: if not filecount: michael@0: ErrorExit("No valid patches found in output from git diff") michael@0: return "".join(svndiff) michael@0: michael@0: def GetUnknownFiles(self): michael@0: status = RunShell(["git", "ls-files", "--exclude-standard", "--others"], michael@0: silent_ok=True) michael@0: return status.splitlines() michael@0: michael@0: def GetBaseFile(self, filename): michael@0: hash = self.base_hashes[filename] michael@0: base_content = None michael@0: new_content = None michael@0: is_binary = False michael@0: if hash == "0" * 40: # All-zero hash indicates no base file. michael@0: status = "A" michael@0: base_content = "" michael@0: else: michael@0: status = "M" michael@0: base_content, returncode = RunShellWithReturnCode(["git", "show", hash]) michael@0: if returncode: michael@0: ErrorExit("Got error status from 'git show %s'" % hash) michael@0: return (base_content, new_content, is_binary, status) michael@0: michael@0: michael@0: class MercurialVCS(VersionControlSystem): michael@0: """Implementation of the VersionControlSystem interface for Mercurial.""" michael@0: michael@0: def __init__(self, options, repo_dir): michael@0: super(MercurialVCS, self).__init__(options) michael@0: # Absolute path to repository (we can be in a subdir) michael@0: self.repo_dir = os.path.normpath(repo_dir) michael@0: # Compute the subdir michael@0: cwd = os.path.normpath(os.getcwd()) michael@0: assert cwd.startswith(self.repo_dir) michael@0: self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/") michael@0: if self.options.revision: michael@0: self.base_rev = self.options.revision michael@0: else: michael@0: self.base_rev = RunShell(["hg", "parent", "-q"]).split(':')[1].strip() michael@0: michael@0: def _GetRelPath(self, filename): michael@0: """Get relative path of a file according to the current directory, michael@0: given its logical path in the repo.""" michael@0: assert filename.startswith(self.subdir), filename michael@0: return filename[len(self.subdir):].lstrip(r"\/") michael@0: michael@0: def GenerateDiff(self, extra_args): michael@0: # If no file specified, restrict to the current subdir michael@0: extra_args = extra_args or ["."] michael@0: cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args michael@0: data = RunShell(cmd, silent_ok=True) michael@0: svndiff = [] michael@0: filecount = 0 michael@0: for line in data.splitlines(): michael@0: m = re.match("diff --git a/(\S+) b/(\S+)", line) michael@0: if m: michael@0: # Modify line to make it look like as it comes from svn diff. michael@0: # With this modification no changes on the server side are required michael@0: # to make upload.py work with Mercurial repos. michael@0: # NOTE: for proper handling of moved/copied files, we have to use michael@0: # the second filename. michael@0: filename = m.group(2) michael@0: svndiff.append("Index: %s" % filename) michael@0: svndiff.append("=" * 67) michael@0: filecount += 1 michael@0: logging.info(line) michael@0: else: michael@0: svndiff.append(line) michael@0: if not filecount: michael@0: ErrorExit("No valid patches found in output from hg diff") michael@0: return "\n".join(svndiff) + "\n" michael@0: michael@0: def GetUnknownFiles(self): michael@0: """Return a list of files unknown to the VCS.""" michael@0: args = [] michael@0: status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."], michael@0: silent_ok=True) michael@0: unknown_files = [] michael@0: for line in status.splitlines(): michael@0: st, fn = line.split(" ", 1) michael@0: if st == "?": michael@0: unknown_files.append(fn) michael@0: return unknown_files michael@0: michael@0: def GetBaseFile(self, filename): michael@0: # "hg status" and "hg cat" both take a path relative to the current subdir michael@0: # rather than to the repo root, but "hg diff" has given us the full path michael@0: # to the repo root. michael@0: base_content = "" michael@0: new_content = None michael@0: is_binary = False michael@0: oldrelpath = relpath = self._GetRelPath(filename) michael@0: # "hg status -C" returns two lines for moved/copied files, one otherwise michael@0: out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath]) michael@0: out = out.splitlines() michael@0: # HACK: strip error message about missing file/directory if it isn't in michael@0: # the working copy michael@0: if out[0].startswith('%s: ' % relpath): michael@0: out = out[1:] michael@0: if len(out) > 1: michael@0: # Moved/copied => considered as modified, use old filename to michael@0: # retrieve base contents michael@0: oldrelpath = out[1].strip() michael@0: status = "M" michael@0: else: michael@0: status, _ = out[0].split(' ', 1) michael@0: if status != "A": michael@0: base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath], michael@0: silent_ok=True) michael@0: is_binary = "\0" in base_content # Mercurial's heuristic michael@0: if status != "R": michael@0: new_content = open(relpath, "rb").read() michael@0: is_binary = is_binary or "\0" in new_content michael@0: if is_binary and base_content: michael@0: # Fetch again without converting newlines michael@0: base_content = RunShell(["hg", "cat", "-r", self.base_rev, oldrelpath], michael@0: silent_ok=True, universal_newlines=False) michael@0: if not is_binary or not self.IsImage(relpath): michael@0: new_content = None michael@0: return base_content, new_content, is_binary, status michael@0: michael@0: michael@0: # NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync. michael@0: def SplitPatch(data): michael@0: """Splits a patch into separate pieces for each file. michael@0: michael@0: Args: michael@0: data: A string containing the output of svn diff. michael@0: michael@0: Returns: michael@0: A list of 2-tuple (filename, text) where text is the svn diff output michael@0: pertaining to filename. michael@0: """ michael@0: patches = [] michael@0: filename = None michael@0: diff = [] michael@0: for line in data.splitlines(True): michael@0: new_filename = None michael@0: if line.startswith('Index:'): michael@0: unused, new_filename = line.split(':', 1) michael@0: new_filename = new_filename.strip() michael@0: elif line.startswith('Property changes on:'): michael@0: unused, temp_filename = line.split(':', 1) michael@0: # When a file is modified, paths use '/' between directories, however michael@0: # when a property is modified '\' is used on Windows. Make them the same michael@0: # otherwise the file shows up twice. michael@0: temp_filename = temp_filename.strip().replace('\\', '/') michael@0: if temp_filename != filename: michael@0: # File has property changes but no modifications, create a new diff. michael@0: new_filename = temp_filename michael@0: if new_filename: michael@0: if filename and diff: michael@0: patches.append((filename, ''.join(diff))) michael@0: filename = new_filename michael@0: diff = [line] michael@0: continue michael@0: if diff is not None: michael@0: diff.append(line) michael@0: if filename and diff: michael@0: patches.append((filename, ''.join(diff))) michael@0: return patches michael@0: michael@0: michael@0: def UploadSeparatePatches(issue, rpc_server, patchset, data, options): michael@0: """Uploads a separate patch for each file in the diff output. michael@0: michael@0: Returns a list of [patch_key, filename] for each file. michael@0: """ michael@0: patches = SplitPatch(data) michael@0: rv = [] michael@0: for patch in patches: michael@0: if len(patch[1]) > MAX_UPLOAD_SIZE: michael@0: print ("Not uploading the patch for " + patch[0] + michael@0: " because the file is too large.") michael@0: continue michael@0: form_fields = [("filename", patch[0])] michael@0: if not options.download_base: michael@0: form_fields.append(("content_upload", "1")) michael@0: files = [("data", "data.diff", patch[1])] michael@0: ctype, body = EncodeMultipartFormData(form_fields, files) michael@0: url = "/%d/upload_patch/%d" % (int(issue), int(patchset)) michael@0: print "Uploading patch for " + patch[0] michael@0: response_body = rpc_server.Send(url, body, content_type=ctype) michael@0: lines = response_body.splitlines() michael@0: if not lines or lines[0] != "OK": michael@0: StatusUpdate(" --> %s" % response_body) michael@0: sys.exit(1) michael@0: rv.append([lines[1], patch[0]]) michael@0: return rv michael@0: michael@0: michael@0: def GuessVCS(options): michael@0: """Helper to guess the version control system. michael@0: michael@0: This examines the current directory, guesses which VersionControlSystem michael@0: we're using, and returns an instance of the appropriate class. Exit with an michael@0: error if we can't figure it out. michael@0: michael@0: Returns: michael@0: A VersionControlSystem instance. Exits if the VCS can't be guessed. michael@0: """ michael@0: # Mercurial has a command to get the base directory of a repository michael@0: # Try running it, but don't die if we don't have hg installed. michael@0: # NOTE: we try Mercurial first as it can sit on top of an SVN working copy. michael@0: try: michael@0: out, returncode = RunShellWithReturnCode(["hg", "root"]) michael@0: if returncode == 0: michael@0: return MercurialVCS(options, out.strip()) michael@0: except OSError, (errno, message): michael@0: if errno != 2: # ENOENT -- they don't have hg installed. michael@0: raise michael@0: michael@0: # Subversion has a .svn in all working directories. michael@0: if os.path.isdir('.svn'): michael@0: logging.info("Guessed VCS = Subversion") michael@0: return SubversionVCS(options) michael@0: michael@0: # Git has a command to test if you're in a git tree. michael@0: # Try running it, but don't die if we don't have git installed. michael@0: try: michael@0: out, returncode = RunShellWithReturnCode(["git", "rev-parse", michael@0: "--is-inside-work-tree"]) michael@0: if returncode == 0: michael@0: return GitVCS(options) michael@0: except OSError, (errno, message): michael@0: if errno != 2: # ENOENT -- they don't have git installed. michael@0: raise michael@0: michael@0: ErrorExit(("Could not guess version control system. " michael@0: "Are you in a working copy directory?")) michael@0: michael@0: michael@0: def RealMain(argv, data=None): michael@0: """The real main function. michael@0: michael@0: Args: michael@0: argv: Command line arguments. michael@0: data: Diff contents. If None (default) the diff is generated by michael@0: the VersionControlSystem implementation returned by GuessVCS(). michael@0: michael@0: Returns: michael@0: A 2-tuple (issue id, patchset id). michael@0: The patchset id is None if the base files are not uploaded by this michael@0: script (applies only to SVN checkouts). michael@0: """ michael@0: logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:" michael@0: "%(lineno)s %(message)s ")) michael@0: os.environ['LC_ALL'] = 'C' michael@0: options, args = parser.parse_args(argv[1:]) michael@0: global verbosity michael@0: verbosity = options.verbose michael@0: if verbosity >= 3: michael@0: logging.getLogger().setLevel(logging.DEBUG) michael@0: elif verbosity >= 2: michael@0: logging.getLogger().setLevel(logging.INFO) michael@0: vcs = GuessVCS(options) michael@0: if isinstance(vcs, SubversionVCS): michael@0: # base field is only allowed for Subversion. michael@0: # Note: Fetching base files may become deprecated in future releases. michael@0: base = vcs.GuessBase(options.download_base) michael@0: else: michael@0: base = None michael@0: if not base and options.download_base: michael@0: options.download_base = True michael@0: logging.info("Enabled upload of base file") michael@0: if not options.assume_yes: michael@0: vcs.CheckForUnknownFiles() michael@0: if data is None: michael@0: data = vcs.GenerateDiff(args) michael@0: files = vcs.GetBaseFiles(data) michael@0: if verbosity >= 1: michael@0: print "Upload server:", options.server, "(change with -s/--server)" michael@0: if options.issue: michael@0: prompt = "Message describing this patch set: " michael@0: else: michael@0: prompt = "New issue subject: " michael@0: message = options.message or raw_input(prompt).strip() michael@0: if not message: michael@0: ErrorExit("A non-empty message is required") michael@0: rpc_server = GetRpcServer(options) michael@0: form_fields = [("subject", message)] michael@0: if base: michael@0: form_fields.append(("base", base)) michael@0: if options.issue: michael@0: form_fields.append(("issue", str(options.issue))) michael@0: if options.email: michael@0: form_fields.append(("user", options.email)) michael@0: if options.reviewers: michael@0: for reviewer in options.reviewers.split(','): michael@0: if "@" in reviewer and not reviewer.split("@")[1].count(".") == 1: michael@0: ErrorExit("Invalid email address: %s" % reviewer) michael@0: form_fields.append(("reviewers", options.reviewers)) michael@0: if options.cc: michael@0: for cc in options.cc.split(','): michael@0: if "@" in cc and not cc.split("@")[1].count(".") == 1: michael@0: ErrorExit("Invalid email address: %s" % cc) michael@0: form_fields.append(("cc", options.cc)) michael@0: description = options.description michael@0: if options.description_file: michael@0: if options.description: michael@0: ErrorExit("Can't specify description and description_file") michael@0: file = open(options.description_file, 'r') michael@0: description = file.read() michael@0: file.close() michael@0: if description: michael@0: form_fields.append(("description", description)) michael@0: # Send a hash of all the base file so the server can determine if a copy michael@0: # already exists in an earlier patchset. michael@0: base_hashes = "" michael@0: for file, info in files.iteritems(): michael@0: if not info[0] is None: michael@0: checksum = md5.new(info[0]).hexdigest() michael@0: if base_hashes: michael@0: base_hashes += "|" michael@0: base_hashes += checksum + ":" + file michael@0: form_fields.append(("base_hashes", base_hashes)) michael@0: # If we're uploading base files, don't send the email before the uploads, so michael@0: # that it contains the file status. michael@0: if options.send_mail and options.download_base: michael@0: form_fields.append(("send_mail", "1")) michael@0: if not options.download_base: michael@0: form_fields.append(("content_upload", "1")) michael@0: if len(data) > MAX_UPLOAD_SIZE: michael@0: print "Patch is large, so uploading file patches separately." michael@0: uploaded_diff_file = [] michael@0: form_fields.append(("separate_patches", "1")) michael@0: else: michael@0: uploaded_diff_file = [("data", "data.diff", data)] michael@0: ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file) michael@0: response_body = rpc_server.Send("/upload", body, content_type=ctype) michael@0: patchset = None michael@0: if not options.download_base or not uploaded_diff_file: michael@0: lines = response_body.splitlines() michael@0: if len(lines) >= 2: michael@0: msg = lines[0] michael@0: patchset = lines[1].strip() michael@0: patches = [x.split(" ", 1) for x in lines[2:]] michael@0: else: michael@0: msg = response_body michael@0: else: michael@0: msg = response_body michael@0: StatusUpdate(msg) michael@0: if not response_body.startswith("Issue created.") and \ michael@0: not response_body.startswith("Issue updated."): michael@0: sys.exit(0) michael@0: issue = msg[msg.rfind("/")+1:] michael@0: michael@0: if not uploaded_diff_file: michael@0: result = UploadSeparatePatches(issue, rpc_server, patchset, data, options) michael@0: if not options.download_base: michael@0: patches = result michael@0: michael@0: if not options.download_base: michael@0: vcs.UploadBaseFiles(issue, rpc_server, patches, patchset, options, files) michael@0: if options.send_mail: michael@0: rpc_server.Send("/" + issue + "/mail", payload="") michael@0: return issue, patchset michael@0: michael@0: michael@0: def main(): michael@0: try: michael@0: RealMain(sys.argv) michael@0: except KeyboardInterrupt: michael@0: print michael@0: StatusUpdate("Interrupted.") michael@0: sys.exit(1) michael@0: michael@0: michael@0: if __name__ == "__main__": michael@0: main()