Index: rietveld.py |
diff --git a/rietveld.py b/rietveld.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..6984d393423b7108070aa71b1743c9aab9fe8927 |
--- /dev/null |
+++ b/rietveld.py |
@@ -0,0 +1,204 @@ |
+# Copyright (c) 2011 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. |
+"""Defines class Rietveld to easily access a rietveld instance. |
+ |
+Security implications: |
+ |
+The following hypothesis are made: |
+- Rietveld enforces: |
+ - Nobody else than issue owner can upload a patch set |
+ - Verifies the issue owner credentials when creating new issues |
+ - A issue owner can't change once the issue is created |
+ - A patch set cannot be modified |
+""" |
+ |
+import logging |
+import os |
+import sys |
+import time |
+import urllib2 |
+ |
+try: |
+ import simplejson as json # pylint: disable=F0401 |
+except ImportError: |
+ try: |
+ import json # pylint: disable=F0401 |
+ except ImportError: |
+ # Import the one included in depot_tools. |
+ sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party')) |
+ import simplejson as json # pylint: disable=F0401 |
+ |
+from third_party import upload |
+import patch |
+ |
+# Hack out upload logging.info() |
+upload.logging = logging.getLogger('upload') |
+upload.logging.setLevel(logging.WARNING) |
+ |
+ |
+class Rietveld(object): |
+ """Accesses rietveld.""" |
+ def __init__(self, url, email, password): |
+ self.issue = None |
+ self.user = email |
+ self.url = url |
+ self._get_creds = lambda: (email, password) |
+ self._xsrf_token = None |
+ self._xsrf_token_time = None |
+ self.rpc_server = upload.HttpRpcServer( |
+ self.url, |
+ self._get_creds, |
+ save_cookies=False) |
+ |
+ def xsrf_token(self): |
+ if (not self._xsrf_token_time or |
+ (time.time() - self._xsrf_token_time) > 30*60): |
+ self._xsrf_token_time = time.time() |
+ self._xsrf_token = self.get( |
+ '/xsrf_token', |
+ extra_headers={'X-Requesting-XSRF-Token': '1'}) |
+ return self._xsrf_token |
+ |
+ def get_pending_issues(self): |
+ """Returns an array of dict of all the pending issues on the server.""" |
+ return json.loads(self.get( |
+ '/search?format=json&commit=True&closed=False&keys_only=True') |
+ )['results'] |
+ |
+ def close_issue(self, issue): |
+ """Closes the Rietveld issue for this changelist.""" |
+ logging.info('closing issue %s' % issue) |
+ self.post("/%d/close" % issue, [('xsrf_token', self.xsrf_token())]) |
+ |
+ def get_description(self, issue): |
+ """Returns the issue's description.""" |
+ return self.get('/%d/description' % issue) |
+ |
+ def get_issue_properties(self, issue, messages): |
+ """Returns all the issue's metadata as a dictionary.""" |
+ url = '/api/%s' % issue |
+ if messages: |
+ url += '?messages=true' |
+ return json.loads(self.get(url)) |
+ |
+ def get_patchset_properties(self, issue, patchset): |
+ """Returns the patchset properties.""" |
+ url = '/api/%s/%s' % (issue, patchset) |
+ return json.loads(self.get(url)) |
+ |
+ def get_file_content(self, issue, patchset, item): |
+ """Returns the content of a new file. |
+ |
+ Throws HTTP 302 exception if the file doesn't exist or is not a binary file. |
+ """ |
+ # content = 0 is the old file, 1 is the new file. |
+ content = 1 |
+ url = '/%s/image/%s/%s/%s' % (issue, patchset, item, content) |
+ return self.get(url) |
+ |
+ def get_file_diff(self, issue, patchset, item): |
+ """Returns the diff of the file. |
+ |
+ Returns a useless diff for binary files. |
+ """ |
+ url = '/download/issue%s_%s_%s.diff' % (issue, patchset, item) |
+ return self.get(url) |
+ |
+ def get_patch(self, issue, patchset): |
+ """Returns a PatchSet object containing the details to apply this patch.""" |
+ props = self.get_patchset_properties(issue, patchset) or {} |
+ out = [] |
+ for filename, state in props.get('files', {}).iteritems(): |
+ status = state.get('status') |
+ if status is None: |
+ raise patch.UnsupportedPatchFormat( |
+ filename, 'File\'s status is None, patchset upload is incomplete') |
+ |
+ # TODO(maruel): That's bad, it confuses property change. |
+ status = status.strip() |
+ |
+ if status == 'D': |
+ # Ignore the diff. |
+ out.append(patch.FilePatchDelete(filename, state['is_binary'])) |
+ elif status in ('A', 'M'): |
+ # TODO(maruel): Rietveld support is still weird, add this line once it's |
+ # safe to use. |
+ # props = state.get('property_changes', '').splitlines() or [] |
+ props = [] |
+ if state['is_binary']: |
+ out.append(patch.FilePatchBinary( |
+ filename, |
+ self.get_file_content(issue, patchset, state['id']), |
+ props)) |
+ else: |
+ if state['num_chunks']: |
+ diff = self.get_file_diff(issue, patchset, state['id']) |
+ else: |
+ diff = None |
+ out.append(patch.FilePatchDiff(filename, diff, props)) |
+ else: |
+ # Line too long (N/80) |
+ # pylint: disable=C0301 |
+ # TODO: Add support for MM, A+, etc. Rietveld removes the svn properties |
+ # from the diff. |
+ # Example of mergeinfo across branches: |
+ # http://codereview.chromium.org/202046/diff/1/third_party/libxml/xmlcatalog_dummy.cc |
+ # svn:eol-style property that is lost in the diff |
+ # http://codereview.chromium.org/202046/diff/1/third_party/libxml/xmllint_dummy.cc |
+ # Change with no diff, only svn property change: |
+ # http://codereview.chromium.org/6462019/ |
+ raise patch.UnsupportedPatchFormat(filename, status) |
+ return patch.PatchSet(out) |
+ |
+ def update_description(self, issue, description): |
+ """Sets the description for an issue on Rietveld.""" |
+ logging.info('new description for issue %s' % issue) |
+ self.post('/%s/description' % issue, [ |
+ ('description', description), |
+ ('xsrf_token', self.xsrf_token())]) |
+ |
+ def add_comment(self, issue, message): |
+ logging.info('issue %s; comment: %s' % (issue, message)) |
+ return self.post('/%s/publish' % issue, [ |
+ ('xsrf_token', self.xsrf_token()), |
+ ('message', message), |
+ ('message_only', 'True'), |
+ ('send_mail', 'True'), |
+ ('no_redirect', 'True')]) |
+ |
+ def set_flag(self, issue, patchset, flag, value): |
+ return self.post('/%s/edit_flags' % issue, [ |
+ ('last_patchset', str(patchset)), |
+ ('xsrf_token', self.xsrf_token()), |
+ (flag, value)]) |
+ |
+ def get(self, request_path, **kwargs): |
+ return self._send(request_path, payload=None, **kwargs) |
+ |
+ def post(self, request_path, data, **kwargs): |
+ ctype, body = upload.EncodeMultipartFormData(data, []) |
+ return self._send(request_path, payload=body, content_type=ctype, **kwargs) |
+ |
+ def _send(self, request_path, **kwargs): |
+ """Sends a POST/GET to Rietveld. Returns the response body.""" |
+ maxtries = 5 |
+ for retry in xrange(maxtries): |
+ try: |
+ result = self.rpc_server.Send(request_path, **kwargs) |
+ # Sometimes GAE returns a HTTP 200 but with HTTP 500 as the content. How |
+ # nice. |
+ return result |
+ except urllib2.HTTPError, e: |
+ if retry >= (maxtries - 1): |
+ raise |
+ if e.code not in (500, 502, 503): |
+ raise |
+ except urllib2.URLError, e: |
+ if retry >= (maxtries - 1): |
+ raise |
+ if not 'Name or service not known' in e.reason: |
+ # Usually internal GAE flakiness. |
+ raise |
+ # If reaching this line, loop again. Uses a small backoff. |
+ time.sleep(1+maxtries*2) |