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 7See the ispy.ispy_api module for the external API. 8""" 9 10import collections 11import itertools 12import json 13import os 14import sys 15 16import image_tools 17 18 19_INVALID_EXPECTATION_CHARS = ['/', '\\', ' ', '"', '\''] 20 21 22def IsValidExpectationName(expectation_name): 23 return not any(c in _INVALID_EXPECTATION_CHARS for c in expectation_name) 24 25 26def 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 39def 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 53def 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 66class 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, max_keys=None, marker=None, delimiter=None): 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 max_keys: Integer. Specifies the maximum number of objects returned 299 marker: String. Only objects whose fullpath starts lexicographically 300 after marker (exclusively) will be returned 301 delimiter: String. Turns on directory mode, specifies characters 302 to be used as directory separators 303 304 Returns: 305 a list containing urls to all objects that started with 306 the prefix. 307 """ 308 return self.cloud_bucket.GetAllPaths( 309 prefix, max_keys=max_keys, marker=marker, delimiter=delimiter) 310