| OLD | NEW |
| (Empty) |
| 1 # Copyright 2013 The Chromium Authors. All rights reserved. | |
| 2 # Use of this source code is governed by a BSD-style license that can be | |
| 3 # found in the LICENSE file. | |
| 4 | |
| 5 """Internal utilities for managing I-Spy test results in Google Cloud Storage. | |
| 6 | |
| 7 See the ispy.ispy_api module for the external API. | |
| 8 """ | |
| 9 | |
| 10 import collections | |
| 11 import itertools | |
| 12 import json | |
| 13 import os | |
| 14 import sys | |
| 15 | |
| 16 import image_tools | |
| 17 | |
| 18 | |
| 19 _INVALID_EXPECTATION_CHARS = ['/', '\\', ' ', '"', '\''] | |
| 20 | |
| 21 | |
| 22 def IsValidExpectationName(expectation_name): | |
| 23 return not any(c in _INVALID_EXPECTATION_CHARS for c in expectation_name) | |
| 24 | |
| 25 | |
| 26 def GetExpectationPath(expectation, file_name=''): | |
| 27 """Get the path to a test file in the given test run and expectation. | |
| 28 | |
| 29 Args: | |
| 30 expectation: name of the expectation. | |
| 31 file_name: name of the file. | |
| 32 | |
| 33 Returns: | |
| 34 the path as a string relative to the bucket. | |
| 35 """ | |
| 36 return 'expectations/%s/%s' % (expectation, file_name) | |
| 37 | |
| 38 | |
| 39 def GetFailurePath(test_run, expectation, file_name=''): | |
| 40 """Get the path to a failure file in the given test run and test. | |
| 41 | |
| 42 Args: | |
| 43 test_run: name of the test run. | |
| 44 expectation: name of the expectation. | |
| 45 file_name: name of the file. | |
| 46 | |
| 47 Returns: | |
| 48 the path as a string relative to the bucket. | |
| 49 """ | |
| 50 return GetTestRunPath(test_run, '%s/%s' % (expectation, file_name)) | |
| 51 | |
| 52 | |
| 53 def GetTestRunPath(test_run, file_name=''): | |
| 54 """Get the path to a the given test run. | |
| 55 | |
| 56 Args: | |
| 57 test_run: name of the test run. | |
| 58 file_name: name of the file. | |
| 59 | |
| 60 Returns: | |
| 61 the path as a string relative to the bucket. | |
| 62 """ | |
| 63 return 'failures/%s/%s' % (test_run, file_name) | |
| 64 | |
| 65 | |
| 66 class ISpyUtils(object): | |
| 67 """Utility functions for working with an I-Spy google storage bucket.""" | |
| 68 | |
| 69 def __init__(self, cloud_bucket): | |
| 70 """Initialize with a cloud bucket instance to supply GS functionality. | |
| 71 | |
| 72 Args: | |
| 73 cloud_bucket: An object implementing the cloud_bucket.BaseCloudBucket | |
| 74 interface. | |
| 75 """ | |
| 76 self.cloud_bucket = cloud_bucket | |
| 77 | |
| 78 def UploadImage(self, full_path, image): | |
| 79 """Uploads an image to a location in GS. | |
| 80 | |
| 81 Args: | |
| 82 full_path: the path to the file in GS including the file extension. | |
| 83 image: a RGB PIL.Image to be uploaded. | |
| 84 """ | |
| 85 self.cloud_bucket.UploadFile( | |
| 86 full_path, image_tools.EncodePNG(image), 'image/png') | |
| 87 | |
| 88 def DownloadImage(self, full_path): | |
| 89 """Downloads an image from a location in GS. | |
| 90 | |
| 91 Args: | |
| 92 full_path: the path to the file in GS including the file extension. | |
| 93 | |
| 94 Returns: | |
| 95 The downloaded RGB PIL.Image. | |
| 96 | |
| 97 Raises: | |
| 98 cloud_bucket.NotFoundError: if the path to the image is not valid. | |
| 99 """ | |
| 100 return image_tools.DecodePNG(self.cloud_bucket.DownloadFile(full_path)) | |
| 101 | |
| 102 def UpdateImage(self, full_path, image): | |
| 103 """Updates an existing image in GS, preserving permissions and metadata. | |
| 104 | |
| 105 Args: | |
| 106 full_path: the path to the file in GS including the file extension. | |
| 107 image: a RGB PIL.Image. | |
| 108 """ | |
| 109 self.cloud_bucket.UpdateFile(full_path, image_tools.EncodePNG(image)) | |
| 110 | |
| 111 def GenerateExpectation(self, expectation, images): | |
| 112 """Creates and uploads an expectation to GS from a set of images and name. | |
| 113 | |
| 114 This method generates a mask from the uploaded images, then | |
| 115 uploads the mask and first of the images to GS as a expectation. | |
| 116 | |
| 117 Args: | |
| 118 expectation: name for this expectation, any existing expectation with the | |
| 119 name will be replaced. | |
| 120 images: a list of RGB encoded PIL.Images | |
| 121 | |
| 122 Raises: | |
| 123 ValueError: if the expectation name is invalid. | |
| 124 """ | |
| 125 if not IsValidExpectationName(expectation): | |
| 126 raise ValueError("Expectation name contains an illegal character: %s." % | |
| 127 str(_INVALID_EXPECTATION_CHARS)) | |
| 128 | |
| 129 mask = image_tools.InflateMask(image_tools.CreateMask(images), 7) | |
| 130 self.UploadImage( | |
| 131 GetExpectationPath(expectation, 'expected.png'), images[0]) | |
| 132 self.UploadImage(GetExpectationPath(expectation, 'mask.png'), mask) | |
| 133 | |
| 134 def PerformComparison(self, test_run, expectation, actual): | |
| 135 """Runs an image comparison, and uploads discrepancies to GS. | |
| 136 | |
| 137 Args: | |
| 138 test_run: the name of the test_run. | |
| 139 expectation: the name of the expectation to use for comparison. | |
| 140 actual: an RGB-encoded PIL.Image that is the actual result. | |
| 141 | |
| 142 Raises: | |
| 143 cloud_bucket.NotFoundError: if the given expectation is not found. | |
| 144 ValueError: if the expectation name is invalid. | |
| 145 """ | |
| 146 if not IsValidExpectationName(expectation): | |
| 147 raise ValueError("Expectation name contains an illegal character: %s." % | |
| 148 str(_INVALID_EXPECTATION_CHARS)) | |
| 149 | |
| 150 expectation_tuple = self.GetExpectation(expectation) | |
| 151 if not image_tools.SameImage( | |
| 152 actual, expectation_tuple.expected, mask=expectation_tuple.mask): | |
| 153 self.UploadImage( | |
| 154 GetFailurePath(test_run, expectation, 'actual.png'), actual) | |
| 155 diff, diff_pxls = image_tools.VisualizeImageDifferences( | |
| 156 expectation_tuple.expected, actual, mask=expectation_tuple.mask) | |
| 157 self.UploadImage(GetFailurePath(test_run, expectation, 'diff.png'), diff) | |
| 158 self.cloud_bucket.UploadFile( | |
| 159 GetFailurePath(test_run, expectation, 'info.txt'), | |
| 160 json.dumps({ | |
| 161 'different_pixels': diff_pxls, | |
| 162 'fraction_different': | |
| 163 diff_pxls / float(actual.size[0] * actual.size[1])}), | |
| 164 'application/json') | |
| 165 | |
| 166 def GetExpectation(self, expectation): | |
| 167 """Returns the given expectation from GS. | |
| 168 | |
| 169 Args: | |
| 170 expectation: the name of the expectation to get. | |
| 171 | |
| 172 Returns: | |
| 173 A named tuple: 'Expectation', containing two images: expected and mask. | |
| 174 | |
| 175 Raises: | |
| 176 cloud_bucket.NotFoundError: if the test is not found in GS. | |
| 177 """ | |
| 178 Expectation = collections.namedtuple('Expectation', ['expected', 'mask']) | |
| 179 return Expectation(self.DownloadImage(GetExpectationPath(expectation, | |
| 180 'expected.png')), | |
| 181 self.DownloadImage(GetExpectationPath(expectation, | |
| 182 'mask.png'))) | |
| 183 | |
| 184 def ExpectationExists(self, expectation): | |
| 185 """Returns whether the given expectation exists in GS. | |
| 186 | |
| 187 Args: | |
| 188 expectation: the name of the expectation to check. | |
| 189 | |
| 190 Returns: | |
| 191 A boolean indicating whether the test exists. | |
| 192 """ | |
| 193 expected_image_exists = self.cloud_bucket.FileExists( | |
| 194 GetExpectationPath(expectation, 'expected.png')) | |
| 195 mask_image_exists = self.cloud_bucket.FileExists( | |
| 196 GetExpectationPath(expectation, 'mask.png')) | |
| 197 return expected_image_exists and mask_image_exists | |
| 198 | |
| 199 def FailureExists(self, test_run, expectation): | |
| 200 """Returns whether a failure for the expectation exists for the given run. | |
| 201 | |
| 202 Args: | |
| 203 test_run: the name of the test_run. | |
| 204 expectation: the name of the expectation that failed. | |
| 205 | |
| 206 Returns: | |
| 207 A boolean indicating whether the failure exists. | |
| 208 """ | |
| 209 actual_image_exists = self.cloud_bucket.FileExists( | |
| 210 GetFailurePath(test_run, expectation, 'actual.png')) | |
| 211 test_exists = self.ExpectationExists(expectation) | |
| 212 info_exists = self.cloud_bucket.FileExists( | |
| 213 GetFailurePath(test_run, expectation, 'info.txt')) | |
| 214 return test_exists and actual_image_exists and info_exists | |
| 215 | |
| 216 def RemoveExpectation(self, expectation): | |
| 217 """Removes an expectation and all associated failures with that test. | |
| 218 | |
| 219 Args: | |
| 220 expectation: the name of the expectation to remove. | |
| 221 """ | |
| 222 test_paths = self.cloud_bucket.GetAllPaths( | |
| 223 GetExpectationPath(expectation)) | |
| 224 for path in test_paths: | |
| 225 self.cloud_bucket.RemoveFile(path) | |
| 226 | |
| 227 def GenerateExpectationPinkOut(self, expectation, images, pint_out, rgb): | |
| 228 """Uploads an ispy-test to GS with the pink_out workaround. | |
| 229 | |
| 230 Args: | |
| 231 expectation: the name of the expectation to be uploaded. | |
| 232 images: a json encoded list of base64 encoded png images. | |
| 233 pink_out: an image. | |
| 234 RGB: a json list representing the RGB values of a color to mask out. | |
| 235 | |
| 236 Raises: | |
| 237 ValueError: if expectation name is invalid. | |
| 238 """ | |
| 239 if not IsValidExpectationName(expectation): | |
| 240 raise ValueError("Expectation name contains an illegal character: %s." % | |
| 241 str(_INVALID_EXPECTATION_CHARS)) | |
| 242 | |
| 243 # convert the pink_out into a mask | |
| 244 black = (0, 0, 0, 255) | |
| 245 white = (255, 255, 255, 255) | |
| 246 pink_out.putdata( | |
| 247 [black if px == (rgb[0], rgb[1], rgb[2], 255) else white | |
| 248 for px in pink_out.getdata()]) | |
| 249 mask = image_tools.CreateMask(images) | |
| 250 mask = image_tools.InflateMask(image_tools.CreateMask(images), 7) | |
| 251 combined_mask = image_tools.AddMasks([mask, pink_out]) | |
| 252 self.UploadImage(GetExpectationPath(expectation, 'expected.png'), images[0]) | |
| 253 self.UploadImage(GetExpectationPath(expectation, 'mask.png'), combined_mask) | |
| 254 | |
| 255 def RemoveFailure(self, test_run, expectation): | |
| 256 """Removes a failure from GS. | |
| 257 | |
| 258 Args: | |
| 259 test_run: the name of the test_run. | |
| 260 expectation: the expectation on which the failure to be removed occured. | |
| 261 """ | |
| 262 failure_paths = self.cloud_bucket.GetAllPaths( | |
| 263 GetFailurePath(test_run, expectation)) | |
| 264 for path in failure_paths: | |
| 265 self.cloud_bucket.RemoveFile(path) | |
| 266 | |
| 267 def GetFailure(self, test_run, expectation): | |
| 268 """Returns a given test failure's expected, diff, and actual images. | |
| 269 | |
| 270 Args: | |
| 271 test_run: the name of the test_run. | |
| 272 expectation: the name of the expectation the result corresponds to. | |
| 273 | |
| 274 Returns: | |
| 275 A named tuple: Failure containing three images: expected, diff, and | |
| 276 actual. | |
| 277 | |
| 278 Raises: | |
| 279 cloud_bucket.NotFoundError: if the result is not found in GS. | |
| 280 """ | |
| 281 expected = self.DownloadImage( | |
| 282 GetExpectationPath(expectation, 'expected.png')) | |
| 283 actual = self.DownloadImage( | |
| 284 GetFailurePath(test_run, expectation, 'actual.png')) | |
| 285 diff = self.DownloadImage( | |
| 286 GetFailurePath(test_run, expectation, 'diff.png')) | |
| 287 info = json.loads(self.cloud_bucket.DownloadFile( | |
| 288 GetFailurePath(test_run, expectation, 'info.txt'))) | |
| 289 Failure = collections.namedtuple( | |
| 290 'Failure', ['expected', 'diff', 'actual', 'info']) | |
| 291 return Failure(expected, diff, actual, info) | |
| 292 | |
| 293 def GetAllPaths(self, prefix): | |
| 294 """Gets urls to all files in GS whose path starts with a given prefix. | |
| 295 | |
| 296 Args: | |
| 297 prefix: the prefix to filter files in GS by. | |
| 298 | |
| 299 Returns: | |
| 300 a list containing urls to all objects that started with | |
| 301 the prefix. | |
| 302 """ | |
| 303 return self.cloud_bucket.GetAllPaths(prefix) | |
| 304 | |
| OLD | NEW |