#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tool for uploading subversion diffs to the codereview app.

Usage summary: upload.py [options] [-- svn_diff_options]
"""
# This code is derived from appcfg.py in the App Engine SDK (open source),
# and from ASPN recipe #146306.

import cookielib
import getpass
import logging
import mimetypes
import optparse
import os
import socket
import sys
import urllib
import urllib2
import urlparse


# The logging verbosity:
#  0: Errors only.
#  1: Status messages.
#  2: Info logs.
#  3: Debug logs.
verbosity = 1


def StatusUpdate(msg):
    """Print a status message to stdout.

    If 'verbosity' is greater than 0, print the message.

    Args:
    msg: The string to print.
    """
    if verbosity > 0:
        print msg


def ErrorExit(msg):
    """Print an error message to stderr and exit."""
    print >>sys.stderr, msg
    sys.exit(1)


class ClientLoginError(urllib2.HTTPError):
    """Raised to indicate there was an error authenticating with
    ClientLogin."""

    def __init__(self, url, code, msg, headers, args):
        urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
        self.args = args
        self.reason = args["Error"]


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

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

        Args:
          host: The host to send requests to.
          auth_function: A function that takes no arguments and returns an
            (email, password) tuple when called. Will be called if
            authentication is required.
          host_override: The host header to send to the server
          (defaults to host).
          extra_headers: A dict of extra headers to append to every request.
          save_cookies: If True, save the authentication cookies to local disk.
            If False, use an in-memory cookiejar instead.  Subclasses must
            implement this functionality.  Defaults to False.
        """
        self.host = host
        self.host_override = host_override
        self.auth_function = auth_function
        self.authenticated = False
        self.extra_headers = extra_headers
        self.save_cookies = save_cookies
        self.opener = self._GetOpener()
        if self.host_override:
            logging.info("Server: %s; Host: %s", self.host, self.host_override)
        else:
            logging.info("Server: %s", self.host)

    def _GetOpener(self):
        """Returns an OpenerDirector for making HTTP requests.

        Returns:
          A urllib2.OpenerDirector object.
        """
        raise NotImplemented()

    def _CreateRequest(self, url, data=None):
        """Creates a new urllib request."""
        logging.debug("Creating request for: '%s' with payload:\n%s",
                      url, data)
        req = urllib2.Request(url, data=data)
        if self.host_override:
            req.add_header("Host", self.host_override)
        for key, value in self.extra_headers.iteritems():
            req.add_header(key, value)
        return req

    def _GetAuthToken(self, email, password):
        """Uses ClientLogin to authenticate the user, returning an auth token.

        Args:
          email:    The user's email address
          password: The user's password

        Raises:
          ClientLoginError: If there was an error authenticating
          with ClientLogin. HTTPError: If there was some other form
          of HTTP error.

        Returns:
          The authentication token returned by ClientLogin.
        """
        req = self._CreateRequest(
            url="https://www.google.com/accounts/ClientLogin",
            data=urllib.urlencode({
                "Email": email,
                "Passwd": password,
                "service": "ah",
                "source": "rietveld-codereview-upload",
                "accountType": "HOSTED_OR_GOOGLE"}))
        try:
            response = self.opener.open(req)
            response_body = response.read()
            response_dict = dict(x.split("=")
                                 for x in response_body.split("\n") if x)
            return response_dict["Auth"]
        except urllib2.HTTPError, e:
            if e.code == 403:
                body = e.read()
                response_dict = dict(x.split("=", 1)
                                     for x in body.split("\n") if x)
                raise ClientLoginError(req.get_full_url(), e.code, e.msg,
                                       e.headers, response_dict)
            else:
                raise

    def _GetAuthCookie(self, auth_token):
        """Fetches authentication cookies for an authentication token.

        Args:
          auth_token: The authentication token returned by ClientLogin.

        Raises:
          HTTPError: If there was an error fetching the authentication cookies.
        """
        # This is a dummy value to allow us to identify when we're successful.
        continue_location = "http://localhost/"
        args = {"continue": continue_location, "auth": auth_token}
        req = self._CreateRequest("http://%s/_ah/login?%s" %
                                  (self.host, urllib.urlencode(args)))
        try:
            response = self.opener.open(req)
        except urllib2.HTTPError, e:
            response = e
        if (response.code != 302 or
            response.info()["location"] != continue_location):
            raise urllib2.HTTPError(req.get_full_url(),
                                    response.code, response.msg,
                                    response.headers, response.fp)
        self.authenticated = True

    def _Authenticate(self):
        """Authenticates the user.

        The authentication process works as follows:
         1) We get a username and password from the user
         2) We use ClientLogin to obtain an AUTH token for the user
            (see http://code.google.com/apis/accounts/AuthForInstalledApps.
            html).
         3) We pass the auth token to /_ah/login on the server to obtain an
            authentication cookie. If login was successful, it tries
            to redirect us to the URL we provided.

        If we attempt to access the upload API without first obtaining an
        authentication cookie, it returns a 401 response and directs us to
        authenticate ourselves with ClientLogin.
        """
        for i in range(3):
            credentials = self.auth_function()
            try:
                auth_token = self._GetAuthToken(credentials[0], credentials[1])
            except ClientLoginError, e:
                if e.reason == "BadAuthentication":
                    print >>sys.stderr, "Invalid username or password."
                    continue
                if e.reason == "CaptchaRequired":
                    print >>sys.stderr, (
                        "Please go to\n"
                        "https://www.google.com/accounts/"
                        "DisplayUnlockCaptcha\n"
                        "and verify you are a human.  Then try again.")
                    break
                if e.reason == "NotVerified":
                    print >>sys.stderr, "Account not verified."
                    break
                if e.reason == "TermsNotAgreed":
                    print >>sys.stderr, "User has not agreed to TOS."
                    break
                if e.reason == "AccountDeleted":
                    print >>sys.stderr, "The user account has been deleted."
                    break
                if e.reason == "AccountDisabled":
                    print >>sys.stderr, "The user account has been disabled."
                    break
                if e.reason == "ServiceDisabled":
                    print >>sys.stderr, ("The user's access to the service "
                                         "has been disabled.")
                    break
                if e.reason == "ServiceUnavailable":
                    print >>sys.stderr, ("The service is not available; "
                                         "try again later.")
                    break
                raise
            self._GetAuthCookie(auth_token)
            return

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

        Args:
          request_path: The path to send the request to,
          eg /api/appversion/create.
          payload: The body of the request, or None to send an empty request.
          content_type: The Content-Type header to use.
          timeout: timeout in seconds; default None i.e. no timeout.
            (Note: for large requests on OS X, the timeout doesn't work right.)
          kwargs: Any keyword arguments are converted into query string
          parameters.

        Returns:
          The response body, as a string.
        """
        # TODO: Don't require authentication.  Let the server say
        # whether it is necessary.
        if not self.authenticated:
            self._Authenticate()

        old_timeout = socket.getdefaulttimeout()
        socket.setdefaulttimeout(timeout)
        try:
            tries = 0
            while True:
                tries += 1
                args = dict(kwargs)
                url = "http://%s%s" % (self.host, request_path)
                if args:
                    url += "?" + urllib.urlencode(args)
                req = self._CreateRequest(url=url, data=payload)
                req.add_header("Content-Type", content_type)
                try:
                    f = self.opener.open(req)
                    response = f.read()
                    f.close()
                    return response
                except urllib2.HTTPError, e:
                    if tries > 3:
                        raise
                    elif e.code == 401:
                        self._Authenticate()
##           elif e.code >= 500 and e.code < 600:
##             # Server Error - try again.
##             continue
                    else:
                        raise
        finally:
            socket.setdefaulttimeout(old_timeout)


class HttpRpcServer(AbstractRpcServer):
    """Provides a simplified RPC-style interface for HTTP requests."""

    def _Authenticate(self):
        """Save the cookie jar after authentication."""
        super(HttpRpcServer, self)._Authenticate()
        if self.save_cookies:
            StatusUpdate("Saving authentication cookies to %s" %
                         self.cookie_file)
            self.cookie_jar.save()

    def _GetOpener(self):
        """Returns an OpenerDirector that supports cookies and
        ignores redirects.

        Returns:
          A urllib2.OpenerDirector object.
        """
        opener = urllib2.OpenerDirector()
        opener.add_handler(urllib2.ProxyHandler())
        opener.add_handler(urllib2.UnknownHandler())
        opener.add_handler(urllib2.HTTPHandler())
        opener.add_handler(urllib2.HTTPDefaultErrorHandler())
        opener.add_handler(urllib2.HTTPSHandler())
        opener.add_handler(urllib2.HTTPErrorProcessor())
        if self.save_cookies:
            self.cookie_file = os.path.expanduser(
                "~/.codereview_upload_cookies")
            self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
            if os.path.exists(self.cookie_file):
                try:
                    self.cookie_jar.load()
                    self.authenticated = True
                    StatusUpdate("Loaded authentication cookies from %s" %
                                 self.cookie_file)
                except cookielib.LoadError:
                    # Failed to load cookies - just ignore them.
                    pass
            else:
                # Create an empty cookie file with mode 600
                fd = os.open(self.cookie_file, os.O_CREAT, 0600)
                os.close(fd)
            # Always chmod the cookie file
            os.chmod(self.cookie_file, 0600)
        else:
            # Don't save cookies across runs of update.py.
            self.cookie_jar = cookielib.CookieJar()
        opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
        return opener


parser = optparse.OptionParser(usage="%prog [options] [-- svn_diff_options]")
parser.add_option("-q", "--quiet", action="store_const", const=0,
                  dest="verbose", help="Print errors only.")
parser.add_option("-v", "--verbose", action="store_const", const=2,
                  dest="verbose", default=1,
                  help="Print info level logs.")
parser.add_option("--noisy", action="store_const", const=3,
                  dest="verbose", help="Print all logs.")
parser.add_option("-s", "--server", action="store", dest="server",
                  default="codereview.appspot.com",
                  metavar="SERVER",
                  help="The server to upload to. The format is host[:port].")
parser.add_option("-e", "--email", action="store", dest="email",
                  metavar="EMAIL", default=None,
                  help="The username to use. Will prompt if omitted.")
parser.add_option("-H", "--host", action="store", dest="host",
                  metavar="HOST", default=None,
                  help="Overrides the Host header sent with all RPCs.")
parser.add_option("--no_cookies", action="store_false",
                  dest="save_cookies", default=True,
                  help="Do not save authentication cookies to local disk.")
parser.add_option("-m", "--message", action="store", dest="message",
                  metavar="MESSAGE", default=None,
                  help="A message to identify the patch. "
                       "Will prompt if omitted.")
parser.add_option("-i", "--issue", type="int", action="store",
                  metavar="ISSUE", default=None,
                  help="Issue number to which to add. Defaults to new issue.")


def GetRpcServer(options):
    """Returns an instance of an AbstractRpcServer.

    Returns:
      A new AbstractRpcServer, on which RPC calls can be made.
    """

    rpc_server_class = HttpRpcServer

    def GetUserCredentials():
        """Prompts the user for a username and password."""
        email = options.email
        if email is None:
            email = raw_input("Email: ").strip()
        password = getpass.getpass("Password for %s: " % email)
        return (email, password)

    # If this is the dev_appserver, use fake authentication.
    host = (options.host or options.server).lower()
    if host == "localhost" or host.startswith("localhost:"):
        email = options.email
        if email is None:
            email = "test@example.com"
            logging.info("Using debug user %s.  Override with --email" % email)
        server = rpc_server_class(
            options.server,
            lambda: (email, "password"),
            host_override=options.host,
            extra_headers={"Cookie":
                           'dev_appserver_login="%s:False"' % email},
            save_cookies=options.save_cookies)
        # Don't try to talk to ClientLogin.
        server.authenticated = True
        return server

    return rpc_server_class(options.server, GetUserCredentials,
                            host_override=options.host,
                            save_cookies=options.save_cookies)


def EncodeMultipartFormData(fields, files):
    """Encode form fields for multipart/form-data.

    Args:
      fields: A sequence of (name, value) elements for regular form fields.
      files: A sequence of (name, filename, value) elements for data to be
             uploaded as files.
    Returns:
      (content_type, body) ready for httplib.HTTP instance.

    Source:
      http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
    """
    BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
    CRLF = '\r\n'
    lines = []
    for (key, value) in fields:
        lines.append('--' + BOUNDARY)
        lines.append('Content-Disposition: form-data; name="%s"' % key)
        lines.append('')
        lines.append(value)
    for (key, filename, value) in files:
        lines.append('--' + BOUNDARY)
        lines.append(
            'Content-Disposition: form-data; name="%s"; filename="%s"' %
            (key, filename))
        lines.append('Content-Type: %s' % GetContentType(filename))
        lines.append('')
        lines.append(value)
    lines.append('--' + BOUNDARY + '--')
    lines.append('')
    body = CRLF.join(lines)
    content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
    return content_type, body


def GetContentType(filename):
    """Helper to guess the content-type from the filename."""
    return mimetypes.guess_type(filename)[0] or 'application/octet-stream'


def RunShell(command, args=(), silent_ok=False):
    logging.info("Running %s", command)
    stream = os.popen("%s %s" % (command, " ".join(args)), "r")
    data = stream.read()
    if stream.close():
        ErrorExit("Got error status from %s" % command)
    if not silent_ok and not data:
        ErrorExit("No output from %s" % command)
    return data


def GuessBase():
    info = RunShell("svn info")
    for line in info.splitlines():
        words = line.split()
        if len(words) == 2 and words[0] == "URL:":
            url = words[1]
            scheme, netloc, path, params, query, fragment = urlparse.urlparse(
                url)
            if netloc.endswith("svn.python.org"):
                if netloc == "svn.python.org":
                    if path.startswith("/projects/"):
                        path = path[9:]
                elif netloc != "pythondev@svn.python.org":
                    ErrorExit("Unrecognized Python URL: %s" % url)
                base = "http://svn.python.org/view/*checkout*%s/" % path
                logging.info("Guessed Python base = %s", base)
            elif netloc.endswith("svn.collab.net"):
                if path.startswith("/repos/"):
                    path = path[6:]
                base = "http://svn.collab.net/viewvc/*checkout*%s/" % path
                logging.info("Guessed CollabNet base = %s", base)
            elif netloc.endswith(".googlecode.com"):
                base = url + "/"
                if base.startswith("https"):
                    base = "http" + base[5:]
                logging.info("Guessed Google Code base = %s", base)
            elif url.startswith('svn+ssh://svn@code.fluendo.com'):
                url = url.replace('svn+ssh://svn@', 'https')
                path = path.replace('flumotion/flumotion', 'flumotion')
                base = "https://code.fluendo.com/flumotion/svn" + path
            else:
                ErrorExit("Unrecognized svn project root: %s" % url)
            return base
    ErrorExit("Can't find URL in output from svn info")


def RealMain(argv):
    logging.basicConfig(format=("%(asctime).19s %(levelname)s %(filename)s:"
                                "%(lineno)s %(message)s "))
    options, args = parser.parse_args(sys.argv[1:])
    global verbosity
    verbosity = options.verbose
    if verbosity >= 3:
        logging.getLogger().setLevel(logging.DEBUG)
    elif verbosity >= 2:
        logging.getLogger().setLevel(logging.INFO)
    base = GuessBase()
    CheckForUnknownFiles()
    data = RunShell("svn diff", args)
    count = 0
    for line in data.splitlines():
        if line.startswith("Index:"):
            count += 1
            logging.info(line)
    if not count:
        ErrorExit("No valid patches found in output from svn diff")
    if options.issue:
        prompt = "Message describing this patch set: "
    else:
        prompt = "New issue subject: "
    message = options.message or raw_input(prompt).strip()
    if not message:
        ErrorExit("A non-empty message is required")
    rpc_server = GetRpcServer(options)
    form_fields = [("base", base), ("subject", message)]
    if options.issue:
        form_fields.append(("issue", str(options.issue)))
    if options.email:
        form_fields.append(("user", options.email))
    ctype, body = EncodeMultipartFormData(form_fields,
                                          [("data", "data.diff", data)])
    response_body = rpc_server.Send("/upload", body, content_type=ctype)
    StatusUpdate(response_body)
    sys.exit(not response_body.startswith("Issue created."))


def CheckForUnknownFiles():
    status = RunShell("svn status --ignore-externals", silent_ok=True)
    unknown_files = []
    for line in status.split("\n"):
        if line and line[0] == "?":
            unknown_files.append(line)
    if unknown_files:
        print "The following files are not added to version control:"
        for line in unknown_files:
            print line
        prompt = "Are you sure to continue?(y/N) "
        answer = raw_input(prompt).strip()
        if answer != "y":
            ErrorExit("User aborted")


def main():
    try:
        RealMain(sys.argv)
    except KeyboardInterrupt:
        print
        StatusUpdate("Interrupted.")
        sys.exit(1)


if __name__ == "__main__":
    main()
