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