rebaseline_server: create ImagePairSet-- holds a number of ImagePairs to examine
authorcommit-bot@chromium.org <commit-bot@chromium.org@2bbb7eff-a529-9590-31e7-b0007b416f81>
Thu, 13 Feb 2014 17:17:05 +0000 (17:17 +0000)
committercommit-bot@chromium.org <commit-bot@chromium.org@2bbb7eff-a529-9590-31e7-b0007b416f81>
Thu, 13 Feb 2014 17:17:05 +0000 (17:17 +0000)
See https://goto.google.com/ChangingRbsJson and bug 1919 for additional context

BUG=skia:1919
NOTRY=True
R=rmistry@google.com

Author: epoger@google.com

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

git-svn-id: http://skia.googlecode.com/svn/trunk@13434 2bbb7eff-a529-9590-31e7-b0007b416f81

gm/rebaseline_server/column.py [new file with mode: 0644]
gm/rebaseline_server/imagepair.py
gm/rebaseline_server/imagepair_test.py
gm/rebaseline_server/imagepairset.py [new file with mode: 0644]
gm/rebaseline_server/imagepairset_test.py [new file with mode: 0755]

diff --git a/gm/rebaseline_server/column.py b/gm/rebaseline_server/column.py
new file mode 100644 (file)
index 0000000..7bce15a
--- /dev/null
@@ -0,0 +1,64 @@
+#!/usr/bin/python
+
+"""
+Copyright 2014 Google Inc.
+
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+
+ColumnHeaderFactory class (see class docstring for details)
+"""
+
+# Keys used within dictionary representation of each column header.
+KEY__HEADER_TEXT = 'headerText'
+KEY__HEADER_URL = 'headerUrl'
+KEY__IS_FILTERABLE = 'isFilterable'
+KEY__IS_SORTABLE = 'isSortable'
+KEY__VALUES_AND_COUNTS = 'valuesAndCounts'
+
+
+class ColumnHeaderFactory(object):
+  """Factory which assembles the header for a single column of data."""
+
+  def __init__(self, header_text, header_url=None,
+               is_filterable=True, is_sortable=True,
+               include_values_and_counts=True):
+    """
+    Args:
+      header_text: string; text the client should display within column header.
+      header_url: string; target URL if user clicks on column header.
+          If None, nothing to click on.
+      is_filterable: boolean; whether client should allow filtering on this
+          column.
+      is_sortable: boolean; whether client should allow sorting on this column.
+      include_values_and_counts: boolean; whether the set of values found
+          within this column, and their counts, should be available for the
+          client to display.
+    """
+    self._header_text = header_text
+    self._header_url = header_url
+    self._is_filterable = is_filterable
+    self._is_sortable = is_sortable
+    self._include_values_and_counts = include_values_and_counts
+
+  def create_as_dict(self, values_and_counts_dict=None):
+    """Creates the header for this column, in dictionary form.
+
+    Creates the header for this column in dictionary form, as needed when
+    constructing the JSON representation.  Uses the KEY__* constants as keys.
+
+    Args:
+      values_and_counts_dict: dictionary mapping each possible column value
+          to its count (how many entries in the column have this value), or
+          None if this information is not available.
+    """
+    asdict = {
+        KEY__HEADER_TEXT: self._header_text,
+        KEY__IS_FILTERABLE: self._is_filterable,
+        KEY__IS_SORTABLE: self._is_sortable,
+    }
+    if self._header_url:
+      asdict[KEY__HEADER_URL] = self._header_url
+    if self._include_values_and_counts and values_and_counts_dict:
+      asdict[KEY__VALUES_AND_COUNTS] = values_and_counts_dict
+    return asdict
index 1c71bd9..bba36fa 100644 (file)
@@ -12,18 +12,16 @@ ImagePair class (see class docstring for details)
 import posixpath
 
 # Keys used within ImagePair dictionary representations.
-KEY_DIFFERENCE_DATA = 'differenceData'
-KEY_EXPECTATIONS_DATA = 'expectationsData'
-KEY_EXTRA_COLUMN_VALUES = 'extraColumnValues'
-KEY_IMAGE_A_URL = 'imageAUrl'
-KEY_IMAGE_B_URL = 'imageBUrl'
-KEY_IS_DIFFERENT = 'isDifferent'
+KEY__DIFFERENCE_DATA = 'differenceData'
+KEY__EXPECTATIONS_DATA = 'expectations'
+KEY__EXTRA_COLUMN_VALUES = 'extraColumns'
+KEY__IMAGE_A_URL = 'imageAUrl'
+KEY__IMAGE_B_URL = 'imageBUrl'
+KEY__IS_DIFFERENT = 'isDifferent'
 
 
 class ImagePair(object):
-  """
-  Describes a pair of images, along with optional metadata (pixel difference
-  metrics, whether to ignore mismatches, etc.)
+  """Describes a pair of images, pixel difference info, and optional metadata.
   """
 
   def __init__(self, image_diff_db,
@@ -63,21 +61,21 @@ class ImagePair(object):
           actual_image_locator=imageB_relative_url)
 
   def as_dict(self):
-    """
-    Return a dictionary describing this ImagePair, as needed when constructing
-    the JSON representation.  Uses the KEY_* constants as keys.
+    """Returns a dictionary describing this ImagePair.
+
+    Uses the KEY__* constants as keys.
     """
     asdict = {
-        KEY_IMAGE_A_URL: self.imageA_relative_url,
-        KEY_IMAGE_B_URL: self.imageB_relative_url,
+        KEY__IMAGE_A_URL: self.imageA_relative_url,
+        KEY__IMAGE_B_URL: self.imageB_relative_url,
     }
     if self.expectations_dict:
-      asdict[KEY_EXPECTATIONS_DATA] = self.expectations_dict
+      asdict[KEY__EXPECTATIONS_DATA] = self.expectations_dict
     if self.extra_columns_dict:
-      asdict[KEY_EXTRA_COLUMN_VALUES] = self.extra_columns_dict
+      asdict[KEY__EXTRA_COLUMN_VALUES] = self.extra_columns_dict
     if self.diff_record and (self.diff_record.get_num_pixels_differing() > 0):
-      asdict[KEY_IS_DIFFERENT] = True
-      asdict[KEY_DIFFERENCE_DATA] = self.diff_record.as_dict()
+      asdict[KEY__IS_DIFFERENT] = True
+      asdict[KEY__DIFFERENCE_DATA] = self.diff_record.as_dict()
     else:
-      asdict[KEY_IS_DIFFERENT] = False
+      asdict[KEY__IS_DIFFERENT] = False
     return asdict
index fc1f275..d29438e 100755 (executable)
@@ -32,11 +32,11 @@ class ImagePairTest(unittest.TestCase):
     shutil.rmtree(self._temp_dir)
 
   def shortDescription(self):
-    """Tell unittest framework to not print docstrings for test cases."""
+    """Tells unittest framework to not print docstrings for test cases."""
     return None
 
   def test_endToEnd(self):
-    """Test ImagePair, using a real ImageDiffDB to download real images.
+    """Tests ImagePair, using a real ImageDiffDB to download real images.
 
     TODO(epoger): Either in addition to or instead of this end-to-end test,
     we should perform some tests using either:
@@ -65,7 +65,7 @@ class ImagePairTest(unittest.TestCase):
             },
             # expected output:
             {
-                'extraColumnValues': {
+                'extraColumns': {
                     'builder': 'MyBuilder',
                     'test': 'MyTest',
                 },
@@ -115,11 +115,11 @@ class ImagePairTest(unittest.TestCase):
                     'percentDifferingPixels': 100.00,
                     'weightedDiffMeasure': 66.66666666666667,
                 },
-                'expectationsData': {
+                'expectations': {
                     'bugs': [1001, 1002],
                     'ignoreFailure': True,
                 },
-                'extraColumnValues': {
+                'extraColumns': {
                     'builder': 'MyBuilder',
                     'test': 'MyTest',
                 },
diff --git a/gm/rebaseline_server/imagepairset.py b/gm/rebaseline_server/imagepairset.py
new file mode 100644 (file)
index 0000000..2e173f5
--- /dev/null
@@ -0,0 +1,122 @@
+#!/usr/bin/python
+
+"""
+Copyright 2014 Google Inc.
+
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+
+ImagePairSet class; see its docstring below.
+"""
+
+import column
+
+# Keys used within dictionary representation of ImagePairSet.
+KEY__COLUMNHEADERS = 'columnHeaders'
+KEY__IMAGEPAIRS = 'imagePairs'
+KEY__IMAGESETS = 'imageSets'
+KEY__IMAGESETS__BASE_URL = 'baseUrl'
+KEY__IMAGESETS__DESCRIPTION = 'description'
+
+DEFAULT_DESCRIPTIONS = ('setA', 'setB')
+
+
+class ImagePairSet(object):
+  """A collection of ImagePairs, representing two arbitrary sets of images.
+
+  These could be:
+  - images generated before and after a code patch
+  - expected and actual images for some tests
+  - or any other pairwise set of images.
+  """
+
+  def __init__(self, descriptions=None):
+    """
+    Args:
+      descriptions: a (string, string) tuple describing the two image sets.
+          If not specified, DEFAULT_DESCRIPTIONS will be used.
+    """
+    self._column_header_factories = {}
+    self._descriptions = descriptions or DEFAULT_DESCRIPTIONS
+    self._extra_column_tallies = {}  # maps column_id -> values
+                                     #                -> instances_per_value
+    self._image_pair_dicts = []
+
+  def add_image_pair(self, image_pair):
+    """Adds an ImagePair; this may be repeated any number of times."""
+    # Special handling when we add the first ImagePair...
+    if not self._image_pair_dicts:
+      self._base_url = image_pair.base_url
+
+    if image_pair.base_url != self._base_url:
+      raise Exception('added ImagePair with base_url "%s" instead of "%s"' % (
+          image_pair.base_url, self._base_url))
+    self._image_pair_dicts.append(image_pair.as_dict())
+    extra_columns_dict = image_pair.extra_columns_dict
+    if extra_columns_dict:
+      for column_id, value in extra_columns_dict.iteritems():
+        self._add_extra_column_entry(column_id, value)
+
+  def set_column_header_factory(self, column_id, column_header_factory):
+    """Overrides the default settings for one of the extraColumn headers.
+
+    Args:
+      column_id: string; unique ID of this column (must match a key within
+          an ImagePair's extra_columns dictionary)
+      column_header_factory: a ColumnHeaderFactory object
+    """
+    self._column_header_factories[column_id] = column_header_factory
+
+  def get_column_header_factory(self, column_id):
+    """Returns the ColumnHeaderFactory object for a particular extraColumn.
+
+    Args:
+      column_id: string; unique ID of this column (must match a key within
+          an ImagePair's extra_columns dictionary)
+    """
+    column_header_factory = self._column_header_factories.get(column_id, None)
+    if not column_header_factory:
+      column_header_factory = column.ColumnHeaderFactory(header_text=column_id)
+      self._column_header_factories[column_id] = column_header_factory
+    return column_header_factory
+
+  def _add_extra_column_entry(self, column_id, value):
+    """Records one column_id/value extraColumns pair found within an ImagePair.
+
+    We use this information to generate tallies within the column header
+    (how many instances we saw of a particular value, within a particular
+    extraColumn).
+    """
+    known_values_for_column = self._extra_column_tallies.get(column_id, None)
+    if not known_values_for_column:
+      known_values_for_column = {}
+      self._extra_column_tallies[column_id] = known_values_for_column
+    instances_of_this_value = known_values_for_column.get(value, 0)
+    instances_of_this_value += 1
+    known_values_for_column[value] = instances_of_this_value
+
+  def _column_headers_as_dict(self):
+    """Returns all column headers as a dictionary."""
+    asdict = {}
+    for column_id, values_for_column in self._extra_column_tallies.iteritems():
+      column_header_factory = self.get_column_header_factory(column_id)
+      asdict[column_id] = column_header_factory.create_as_dict(
+          values_for_column)
+    return asdict
+
+  def as_dict(self):
+    """Returns a dictionary describing this package of ImagePairs.
+
+    Uses the KEY__* constants as keys.
+    """
+    return {
+        KEY__COLUMNHEADERS: self._column_headers_as_dict(),
+        KEY__IMAGEPAIRS: self._image_pair_dicts,
+        KEY__IMAGESETS: [{
+            KEY__IMAGESETS__BASE_URL: self._base_url,
+            KEY__IMAGESETS__DESCRIPTION: self._descriptions[0],
+        }, {
+            KEY__IMAGESETS__BASE_URL: self._base_url,
+            KEY__IMAGESETS__DESCRIPTION: self._descriptions[1],
+        }],
+    }
diff --git a/gm/rebaseline_server/imagepairset_test.py b/gm/rebaseline_server/imagepairset_test.py
new file mode 100755 (executable)
index 0000000..8f1edfc
--- /dev/null
@@ -0,0 +1,173 @@
+#!/usr/bin/python
+
+"""
+Copyright 2014 Google Inc.
+
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+
+Test imagepairset.py
+"""
+
+# System-level imports
+import unittest
+
+# Local imports
+import column
+import imagepair
+import imagepairset
+
+
+BASE_URL_1 = 'http://base/url/1'
+BASE_URL_2 = 'http://base/url/2'
+IMAGEPAIR_1_AS_DICT = {
+    imagepair.KEY__EXTRA_COLUMN_VALUES: {
+        'builder': 'MyBuilder',
+        'test': 'test1',
+    },
+    imagepair.KEY__IMAGE_A_URL: 'test1/1111.png',
+    imagepair.KEY__IMAGE_B_URL: 'test1/1111.png',
+    imagepair.KEY__IS_DIFFERENT: False,
+}
+IMAGEPAIR_2_AS_DICT = {
+    imagepair.KEY__DIFFERENCE_DATA: {
+        'maxDiffPerChannel': [1, 2, 3],
+        'numDifferingPixels': 111,
+        'percentDifferingPixels': 22.222,
+        'weightedDiffMeasure': 33.333,
+    },
+    imagepair.KEY__EXTRA_COLUMN_VALUES: {
+        'builder': 'MyBuilder',
+        'test': 'test2',
+    },
+    imagepair.KEY__IMAGE_A_URL: 'test2/2222.png',
+    imagepair.KEY__IMAGE_B_URL: 'test2/22223.png',
+    imagepair.KEY__IS_DIFFERENT: True,
+}
+IMAGEPAIR_3_AS_DICT = {
+    imagepair.KEY__DIFFERENCE_DATA: {
+        'maxDiffPerChannel': [4, 5, 6],
+        'numDifferingPixels': 111,
+        'percentDifferingPixels': 44.444,
+        'weightedDiffMeasure': 33.333,
+    },
+    imagepair.KEY__EXPECTATIONS_DATA: {
+        'bugs': [1001, 1002],
+        'ignoreFailure': True,
+    },
+    imagepair.KEY__EXTRA_COLUMN_VALUES: {
+        'builder': 'MyBuilder',
+        'test': 'test3',
+    },
+    imagepair.KEY__IMAGE_A_URL: 'test3/3333.png',
+    imagepair.KEY__IMAGE_B_URL: 'test3/33334.png',
+    imagepair.KEY__IS_DIFFERENT: True,
+}
+SET_A_DESCRIPTION = 'expectations'
+SET_B_DESCRIPTION = 'actuals'
+
+
+class ImagePairSetTest(unittest.TestCase):
+
+  def setUp(self):
+    self.maxDiff = None  # do not truncate diffs when tests fail
+
+  def shortDescription(self):
+    """Tells unittest framework to not print docstrings for test cases."""
+    return None
+
+  def test_success(self):
+    """Assembles some ImagePairs into an ImagePairSet, and validates results.
+    """
+    image_pairs = [
+        MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_1_AS_DICT),
+        MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_2_AS_DICT),
+        MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_3_AS_DICT),
+    ]
+    expected_imageset_dict = {
+        'columnHeaders': {
+            'builder': {
+                'headerText': 'builder',
+                'isFilterable': True,
+                'isSortable': True,
+                'valuesAndCounts': {
+                    'MyBuilder': 3
+                },
+            },
+            'test': {
+                'headerText': 'which GM test',
+                'headerUrl': 'http://learn/about/gm/tests',
+                'isFilterable': True,
+                'isSortable': False,
+            },
+        },
+        'imagePairs': [
+            IMAGEPAIR_1_AS_DICT,
+            IMAGEPAIR_2_AS_DICT,
+            IMAGEPAIR_3_AS_DICT,
+        ],
+        'imageSets': [
+            {
+                'baseUrl': BASE_URL_1,
+                'description': SET_A_DESCRIPTION,
+            },
+            {
+                'baseUrl': BASE_URL_1,
+                'description': SET_B_DESCRIPTION,
+            },
+        ],
+    }
+
+    image_pair_set = imagepairset.ImagePairSet(
+        descriptions=(SET_A_DESCRIPTION, SET_B_DESCRIPTION))
+    for image_pair in image_pairs:
+      image_pair_set.add_image_pair(image_pair)
+    # The 'builder' column header uses the default settings,
+    # but the 'test' column header has manual adjustments.
+    image_pair_set.set_column_header_factory(
+        'test',
+        column.ColumnHeaderFactory(
+            header_text='which GM test',
+            header_url='http://learn/about/gm/tests',
+            is_filterable=True,
+            is_sortable=False,
+            include_values_and_counts=False))
+    self.assertEqual(image_pair_set.as_dict(), expected_imageset_dict)
+
+  def test_mismatched_base_url(self):
+    """Confirms that mismatched base_urls will cause an exception."""
+    image_pair_set = imagepairset.ImagePairSet()
+    image_pair_set.add_image_pair(
+        MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_1_AS_DICT))
+    image_pair_set.add_image_pair(
+        MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_2_AS_DICT))
+    with self.assertRaises(Exception):
+      image_pair_set.add_image_pair(
+          MockImagePair(base_url=BASE_URL_2,
+                        dict_to_return=IMAGEPAIR_3_AS_DICT))
+
+
+class MockImagePair(object):
+  """Mock ImagePair object, which will return canned results."""
+  def __init__(self, base_url, dict_to_return):
+    """
+    Args:
+      base_url: base_url attribute for this object
+      dict_to_return: dictionary to return from as_dict()
+    """
+    self.base_url = base_url
+    self.extra_columns_dict = dict_to_return.get(
+        imagepair.KEY__EXTRA_COLUMN_VALUES, None)
+    self._dict_to_return = dict_to_return
+
+  def as_dict(self):
+    return self._dict_to_return
+
+
+def main():
+  suite = unittest.TestLoader().loadTestsFromTestCase(ImagePairSetTest)
+  unittest.TextTestRunner(verbosity=2).run(suite)
+
+
+if __name__ == '__main__':
+  main()