1 #!/usr/bin/python 2 ''' 3 Copyright 2012 Google Inc. 4 5 Use of this source code is governed by a BSD-style license that can be 6 found in the LICENSE file. 7 ''' 8 9 ''' 10 Generates a visual diff of all pending changes in the local SVN (or git!) 11 checkout. 12 13 Launch with --help to see more information. 14 15 TODO(epoger): Now that this tool supports either git or svn, rename it. 16 TODO(epoger): Fix indentation in this file (2-space indents, not 4-space). 17 ''' 18 19 # common Python modules 20 import optparse 21 import os 22 import posixpath 23 import re 24 import shutil 25 import subprocess 26 import sys 27 import tempfile 28 import urllib2 29 30 # Imports from within Skia 31 # 32 # We need to add the 'gm' directory, so that we can import gm_json.py within 33 # that directory. That script allows us to parse the actual-results.json file 34 # written out by the GM tool. 35 # Make sure that the 'gm' dir is in the PYTHONPATH, but add it at the *end* 36 # so any dirs that are already in the PYTHONPATH will be preferred. 37 # 38 # This assumes that the 'gm' directory has been checked out as a sibling of 39 # the 'tools' directory containing this script, which will be the case if 40 # 'trunk' was checked out as a single unit. 41 GM_DIRECTORY = os.path.realpath( 42 os.path.join(os.path.dirname(os.path.dirname(__file__)), 'gm')) 43 if GM_DIRECTORY not in sys.path: 44 sys.path.append(GM_DIRECTORY) 45 import gm_json 46 import jsondiff 47 import svn 48 49 USAGE_STRING = 'Usage: %s [options]' 50 HELP_STRING = ''' 51 52 Generates a visual diff of all pending changes in the local SVN/git checkout. 53 54 This includes a list of all files that have been added, deleted, or modified 55 (as far as SVN/git knows about). For any image modifications, pixel diffs will 56 be generated. 57 58 ''' 59 60 IMAGE_FILENAME_RE = re.compile(gm_json.IMAGE_FILENAME_PATTERN) 61 62 TRUNK_PATH = os.path.join(os.path.dirname(__file__), os.pardir) 63 64 OPTION_DEST_DIR = '--dest-dir' 65 OPTION_PATH_TO_SKDIFF = '--path-to-skdiff' 66 OPTION_SOURCE_DIR = '--source-dir' 67 68 def RunCommand(command): 69 """Run a command, raising an exception if it fails. 70 71 @param command the command as a single string 72 """ 73 print 'running command [%s]...' % command 74 retval = os.system(command) 75 if retval is not 0: 76 raise Exception('command [%s] failed' % command) 77 78 def FindPathToSkDiff(user_set_path=None): 79 """Return path to an existing skdiff binary, or raise an exception if we 80 cannot find one. 81 82 @param user_set_path if None, the user did not specify a path, so look in 83 some likely places; otherwise, only check at this path 84 """ 85 if user_set_path is not None: 86 if os.path.isfile(user_set_path): 87 return user_set_path 88 raise Exception('unable to find skdiff at user-set path %s' % 89 user_set_path) 90 trunk_path = os.path.join(os.path.dirname(__file__), os.pardir) 91 92 extension = '' 93 if os.name is 'nt': 94 extension = '.exe' 95 96 possible_paths = [os.path.join(trunk_path, 'out', 'Release', 97 'skdiff' + extension), 98 os.path.join(trunk_path, 'out', 'Debug', 99 'skdiff' + extension)] 100 for try_path in possible_paths: 101 if os.path.isfile(try_path): 102 return try_path 103 raise Exception('cannot find skdiff in paths %s; maybe you need to ' 104 'specify the %s option or build skdiff?' % ( 105 possible_paths, OPTION_PATH_TO_SKDIFF)) 106 107 def _DownloadUrlToFile(source_url, dest_path): 108 """Download source_url, and save its contents to dest_path. 109 Raises an exception if there were any problems.""" 110 try: 111 reader = urllib2.urlopen(source_url) 112 writer = open(dest_path, 'wb') 113 writer.write(reader.read()) 114 writer.close() 115 except BaseException as e: 116 raise Exception( 117 '%s: unable to download source_url %s to dest_path %s' % ( 118 e, source_url, dest_path)) 119 120 def _CreateGSUrl(imagename, hash_type, hash_digest): 121 """Return the HTTP URL we can use to download this particular version of 122 the actually-generated GM image with this imagename. 123 124 imagename: name of the test image, e.g. 'perlinnoise_msaa4.png' 125 hash_type: string indicating the hash type used to generate hash_digest, 126 e.g. gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5 127 hash_digest: the hash digest of the image to retrieve 128 """ 129 return gm_json.CreateGmActualUrl( 130 test_name=IMAGE_FILENAME_RE.match(imagename).group(1), 131 hash_type=hash_type, 132 hash_digest=hash_digest) 133 134 def _CallJsonDiff(old_json_path, new_json_path, 135 old_flattened_dir, new_flattened_dir, 136 filename_prefix): 137 """Using jsondiff.py, write the images that differ between two GM 138 expectations summary files (old and new) into old_flattened_dir and 139 new_flattened_dir. 140 141 filename_prefix: prefix to prepend to filenames of all images we write 142 into the flattened directories 143 """ 144 json_differ = jsondiff.GMDiffer() 145 diff_dict = json_differ.GenerateDiffDict(oldfile=old_json_path, 146 newfile=new_json_path) 147 print 'Downloading %d before-and-after image pairs...' % len(diff_dict) 148 for (imagename, results) in diff_dict.iteritems(): 149 # TODO(epoger): Currently, this assumes that all images have been 150 # checksummed using gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5 151 152 old_checksum = results['old'] 153 if old_checksum: 154 old_image_url = _CreateGSUrl( 155 imagename=imagename, 156 hash_type=gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5, 157 hash_digest=old_checksum) 158 _DownloadUrlToFile( 159 source_url=old_image_url, 160 dest_path=os.path.join(old_flattened_dir, 161 filename_prefix + imagename)) 162 163 new_checksum = results['new'] 164 if new_checksum: 165 new_image_url = _CreateGSUrl( 166 imagename=imagename, 167 hash_type=gm_json.JSONKEY_HASHTYPE_BITMAP_64BITMD5, 168 hash_digest=new_checksum) 169 _DownloadUrlToFile( 170 source_url=new_image_url, 171 dest_path=os.path.join(new_flattened_dir, 172 filename_prefix + imagename)) 173 174 def _RunCommand(args): 175 """Run a command (from self._directory) and return stdout as a single 176 string. 177 178 @param args a list of arguments 179 """ 180 proc = subprocess.Popen(args, 181 stdout=subprocess.PIPE, 182 stderr=subprocess.PIPE) 183 (stdout, stderr) = proc.communicate() 184 if proc.returncode is not 0: 185 raise Exception('command "%s" failed: %s' % (args, stderr)) 186 return stdout 187 188 def _GitGetModifiedFiles(): 189 """Returns a list of locally modified files within the current working dir. 190 191 TODO(epoger): Move this into a git utility package? 192 """ 193 return _RunCommand(['git', 'ls-files', '-m']).splitlines() 194 195 def _GitExportBaseVersionOfFile(file_within_repo, dest_path): 196 """Retrieves a copy of the base version of a file within the repository. 197 198 @param file_within_repo path to the file within the repo whose base 199 version you wish to obtain 200 @param dest_path destination to which to write the base content 201 202 TODO(epoger): Move this into a git utility package? 203 """ 204 # TODO(epoger): Replace use of "git show" command with lower-level git 205 # commands? senorblanco points out that "git show" is a "porcelain" 206 # command, intended for human use, as opposed to the "plumbing" commands 207 # generally more suitable for scripting. (See 208 # http://git-scm.com/book/en/Git-Internals-Plumbing-and-Porcelain ) 209 # 210 # For now, though, "git show" is the most straightforward implementation 211 # I could come up with. I tried using "git cat-file", but I had trouble 212 # getting it to work as desired. 213 # Note that git expects / rather than \ as a path separator even on 214 # windows. 215 args = ['git', 'show', posixpath.join('HEAD:.', file_within_repo)] 216 with open(dest_path, 'wb') as outfile: 217 proc = subprocess.Popen(args, stdout=outfile) 218 proc.communicate() 219 if proc.returncode is not 0: 220 raise Exception('command "%s" failed' % args) 221 222 def SvnDiff(path_to_skdiff, dest_dir, source_dir): 223 """Generates a visual diff of all pending changes in source_dir. 224 225 @param path_to_skdiff 226 @param dest_dir existing directory within which to write results 227 @param source_dir 228 """ 229 # Validate parameters, filling in default values if necessary and possible. 230 path_to_skdiff = os.path.abspath(FindPathToSkDiff(path_to_skdiff)) 231 if not dest_dir: 232 dest_dir = tempfile.mkdtemp() 233 dest_dir = os.path.abspath(dest_dir) 234 235 os.chdir(source_dir) 236 svn_repo = svn.Svn('.') 237 using_svn = True 238 try: 239 svn_repo.GetInfo() 240 except: 241 using_svn = False 242 243 # Prepare temporary directories. 244 modified_flattened_dir = os.path.join(dest_dir, 'modified_flattened') 245 original_flattened_dir = os.path.join(dest_dir, 'original_flattened') 246 diff_dir = os.path.join(dest_dir, 'diffs') 247 for dir in [modified_flattened_dir, original_flattened_dir, diff_dir] : 248 shutil.rmtree(dir, ignore_errors=True) 249 os.mkdir(dir) 250 251 # Get a list of all locally modified (including added/deleted) files, 252 # descending subdirectories. 253 if using_svn: 254 modified_file_paths = svn_repo.GetFilesWithStatus( 255 svn.STATUS_ADDED | svn.STATUS_DELETED | svn.STATUS_MODIFIED) 256 else: 257 modified_file_paths = _GitGetModifiedFiles() 258 259 # For each modified file: 260 # 1. copy its current contents into modified_flattened_dir 261 # 2. copy its original contents into original_flattened_dir 262 for modified_file_path in modified_file_paths: 263 if modified_file_path.endswith('.json'): 264 # Special handling for JSON files, in the hopes that they 265 # contain GM result summaries. 266 original_file = tempfile.NamedTemporaryFile(delete = False) 267 original_file.close() 268 if using_svn: 269 svn_repo.ExportBaseVersionOfFile( 270 modified_file_path, original_file.name) 271 else: 272 _GitExportBaseVersionOfFile( 273 modified_file_path, original_file.name) 274 modified_dir = os.path.dirname(modified_file_path) 275 platform_prefix = (re.sub(re.escape(os.sep), '__', 276 os.path.splitdrive(modified_dir)[1]) 277 + '__') 278 _CallJsonDiff(old_json_path=original_file.name, 279 new_json_path=modified_file_path, 280 old_flattened_dir=original_flattened_dir, 281 new_flattened_dir=modified_flattened_dir, 282 filename_prefix=platform_prefix) 283 os.remove(original_file.name) 284 else: 285 dest_filename = re.sub(re.escape(os.sep), '__', modified_file_path) 286 # If the file had STATUS_DELETED, it won't exist anymore... 287 if os.path.isfile(modified_file_path): 288 shutil.copyfile(modified_file_path, 289 os.path.join(modified_flattened_dir, 290 dest_filename)) 291 if using_svn: 292 svn_repo.ExportBaseVersionOfFile( 293 modified_file_path, 294 os.path.join(original_flattened_dir, dest_filename)) 295 else: 296 _GitExportBaseVersionOfFile( 297 modified_file_path, 298 os.path.join(original_flattened_dir, dest_filename)) 299 300 # Run skdiff: compare original_flattened_dir against modified_flattened_dir 301 RunCommand('%s %s %s %s' % (path_to_skdiff, original_flattened_dir, 302 modified_flattened_dir, diff_dir)) 303 print '\nskdiff results are ready in file://%s/index.html' % diff_dir 304 305 def RaiseUsageException(): 306 raise Exception('%s\nRun with --help for more detail.' % ( 307 USAGE_STRING % __file__)) 308 309 def Main(options, args): 310 """Allow other scripts to call this script with fake command-line args. 311 """ 312 num_args = len(args) 313 if num_args != 0: 314 RaiseUsageException() 315 SvnDiff(path_to_skdiff=options.path_to_skdiff, dest_dir=options.dest_dir, 316 source_dir=options.source_dir) 317 318 if __name__ == '__main__': 319 parser = optparse.OptionParser(USAGE_STRING % '%prog' + HELP_STRING) 320 parser.add_option(OPTION_DEST_DIR, 321 action='store', type='string', default=None, 322 help='existing directory within which to write results; ' 323 'if not set, will create a temporary directory which ' 324 'will remain in place after this script completes') 325 parser.add_option(OPTION_PATH_TO_SKDIFF, 326 action='store', type='string', default=None, 327 help='path to already-built skdiff tool; if not set, ' 328 'will search for it in typical directories near this ' 329 'script') 330 parser.add_option(OPTION_SOURCE_DIR, 331 action='store', type='string', 332 default=os.path.join('expectations', 'gm'), 333 help='root directory within which to compare all ' + 334 'files; defaults to "%default"') 335 (options, args) = parser.parse_args() 336 Main(options, args) 337