Initial Contribution
diff --git a/codereview/proto_client.py b/codereview/proto_client.py
new file mode 100755
index 0000000..e11beff
--- /dev/null
+++ b/codereview/proto_client.py
@@ -0,0 +1,349 @@
+# Copyright 2007, 2008 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.
+
+import base64
+import cookielib
+import getpass
+import logging
+import md5
+import os
+import random
+import socket
+import time
+import urllib
+import urllib2
+import urlparse
+
+from froofle.protobuf.service import RpcChannel
+from froofle.protobuf.service import RpcController
+from need_retry_pb2 import RetryRequestLaterResponse;
+
+class ClientLoginError(urllib2.HTTPError):
+  """Raised to indicate 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 Proxy(object):
+  class _ResultHolder(object):
+    def __call__(self, result):
+      self._result = result
+
+  class _RemoteController(RpcController):
+    def Reset(self):
+      pass
+  
+    def Failed(self):
+      pass
+  
+    def ErrorText(self):
+      pass
+  
+    def StartCancel(self):
+      pass
+  
+    def SetFailed(self, reason):
+      raise RuntimeError, reason
+  
+    def IsCancelled(self):
+      pass
+  
+    def NotifyOnCancel(self, callback):
+      pass
+  
+  def __init__(self, stub):
+    self._stub = stub
+
+  def __getattr__(self, key):
+    method = getattr(self._stub, key)
+
+    def call(request):
+      done = self._ResultHolder()
+      method(self._RemoteController(), request, done)
+      return done._result
+
+    return call
+
+
+class HttpRpc(RpcChannel):
+  """Simple protobuf over HTTP POST implementation."""
+
+  def __init__(self, host, auth_function,
+               host_override=None,
+               extra_headers={},
+               cookie_file=None):
+    """Creates a new HttpRpc.
+
+    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.
+      cookie_file: If not None, name of the file in ~/ to save the
+        cookie jar into.  Applications are encouraged to set this to
+        '.$appname_cookies' or some otherwise unique name.
+    """
+    self.host = host.lower()
+    self.host_override = host_override
+    self.auth_function = auth_function
+    self.authenticated = False
+    self.extra_headers = extra_headers
+    self.xsrf_token = None
+    if cookie_file is None:
+      self.cookie_file = None
+    else:
+      self.cookie_file = os.path.expanduser("~/%s" % cookie_file)
+    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 CallMethod(self, method, controller, request, response_type, done):
+    pat = "application/x-google-protobuf; name=%s"
+
+    url = "/proto/%s/%s" % (method.containing_service.name, method.name)
+    reqbin = request.SerializeToString()
+    reqtyp = pat % request.DESCRIPTOR.full_name
+    reqmd5 = base64.b64encode(md5.new(reqbin).digest())
+
+    start = time.time()
+    while True:
+      t, b = self._Send(url, reqbin, reqtyp, reqmd5)
+      if t == (pat % RetryRequestLaterResponse.DESCRIPTOR.full_name):
+        if time.time() >= (start + 1800):
+          controller.SetFailed("timeout")
+          return
+        s = random.uniform(0.250, 2.000)
+        print "Busy, retrying in %.3f seconds ..." % s
+        time.sleep(s)
+        continue
+
+      if t == (pat % response_type.DESCRIPTOR.full_name):
+        response = response_type()
+        response.ParseFromString(b)
+        done(response)
+      else:
+        controller.SetFailed("Unexpected %s response" % t)
+      break
+
+  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": "gerrit-codereview-client",
+            "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 _GetXsrfToken(self):
+    """Fetches /proto/_token for use in X-XSRF-Token HTTP header.
+
+    Raises:
+      HTTPError: If there was an error fetching a new token.
+    """
+    tries = 0
+    while True:
+      url = "http://%s/proto/_token" % self.host
+      req = self._CreateRequest(url)
+      try:
+        response = self.opener.open(req)
+        self.xsrf_token = response.read()
+        return
+      except urllib2.HTTPError, e:
+        if tries > 3:
+          raise
+        elif e.code == 401:
+          self._Authenticate()
+        else:
+          raise
+
+  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()
+      auth_token = self._GetAuthToken(credentials[0], credentials[1])
+      self._GetAuthCookie(auth_token)
+      if self.cookie_file is not None:
+        self.cookie_jar.save()
+      return
+
+  def _Send(self, request_path, payload, content_type, content_md5):
+    """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.
+      content_md5: The Content-MD5 header to use.
+
+    Returns:
+      The content type, as a string.
+      The response body, as a string.
+    """
+    if not self.authenticated:
+      self._Authenticate()
+    if not self.xsrf_token:
+      self._GetXsrfToken()
+
+    old_timeout = socket.getdefaulttimeout()
+    socket.setdefaulttimeout(None)
+    try:
+      tries = 0
+      while True:
+        tries += 1
+        url = "http://%s%s" % (self.host, request_path)
+        req = self._CreateRequest(url=url, data=payload)
+        req.add_header("Content-Type", content_type)
+        req.add_header("Content-MD5", content_md5)
+        req.add_header("X-XSRF-Token", self.xsrf_token)
+        try:
+          f = self.opener.open(req)
+          hdr = f.info()
+          type = hdr.getheader('Content-Type',
+                               'application/octet-stream')
+          response = f.read()
+          f.close()
+          return type, response
+        except urllib2.HTTPError, e:
+          if tries > 3:
+            raise
+          elif e.code == 401:
+            self._Authenticate()
+          elif e.code == 403:
+            if not hasattr(e, 'read'):
+              e.read = lambda self: ''
+            raise RuntimeError, '403\nxsrf: %s\n%s' \
+                  % (self.xsrf_token, e.read())
+          else:
+            raise
+    finally:
+      socket.setdefaulttimeout(old_timeout)
+
+  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.cookie_file is not None:
+      self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
+      if os.path.exists(self.cookie_file):
+        try:
+          self.cookie_jar.load()
+          self.authenticated = True
+        except (cookielib.LoadError, IOError):
+          # 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
+