Implement interface to Rietveld.

This is a simple python interface based on Rietveld's HTTP interface, which is
being accessed via OAuth2 tokens generated for a service account authenticated
via an RSA key.

R=agable@chromium.org

Review URL: https://codereview.chromium.org/20495003

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/reviewbot@215841 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/model/__init__.py b/model/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/model/__init__.py
diff --git a/model/app_config.py b/model/app_config.py
new file mode 100644
index 0000000..c4e8ee3
--- /dev/null
+++ b/model/app_config.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from google.appengine.ext import ndb
+
+
+class AppConfig(ndb.Model):
+  """Application configuration data."""
+  client_id = ndb.TextProperty()
+  service_account_key = ndb.TextProperty()
+  server_url = ndb.TextProperty()
+  nickname = ndb.TextProperty()
+
+
+def get():
+  config = ndb.Key(AppConfig, 'config').get()
+  if config is None:
+    config = AppConfig(id='config')
+  return config
diff --git a/rietveld.py b/rietveld.py
new file mode 100644
index 0000000..0e3d405
--- /dev/null
+++ b/rietveld.py
@@ -0,0 +1,90 @@
+# Copyright (c) 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from oauth2client.client import SignedJwtAssertionCredentials
+import httplib2
+import model.app_config
+import urllib
+import util
+
+
+EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
+
+
+class RietveldRequestError(Exception):
+  """Raised on request errors."""
+  pass
+
+
+class Rietveld(object):
+  """Implements a Python API to access rietveld via HTTP.
+
+  Authentication is handled via an OAuth2 access token minted from an RSA key
+  associated with a service account (which can be created via the Google API
+  console). For this to work, the Rietveld instance to talk to must be
+  configured to allow the service account client ID as OAuth2 audience (see
+  Rietveld source). Both the RSA key and the server URL are provided via static
+  application configuration.
+  """
+
+  def __init__(self):
+    self.app_config = model.app_config.get()
+
+  @util.lazy_property
+  def http(self):
+    http = httplib2.Http()
+
+    creds = SignedJwtAssertionCredentials(self.app_config.client_id,
+                                          self.app_config.service_account_key,
+                                          EMAIL_SCOPE)
+    creds.authorize(http)
+    return http
+
+  @util.lazy_property
+  def xsrf_token(self):
+    return self.make_request('xsrf_token',
+                             headers={'X-Requesting-XSRF-Token': 1})
+
+  def make_request(self, req, *args, **kwargs):
+    resp, response = self.http.request(
+        '%s/%s' % (self.app_config.server_url, req), *args, **kwargs)
+    if resp.status != 200:
+      raise RietveldRequestError(
+          'Rietveld %s request failed: %s\n%s' %
+          (req, resp.status, str(resp)), resp, response)
+
+    return response
+
+  def post_data(self, req, payload=None):
+    actual_payload = dict(payload or {})
+    actual_payload['xsrf_token'] = self.xsrf_token
+
+    return self.make_request(req, method='POST',
+                             body=urllib.urlencode(actual_payload))
+
+  def post_issue_data(self, issue, req, payload):
+    return self.post_data('%s/%s' % (issue, req), payload)
+
+  def post_comment(self, issue, comment, submit_inline_comments=False):
+    publish_payload = {
+        'message_only': 0 if submit_inline_comments else 1,
+        'send_mail': 1,
+        'add_as_reviewer': 0,
+        'message': comment,
+        'no_redirect': 1,
+    }
+    self.post_issue_data(issue, 'publish', publish_payload)
+
+  def add_inline_comment(self, issue_id, patchset_id, patch_id, line, a_or_b,
+                         comment):
+    comment_payload = {
+        'snapshot': 'old' if a_or_b is 'a' else 'new',
+        'lineno': line,
+        'side': a_or_b,
+        'issue': issue_id,
+        'patchset': patchset_id,
+        'patch': patch_id,
+        'text': comment,
+    }
+    self.post_data('inline_draft', comment_payload)
diff --git a/util.py b/util.py
new file mode 100644
index 0000000..732bb61
--- /dev/null
+++ b/util.py
@@ -0,0 +1,68 @@
+# Copyright (c) 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import email.utils
+
+
+def lazy_property(func):
+  """A decorator for lazy properties."""
+  name = '__lazy_' + func.__name__
+
+  def get_property(self, *args, **kwargs):
+    if not hasattr(self, name):
+      setattr(self, name, func(self, *args, **kwargs))
+    return getattr(self, name)
+
+  return property(get_property)
+
+
+class LazyDict(object):
+  """A simple immutable and lazy dictionary.
+
+  This looks up the actual key values on first access to the key and caches the
+  value to speed up subsequent accesses.
+  """
+
+  def __init__(self, lookup_fn):
+    self.items = {}
+    self.lookup = lookup_fn
+
+  def __getitem__(self, name):
+    if name not in self.items.keys():
+      self.items[name] = self.lookup(name)
+    return self.items[name]
+
+
+class ObjectDict(object):
+  """Wraps a dictionary to allow value retrieval in dot notation."""
+
+  def __init__(self, data):
+    self.data = data
+
+  def __getitem__(self, name):
+    val = self.data[name]
+    if type(val) == dict:
+      return ObjectDict(val)
+    return val
+
+  def __getattr__(self, name):
+    try:
+      return self[name]
+    except KeyError as e:
+      raise AttributeError(e)
+
+  def __iter__(self):
+    return self.data.iterkeys()
+
+
+def get_emails(string):
+  """Normalizes a string containing a list of email recepients.
+
+  Takes a string in the format present in mail headers and returns a list of
+  email addresses. For example, the input
+      'test@example.com, committers <committers@chromium.org>
+  will produce this return value:
+      [ 'test@example.com', 'committers@chromium.org' ]
+  """
+  return [entry[1] for entry in email.utils.getaddresses([string])]