1#!/usr/bin/env python 2# Copyright 2017 The PDFium Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5"""Compares the performance of two versions of the pdfium code.""" 6 7import argparse 8import functools 9import glob 10import json 11import multiprocessing 12import os 13import re 14import shutil 15import subprocess 16import sys 17import tempfile 18 19# pylint: disable=relative-import 20from common import GetBooleanGnArg 21from common import PrintErr 22from common import RunCommandPropagateErr 23from githelper import GitHelper 24from safetynet_conclusions import ComparisonConclusions 25from safetynet_conclusions import PrintConclusionsDictHumanReadable 26from safetynet_conclusions import RATING_IMPROVEMENT 27from safetynet_conclusions import RATING_REGRESSION 28from safetynet_image import ImageComparison 29 30 31def RunSingleTestCaseParallel(this, run_label, build_dir, test_case): 32 result = this.RunSingleTestCase(run_label, build_dir, test_case) 33 return (test_case, result) 34 35 36class CompareRun(object): 37 """A comparison between two branches of pdfium.""" 38 39 def __init__(self, args): 40 self.git = GitHelper() 41 self.args = args 42 self._InitPaths() 43 44 def _InitPaths(self): 45 if self.args.this_repo: 46 self.safe_script_dir = self.args.build_dir 47 else: 48 self.safe_script_dir = os.path.join('testing', 'tools') 49 50 self.safe_measure_script_path = os.path.abspath( 51 os.path.join(self.safe_script_dir, 'safetynet_measure.py')) 52 53 input_file_re = re.compile('^.+[.]pdf$') 54 self.test_cases = [] 55 for input_path in self.args.input_paths: 56 if os.path.isfile(input_path): 57 self.test_cases.append(input_path) 58 elif os.path.isdir(input_path): 59 for file_dir, _, filename_list in os.walk(input_path): 60 for input_filename in filename_list: 61 if input_file_re.match(input_filename): 62 file_path = os.path.join(file_dir, input_filename) 63 if os.path.isfile(file_path): 64 self.test_cases.append(file_path) 65 66 self.after_build_dir = self.args.build_dir 67 if self.args.build_dir_before: 68 self.before_build_dir = self.args.build_dir_before 69 else: 70 self.before_build_dir = self.after_build_dir 71 72 def Run(self): 73 """Runs comparison by checking out branches, building and measuring them. 74 75 Returns: 76 Exit code for the script. 77 """ 78 if self.args.this_repo: 79 self._FreezeMeasureScript() 80 81 if self.args.branch_after: 82 if self.args.this_repo: 83 before, after = self._ProfileTwoOtherBranchesInThisRepo( 84 self.args.branch_before, self.args.branch_after) 85 else: 86 before, after = self._ProfileTwoOtherBranches(self.args.branch_before, 87 self.args.branch_after) 88 elif self.args.branch_before: 89 if self.args.this_repo: 90 before, after = self._ProfileCurrentAndOtherBranchInThisRepo( 91 self.args.branch_before) 92 else: 93 before, after = self._ProfileCurrentAndOtherBranch( 94 self.args.branch_before) 95 else: 96 if self.args.this_repo: 97 before, after = self._ProfileLocalChangesAndCurrentBranchInThisRepo() 98 else: 99 before, after = self._ProfileLocalChangesAndCurrentBranch() 100 101 conclusions = self._DrawConclusions(before, after) 102 conclusions_dict = conclusions.GetOutputDict() 103 conclusions_dict.setdefault('metadata', {})['profiler'] = self.args.profiler 104 105 self._PrintConclusions(conclusions_dict) 106 107 self._CleanUp(conclusions) 108 109 if self.args.png_dir: 110 image_comparison = ImageComparison( 111 self.after_build_dir, self.args.png_dir, ('before', 'after'), 112 self.args.num_workers, self.args.png_threshold) 113 image_comparison.Run(open_in_browser=not self.args.machine_readable) 114 115 return 0 116 117 def _FreezeMeasureScript(self): 118 """Freezes a version of the measuring script. 119 120 This is needed to make sure we are comparing the pdfium library changes and 121 not script changes that may happen between the two branches. 122 """ 123 self.__FreezeFile(os.path.join('testing', 'tools', 'safetynet_measure.py')) 124 self.__FreezeFile(os.path.join('testing', 'tools', 'common.py')) 125 126 def __FreezeFile(self, filename): 127 RunCommandPropagateErr(['cp', filename, self.safe_script_dir], 128 exit_status_on_error=1) 129 130 def _ProfileTwoOtherBranchesInThisRepo(self, before_branch, after_branch): 131 """Profiles two branches that are not the current branch. 132 133 This is done in the local repository and changes may not be restored if the 134 script fails or is interrupted. 135 136 after_branch does not need to descend from before_branch, they will be 137 measured the same way 138 139 Args: 140 before_branch: One branch to profile. 141 after_branch: Other branch to profile. 142 143 Returns: 144 A tuple (before, after), where each of before and after is a dict 145 mapping a test case name to the profiling values for that test case 146 in the given branch. 147 """ 148 branch_to_restore = self.git.GetCurrentBranchName() 149 150 self._StashLocalChanges() 151 152 self._CheckoutBranch(after_branch) 153 self._BuildCurrentBranch(self.after_build_dir) 154 after = self._MeasureCurrentBranch('after', self.after_build_dir) 155 156 self._CheckoutBranch(before_branch) 157 self._BuildCurrentBranch(self.before_build_dir) 158 before = self._MeasureCurrentBranch('before', self.before_build_dir) 159 160 self._CheckoutBranch(branch_to_restore) 161 self._RestoreLocalChanges() 162 163 return before, after 164 165 def _ProfileTwoOtherBranches(self, before_branch, after_branch): 166 """Profiles two branches that are not the current branch. 167 168 This is done in new, cloned repositories, therefore it is safer but slower 169 and requires downloads. 170 171 after_branch does not need to descend from before_branch, they will be 172 measured the same way 173 174 Args: 175 before_branch: One branch to profile. 176 after_branch: Other branch to profile. 177 178 Returns: 179 A tuple (before, after), where each of before and after is a dict 180 mapping a test case name to the profiling values for that test case 181 in the given branch. 182 """ 183 after = self._ProfileSeparateRepo('after', self.after_build_dir, 184 after_branch) 185 before = self._ProfileSeparateRepo('before', self.before_build_dir, 186 before_branch) 187 return before, after 188 189 def _ProfileCurrentAndOtherBranchInThisRepo(self, other_branch): 190 """Profiles the current branch (with uncommitted changes) and another one. 191 192 This is done in the local repository and changes may not be restored if the 193 script fails or is interrupted. 194 195 The current branch does not need to descend from other_branch. 196 197 Args: 198 other_branch: Other branch to profile that is not the current. 199 200 Returns: 201 A tuple (before, after), where each of before and after is a dict 202 mapping a test case name to the profiling values for that test case 203 in the given branch. The current branch is considered to be "after" and 204 the other branch is considered to be "before". 205 """ 206 branch_to_restore = self.git.GetCurrentBranchName() 207 208 self._BuildCurrentBranch(self.after_build_dir) 209 after = self._MeasureCurrentBranch('after', self.after_build_dir) 210 211 self._StashLocalChanges() 212 213 self._CheckoutBranch(other_branch) 214 self._BuildCurrentBranch(self.before_build_dir) 215 before = self._MeasureCurrentBranch('before', self.before_build_dir) 216 217 self._CheckoutBranch(branch_to_restore) 218 self._RestoreLocalChanges() 219 220 return before, after 221 222 def _ProfileCurrentAndOtherBranch(self, other_branch): 223 """Profiles the current branch (with uncommitted changes) and another one. 224 225 This is done in new, cloned repositories, therefore it is safer but slower 226 and requires downloads. 227 228 The current branch does not need to descend from other_branch. 229 230 Args: 231 other_branch: Other branch to profile that is not the current. None will 232 compare to the same branch. 233 234 Returns: 235 A tuple (before, after), where each of before and after is a dict 236 mapping a test case name to the profiling values for that test case 237 in the given branch. The current branch is considered to be "after" and 238 the other branch is considered to be "before". 239 """ 240 self._BuildCurrentBranch(self.after_build_dir) 241 after = self._MeasureCurrentBranch('after', self.after_build_dir) 242 243 before = self._ProfileSeparateRepo('before', self.before_build_dir, 244 other_branch) 245 246 return before, after 247 248 def _ProfileLocalChangesAndCurrentBranchInThisRepo(self): 249 """Profiles the current branch with and without uncommitted changes. 250 251 This is done in the local repository and changes may not be restored if the 252 script fails or is interrupted. 253 254 Returns: 255 A tuple (before, after), where each of before and after is a dict 256 mapping a test case name to the profiling values for that test case 257 using the given version. The current branch without uncommitted changes is 258 considered to be "before" and with uncommitted changes is considered to be 259 "after". 260 """ 261 self._BuildCurrentBranch(self.after_build_dir) 262 after = self._MeasureCurrentBranch('after', self.after_build_dir) 263 264 pushed = self._StashLocalChanges() 265 if not pushed and not self.args.build_dir_before: 266 PrintErr('Warning: No local changes to compare') 267 268 before_build_dir = self.before_build_dir 269 270 self._BuildCurrentBranch(before_build_dir) 271 before = self._MeasureCurrentBranch('before', before_build_dir) 272 273 self._RestoreLocalChanges() 274 275 return before, after 276 277 def _ProfileLocalChangesAndCurrentBranch(self): 278 """Profiles the current branch with and without uncommitted changes. 279 280 This is done in new, cloned repositories, therefore it is safer but slower 281 and requires downloads. 282 283 Returns: 284 A tuple (before, after), where each of before and after is a dict 285 mapping a test case name to the profiling values for that test case 286 using the given version. The current branch without uncommitted changes is 287 considered to be "before" and with uncommitted changes is considered to be 288 "after". 289 """ 290 return self._ProfileCurrentAndOtherBranch(other_branch=None) 291 292 def _ProfileSeparateRepo(self, run_label, relative_build_dir, branch): 293 """Profiles a branch in a a temporary git repository. 294 295 Args: 296 run_label: String to differentiate this version of the code in output 297 files from other versions. 298 relative_build_dir: Path to the build dir in the current working dir to 299 clone build args from. 300 branch: Branch to checkout in the new repository. None will 301 profile the same branch checked out in the original repo. 302 Returns: 303 A dict mapping each test case name to the profiling values for that 304 test case. 305 """ 306 build_dir = self._CreateTempRepo('repo_%s' % run_label, relative_build_dir, 307 branch) 308 309 self._BuildCurrentBranch(build_dir) 310 return self._MeasureCurrentBranch(run_label, build_dir) 311 312 def _CreateTempRepo(self, dir_name, relative_build_dir, branch): 313 """Clones a temporary git repository out of the current working dir. 314 315 Args: 316 dir_name: Name for the temporary repository directory 317 relative_build_dir: Path to the build dir in the current working dir to 318 clone build args from. 319 branch: Branch to checkout in the new repository. None will keep checked 320 out the same branch as the local repo. 321 Returns: 322 Path to the build directory of the new repository. 323 """ 324 cwd = os.getcwd() 325 326 repo_dir = tempfile.mkdtemp(suffix='-%s' % dir_name) 327 src_dir = os.path.join(repo_dir, 'pdfium') 328 329 self.git.CloneLocal(os.getcwd(), src_dir) 330 331 if branch is not None: 332 os.chdir(src_dir) 333 self.git.Checkout(branch) 334 335 os.chdir(repo_dir) 336 PrintErr('Syncing...') 337 338 cmd = [ 339 'gclient', 'config', '--unmanaged', 340 'https://pdfium.googlesource.com/pdfium.git' 341 ] 342 if self.args.cache_dir: 343 cmd.append('--cache-dir=%s' % self.args.cache_dir) 344 RunCommandPropagateErr(cmd, exit_status_on_error=1) 345 346 RunCommandPropagateErr(['gclient', 'sync', '--force'], 347 exit_status_on_error=1) 348 349 PrintErr('Done.') 350 351 build_dir = os.path.join(src_dir, relative_build_dir) 352 os.makedirs(build_dir) 353 os.chdir(src_dir) 354 355 source_gn_args = os.path.join(cwd, relative_build_dir, 'args.gn') 356 dest_gn_args = os.path.join(build_dir, 'args.gn') 357 shutil.copy(source_gn_args, dest_gn_args) 358 359 RunCommandPropagateErr(['gn', 'gen', relative_build_dir], 360 exit_status_on_error=1) 361 362 os.chdir(cwd) 363 364 return build_dir 365 366 def _CheckoutBranch(self, branch): 367 PrintErr("Checking out branch '%s'" % branch) 368 self.git.Checkout(branch) 369 370 def _StashLocalChanges(self): 371 PrintErr('Stashing local changes') 372 return self.git.StashPush() 373 374 def _RestoreLocalChanges(self): 375 PrintErr('Restoring local changes') 376 self.git.StashPopAll() 377 378 def _BuildCurrentBranch(self, build_dir): 379 """Synchronizes and builds the current version of pdfium. 380 381 Args: 382 build_dir: String with path to build directory 383 """ 384 PrintErr('Syncing...') 385 RunCommandPropagateErr(['gclient', 'sync', '--force'], 386 exit_status_on_error=1) 387 PrintErr('Done.') 388 389 PrintErr('Building...') 390 cmd = ['ninja', '-C', build_dir, 'pdfium_test'] 391 if GetBooleanGnArg('use_goma', build_dir): 392 cmd.extend(['-j', '250']) 393 RunCommandPropagateErr(cmd, stdout_has_errors=True, exit_status_on_error=1) 394 PrintErr('Done.') 395 396 def _MeasureCurrentBranch(self, run_label, build_dir): 397 PrintErr('Measuring...') 398 if self.args.num_workers > 1 and len(self.test_cases) > 1: 399 results = self._RunAsync(run_label, build_dir) 400 else: 401 results = self._RunSync(run_label, build_dir) 402 PrintErr('Done.') 403 404 return results 405 406 def _RunSync(self, run_label, build_dir): 407 """Profiles the test cases synchronously. 408 409 Args: 410 run_label: String to differentiate this version of the code in output 411 files from other versions. 412 build_dir: String with path to build directory 413 414 Returns: 415 A dict mapping each test case name to the profiling values for that 416 test case. 417 """ 418 results = {} 419 420 for test_case in self.test_cases: 421 result = self.RunSingleTestCase(run_label, build_dir, test_case) 422 if result is not None: 423 results[test_case] = result 424 425 return results 426 427 def _RunAsync(self, run_label, build_dir): 428 """Profiles the test cases asynchronously. 429 430 Uses as many workers as configured by --num-workers. 431 432 Args: 433 run_label: String to differentiate this version of the code in output 434 files from other versions. 435 build_dir: String with path to build directory 436 437 Returns: 438 A dict mapping each test case name to the profiling values for that 439 test case. 440 """ 441 results = {} 442 pool = multiprocessing.Pool(self.args.num_workers) 443 worker_func = functools.partial(RunSingleTestCaseParallel, self, run_label, 444 build_dir) 445 446 try: 447 # The timeout is a workaround for http://bugs.python.org/issue8296 448 # which prevents KeyboardInterrupt from working. 449 one_year_in_seconds = 3600 * 24 * 365 450 worker_results = ( 451 pool.map_async(worker_func, self.test_cases).get(one_year_in_seconds)) 452 for worker_result in worker_results: 453 test_case, result = worker_result 454 if result is not None: 455 results[test_case] = result 456 except KeyboardInterrupt: 457 pool.terminate() 458 sys.exit(1) 459 else: 460 pool.close() 461 462 pool.join() 463 464 return results 465 466 def RunSingleTestCase(self, run_label, build_dir, test_case): 467 """Profiles a single test case. 468 469 Args: 470 run_label: String to differentiate this version of the code in output 471 files from other versions. 472 build_dir: String with path to build directory 473 test_case: Path to the test case. 474 475 Returns: 476 The measured profiling value for that test case. 477 """ 478 command = [ 479 self.safe_measure_script_path, test_case, 480 '--build-dir=%s' % build_dir 481 ] 482 483 if self.args.interesting_section: 484 command.append('--interesting-section') 485 486 if self.args.profiler: 487 command.append('--profiler=%s' % self.args.profiler) 488 489 profile_file_path = self._GetProfileFilePath(run_label, test_case) 490 if profile_file_path: 491 command.append('--output-path=%s' % profile_file_path) 492 493 if self.args.png_dir: 494 command.append('--png') 495 496 if self.args.pages: 497 command.extend(['--pages', self.args.pages]) 498 499 output = RunCommandPropagateErr(command) 500 501 if output is None: 502 return None 503 504 if self.args.png_dir: 505 self._MoveImages(test_case, run_label) 506 507 # Get the time number as output, making sure it's just a number 508 output = output.strip() 509 if re.match('^[0-9]+$', output): 510 return int(output) 511 512 return None 513 514 def _MoveImages(self, test_case, run_label): 515 png_dir = os.path.join(self.args.png_dir, run_label) 516 if not os.path.exists(png_dir): 517 os.makedirs(png_dir) 518 519 test_case_dir, test_case_filename = os.path.split(test_case) 520 test_case_png_matcher = '%s.*.png' % test_case_filename 521 for output_png in glob.glob( 522 os.path.join(test_case_dir, test_case_png_matcher)): 523 shutil.move(output_png, png_dir) 524 525 def _GetProfileFilePath(self, run_label, test_case): 526 if self.args.output_dir: 527 output_filename = ( 528 'callgrind.out.%s.%s' % (test_case.replace('/', '_'), run_label)) 529 return os.path.join(self.args.output_dir, output_filename) 530 else: 531 return None 532 533 def _DrawConclusions(self, times_before_branch, times_after_branch): 534 """Draws conclusions comparing results of test runs in two branches. 535 536 Args: 537 times_before_branch: A dict mapping each test case name to the 538 profiling values for that test case in the branch to be considered 539 as the baseline. 540 times_after_branch: A dict mapping each test case name to the 541 profiling values for that test case in the branch to be considered 542 as the new version. 543 544 Returns: 545 ComparisonConclusions with all test cases processed. 546 """ 547 conclusions = ComparisonConclusions(self.args.threshold_significant) 548 549 for test_case in sorted(self.test_cases): 550 before = times_before_branch.get(test_case) 551 after = times_after_branch.get(test_case) 552 conclusions.ProcessCase(test_case, before, after) 553 554 return conclusions 555 556 def _PrintConclusions(self, conclusions_dict): 557 """Prints the conclusions as the script output. 558 559 Depending on the script args, this can output a human or a machine-readable 560 version of the conclusions. 561 562 Args: 563 conclusions_dict: Dict to print returned from 564 ComparisonConclusions.GetOutputDict(). 565 """ 566 if self.args.machine_readable: 567 print json.dumps(conclusions_dict) 568 else: 569 PrintConclusionsDictHumanReadable( 570 conclusions_dict, colored=True, key=self.args.case_order) 571 572 def _CleanUp(self, conclusions): 573 """Removes profile output files for uninteresting cases. 574 575 Cases without significant regressions or improvements and considered 576 uninteresting. 577 578 Args: 579 conclusions: A ComparisonConclusions. 580 """ 581 if not self.args.output_dir: 582 return 583 584 if self.args.profiler != 'callgrind': 585 return 586 587 for case_result in conclusions.GetCaseResults().values(): 588 if case_result.rating not in [RATING_REGRESSION, RATING_IMPROVEMENT]: 589 self._CleanUpOutputFile('before', case_result.case_name) 590 self._CleanUpOutputFile('after', case_result.case_name) 591 592 def _CleanUpOutputFile(self, run_label, case_name): 593 """Removes one profile output file. 594 595 If the output file does not exist, fails silently. 596 597 Args: 598 run_label: String to differentiate a version of the code in output 599 files from other versions. 600 case_name: String identifying test case for which to remove the output 601 file. 602 """ 603 try: 604 os.remove(self._GetProfileFilePath(run_label, case_name)) 605 except OSError: 606 pass 607 608 609def main(): 610 parser = argparse.ArgumentParser() 611 parser.add_argument( 612 'input_paths', 613 nargs='+', 614 help='pdf files or directories to search for pdf files ' 615 'to run as test cases') 616 parser.add_argument( 617 '--branch-before', 618 help='git branch to use as "before" for comparison. ' 619 'Omitting this will use the current branch ' 620 'without uncommitted changes as the baseline.') 621 parser.add_argument( 622 '--branch-after', 623 help='git branch to use as "after" for comparison. ' 624 'Omitting this will use the current branch ' 625 'with uncommitted changes.') 626 parser.add_argument( 627 '--build-dir', 628 default=os.path.join('out', 'Release'), 629 help='relative path from the base source directory ' 630 'to the build directory') 631 parser.add_argument( 632 '--build-dir-before', 633 help='relative path from the base source directory ' 634 'to the build directory for the "before" branch, if ' 635 'different from the build directory for the ' 636 '"after" branch') 637 parser.add_argument( 638 '--cache-dir', 639 default=None, 640 help='directory with a new or preexisting cache for ' 641 'downloads. Default is to not use a cache.') 642 parser.add_argument( 643 '--this-repo', 644 action='store_true', 645 help='use the repository where the script is instead of ' 646 'checking out a temporary one. This is faster and ' 647 'does not require downloads, but although it ' 648 'restores the state of the local repo, if the ' 649 'script is killed or crashes the changes can remain ' 650 'stashed and you may be on another branch.') 651 parser.add_argument( 652 '--profiler', 653 default='callgrind', 654 help='which profiler to use. Supports callgrind, ' 655 'perfstat, and none. Default is callgrind.') 656 parser.add_argument( 657 '--interesting-section', 658 action='store_true', 659 help='whether to measure just the interesting section or ' 660 'the whole test harness. Limiting to only the ' 661 'interesting section does not work on Release since ' 662 'the delimiters are optimized out') 663 parser.add_argument( 664 '--pages', 665 help='selects some pages to be rendered. Page numbers ' 666 'are 0-based. "--pages A" will render only page A. ' 667 '"--pages A-B" will render pages A to B ' 668 '(inclusive).') 669 parser.add_argument( 670 '--num-workers', 671 default=multiprocessing.cpu_count(), 672 type=int, 673 help='run NUM_WORKERS jobs in parallel') 674 parser.add_argument( 675 '--output-dir', help='directory to write the profile data output files') 676 parser.add_argument( 677 '--png-dir', 678 default=None, 679 help='outputs pngs to the specified directory that can ' 680 'be compared with a static html generated. Will ' 681 'affect performance measurements.') 682 parser.add_argument( 683 '--png-threshold', 684 default=0.0, 685 type=float, 686 help='Requires --png-dir. Threshold above which a png ' 687 'is considered to have changed.') 688 parser.add_argument( 689 '--threshold-significant', 690 default=0.02, 691 type=float, 692 help='variations in performance above this factor are ' 693 'considered significant') 694 parser.add_argument( 695 '--machine-readable', 696 action='store_true', 697 help='whether to get output for machines. If enabled the ' 698 'output will be a json with the format specified in ' 699 'ComparisonConclusions.GetOutputDict(). Default is ' 700 'human-readable.') 701 parser.add_argument( 702 '--case-order', 703 default=None, 704 help='what key to use when sorting test cases in the ' 705 'output. Accepted values are "after", "before", ' 706 '"ratio" and "rating". Default is sorting by test ' 707 'case path.') 708 709 args = parser.parse_args() 710 711 # Always start at the pdfium src dir, which is assumed to be two level above 712 # this script. 713 pdfium_src_dir = os.path.join( 714 os.path.dirname(__file__), os.path.pardir, os.path.pardir) 715 os.chdir(pdfium_src_dir) 716 717 git = GitHelper() 718 719 if args.branch_after and not args.branch_before: 720 PrintErr('--branch-after requires --branch-before to be specified.') 721 return 1 722 723 if args.branch_after and not git.BranchExists(args.branch_after): 724 PrintErr('Branch "%s" does not exist' % args.branch_after) 725 return 1 726 727 if args.branch_before and not git.BranchExists(args.branch_before): 728 PrintErr('Branch "%s" does not exist' % args.branch_before) 729 return 1 730 731 if args.output_dir: 732 args.output_dir = os.path.expanduser(args.output_dir) 733 if not os.path.isdir(args.output_dir): 734 PrintErr('"%s" is not a directory' % args.output_dir) 735 return 1 736 737 if args.png_dir: 738 args.png_dir = os.path.expanduser(args.png_dir) 739 if not os.path.isdir(args.png_dir): 740 PrintErr('"%s" is not a directory' % args.png_dir) 741 return 1 742 743 if args.threshold_significant <= 0.0: 744 PrintErr('--threshold-significant should receive a positive float') 745 return 1 746 747 if args.png_threshold: 748 if not args.png_dir: 749 PrintErr('--png-threshold requires --png-dir to be specified.') 750 return 1 751 752 if args.png_threshold <= 0.0: 753 PrintErr('--png-threshold should receive a positive float') 754 return 1 755 756 if args.pages: 757 if not re.match(r'^\d+(-\d+)?$', args.pages): 758 PrintErr('Supported formats for --pages are "--pages 7" and ' 759 '"--pages 3-6"') 760 return 1 761 762 run = CompareRun(args) 763 return run.Run() 764 765 766if __name__ == '__main__': 767 sys.exit(main()) 768