Python class for accessing Rietveld reviews.

A simple python class for accessing Rietveld issues. It's using the Rietveld
interface to lazily pull down review meta data as requested by the application.

R=agable@chromium.org, iannucci@chromium.org

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

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/reviewbot@216960 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/PRESUBMIT.py b/PRESUBMIT.py
index 5425c61..28ff4db 100644
--- a/PRESUBMIT.py
+++ b/PRESUBMIT.py
@@ -39,6 +39,12 @@
   finally:
     sys.path = sys_path_backup
 
+  output.extend(input_api.canned_checks.RunUnitTestsInDirectory(
+    input_api,
+    output_api,
+    '.',
+    whitelist=[r'.*_test\.py$']))
+
   return output
 
 
diff --git a/patching.py b/patching.py
new file mode 100644
index 0000000..d0cf3ed
--- /dev/null
+++ b/patching.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.
+
+# Copyright 2008 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Utility for parsing patches, originally inspired by rietveld source."""
+
+import re
+
+
+_CHUNK_RE = re.compile(r"""
+  @@
+  \s+
+  -
+  (?: (\d+) (?: , (\d+) )?)
+  \s+
+  \+
+  (?: (\d+) (?: , (\d+) )?)
+  \s+
+  @@
+""", re.VERBOSE)
+
+
+class PatchParseError(Exception):
+  """Raised on parse errors."""
+  pass
+
+
+def ParsePatchToLines(lines):
+  """Parses a patch from an iterable type.
+
+  Args:
+    lines: The lines to parse.
+
+  Returns:
+    None on error, otherwise a list of 3-tuples:
+    (old_line_no, new_line_no, line)
+
+    A line number can be None if it doesn't exist in the old/new file.
+  """
+  # Helper function that matches a hunk header and returns line numbers.
+  def match_hunk_start(line):
+    match = _CHUNK_RE.match(line)
+    if not match:
+      raise PatchParseError(line)
+    return (int(match.groups()[0]), int(match.groups()[2]))
+
+  iterator = lines.__iter__()
+  try:
+    # Skip leading lines until after we've seen one starting with '+++'.
+    while not iterator.next().startswith('+++'):
+      pass
+
+    # Parse first hunk header.
+    old_ln, new_ln = match_hunk_start(iterator.next())
+  except StopIteration:
+    return []
+
+  # Process the actual patch lines.
+  result = []
+  for line in iterator:
+    if line[0] == '@':
+      old_ln, new_ln = match_hunk_start(line)
+    elif line[0] == '-':
+      result.append((old_ln, None, line[1:]))
+      old_ln += 1
+    elif line[0] == '+':
+      result.append((None, new_ln, line[1:]))
+      new_ln += 1
+    elif line[0] == ' ':
+      result.append((old_ln, new_ln, line[1:]))
+      old_ln += 1
+      new_ln += 1
+    else:
+      raise PatchParseError(line)
+
+  return result
diff --git a/patching_test.py b/patching_test.py
new file mode 100755
index 0000000..88823cf
--- /dev/null
+++ b/patching_test.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python
+# 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 unittest
+
+import patching
+
+
+class TestParsePatchToLines(unittest.TestCase):
+  def test_empty(self):
+    self.assertEquals([], patching.ParsePatchToLines([]))
+
+  def test_prelude(self):
+    self.assertEquals([], patching.ParsePatchToLines("""
+Here goes some prelude.
+And it continues on for some while.
+
+--- file
++++ file
+""".splitlines()))
+
+  def test_bad_hunk_header(self):
+    with self.assertRaises(patching.PatchParseError):
+      patching.ParsePatchToLines("""
+--- file
++++ file
+@@ bad
+""".splitlines())
+
+  def test_multiple_files(self):
+    with self.assertRaises(patching.PatchParseError):
+      patching.ParsePatchToLines("""
+diff -urN test1/bar test2/bar
+--- test1/bar	2013-08-08 14:15:46.604119530 +0200
++++ test2/bar	2013-08-08 14:15:49.814145535 +0200
+@@ -1 +1 @@
+-foo
++bar
+diff -urN test1/foo test2/foo
+--- test1/foo	2013-08-08 14:15:36.044033982 +0200
++++ test2/foo	2013-08-08 14:15:42.204083886 +0200
+@@ -1 +1 @@
+-foo
++bar
+""".splitlines())
+
+  def test_simple(self):
+    self.assertEquals([
+        (1, 1, 'common line'),
+        (2, None, 'old line'),
+        (None, 2, 'new line'),
+        (3, 3, 'common line'),
+        ], patching.ParsePatchToLines("""
+--- old	2013-08-08 14:05:18.539090366 +0200
++++ new	2013-08-08 14:05:18.539090366 +0200
+@@ -1,3 +1,3 @@
+ common line
+-old line
++new line
+ common line
+""".splitlines()))
+
+  def test_multiple_hunks(self):
+    self.assertEquals([
+        (None, 1, 'prepended line'),
+        (1, 2, ''),
+        (2, 3, ''),
+        (3, 4, ''),
+        (8, 9, ''),
+        (9, 10, ''),
+        (10, 11, ''),
+        (None, 12, 'appended line'),
+        ], patching.ParsePatchToLines("""
+--- old	2013-08-08 14:10:10.391408985 +0200
++++ new	2013-08-08 14:10:15.511449623 +0200
+@@ -1,3 +1,4 @@
++prepended line
+ 
+ 
+ 
+@@ -8,3 +9,4 @@
+ 
+ 
+ 
++appended line
+""".splitlines()))
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/review.py b/review.py
new file mode 100644
index 0000000..9ad3ea7
--- /dev/null
+++ b/review.py
@@ -0,0 +1,72 @@
+# 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 json
+
+import patching
+import util
+
+
+class Patch(object):
+  """Helper class for lazily loading and parsing patch data."""
+
+  def __init__(self, rietveld, issue_id, patchset_id, patch_id):
+    self.rietveld = rietveld
+    self.issue_id = issue_id
+    self.patchset_id = patchset_id
+    self.patch_id = patch_id
+
+  @util.lazy_property
+  def raw(self):
+    return self.rietveld.post_data(
+        'download/issue%s_%s_%s.diff' %
+        (self.issue_id, self.patchset_id, self.patch_id))
+
+  @util.lazy_property
+  def lines(self):
+    return patching.ParsePatchToLines(self.raw.splitlines())
+
+
+class Review(object):
+  """Represents a code review.
+
+  Information from rietveld can be obtained via the following properties:
+   - |issue_id| is the issue identifier.
+   - |issue_data| contains issue meta data as retrieved from rietveld. The data
+     is pulled lazily from the rietveld API on first access.
+   - |patchsets| has lazily-pulled patchset meta data, indexed by patchset IDa.
+
+  The subclass may then do its processing and trigger any actions. In
+  particular, the |rietveld| object may be used to update rietveld issue state.
+  """
+  def __init__(self, rietveld, issue_id):
+    self.rietveld = rietveld
+    self.issue_id = issue_id
+
+  @util.lazy_property
+  def issue_data(self):
+    json_data = self.rietveld.post_data('api/%s?messages=true' % self.issue_id)
+    data = json.loads(json_data)
+    data['messages'] = [util.ObjectDict(msg) for msg in data['messages']]
+    return util.ObjectDict(data)
+
+  @util.lazy_property
+  def patchsets(self):
+    def retrieve_patchset(ps):
+      json_patchset_data = self.rietveld.post_data('api/%s/%s' %
+                                                   (self.issue_id, ps))
+      patchset_data = json.loads(json_patchset_data)
+
+      # Amend the files property so it can lazily load and return patch data.
+      for file_data in patchset_data.get('files', {}).values():
+        file_data['patch'] = Patch(self.rietveld, self.issue_id, ps,
+                                   file_data['id'])
+
+      return util.ObjectDict(patchset_data)
+
+    return util.LazyDict(retrieve_patchset)
+
+  @util.lazy_property
+  def latest_patchset(self):
+    return self.patchsets[self.issue_data.patchsets[-1]]