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