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 = ['autoninja', '-C', build_dir, 'pdfium_test'] 390 RunCommandPropagateErr(cmd, stdout_has_errors=True, exit_status_on_error=1) 391 PrintErr('Done.') 392 393 def _MeasureCurrentBranch(self, run_label, build_dir): 394 PrintErr('Measuring...') 395 if self.args.num_workers > 1 and len(self.test_cases) > 1: 396 results = self._RunAsync(run_label, build_dir) 397 else: 398 results = self._RunSync(run_label, build_dir) 399 PrintErr('Done.') 400 401 return results 402 403 def _RunSync(self, run_label, build_dir): 404 """Profiles the test cases synchronously. 405 406 Args: 407 run_label: String to differentiate this version of the code in output 408 files from other versions. 409 build_dir: String with path to build directory 410 411 Returns: 412 A dict mapping each test case name to the profiling values for that 413 test case. 414 """ 415 results = {} 416 417 for test_case in self.test_cases: 418 result = self.RunSingleTestCase(run_label, build_dir, test_case) 419 if result is not None: 420 results[test_case] = result 421 422 return results 423 424 def _RunAsync(self, run_label, build_dir): 425 """Profiles the test cases asynchronously. 426 427 Uses as many workers as configured by --num-workers. 428 429 Args: 430 run_label: String to differentiate this version of the code in output 431 files from other versions. 432 build_dir: String with path to build directory 433 434 Returns: 435 A dict mapping each test case name to the profiling values for that 436 test case. 437 """ 438 results = {} 439 pool = multiprocessing.Pool(self.args.num_workers) 440 worker_func = functools.partial(RunSingleTestCaseParallel, self, run_label, 441 build_dir) 442 443 try: 444 # The timeout is a workaround for http://bugs.python.org/issue8296 445 # which prevents KeyboardInterrupt from working. 446 one_year_in_seconds = 3600 * 24 * 365 447 worker_results = ( 448 pool.map_async(worker_func, self.test_cases).get(one_year_in_seconds)) 449 for worker_result in worker_results: 450 test_case, result = worker_result 451 if result is not None: 452 results[test_case] = result 453 except KeyboardInterrupt: 454 pool.terminate() 455 sys.exit(1) 456 else: 457 pool.close() 458 459 pool.join() 460 461 return results 462 463 def RunSingleTestCase(self, run_label, build_dir, test_case): 464 """Profiles a single test case. 465 466 Args: 467 run_label: String to differentiate this version of the code in output 468 files from other versions. 469 build_dir: String with path to build directory 470 test_case: Path to the test case. 471 472 Returns: 473 The measured profiling value for that test case. 474 """ 475 command = [ 476 self.safe_measure_script_path, test_case, 477 '--build-dir=%s' % build_dir 478 ] 479 480 if self.args.interesting_section: 481 command.append('--interesting-section') 482 483 if self.args.profiler: 484 command.append('--profiler=%s' % self.args.profiler) 485 486 profile_file_path = self._GetProfileFilePath(run_label, test_case) 487 if profile_file_path: 488 command.append('--output-path=%s' % profile_file_path) 489 490 if self.args.png_dir: 491 command.append('--png') 492 493 if self.args.pages: 494 command.extend(['--pages', self.args.pages]) 495 496 output = RunCommandPropagateErr(command) 497 498 if output is None: 499 return None 500 501 if self.args.png_dir: 502 self._MoveImages(test_case, run_label) 503 504 # Get the time number as output, making sure it's just a number 505 output = output.strip() 506 if re.match('^[0-9]+$', output): 507 return int(output) 508 509 return None 510 511 def _MoveImages(self, test_case, run_label): 512 png_dir = os.path.join(self.args.png_dir, run_label) 513 if not os.path.exists(png_dir): 514 os.makedirs(png_dir) 515 516 test_case_dir, test_case_filename = os.path.split(test_case) 517 test_case_png_matcher = '%s.*.png' % test_case_filename 518 for output_png in glob.glob( 519 os.path.join(test_case_dir, test_case_png_matcher)): 520 shutil.move(output_png, png_dir) 521 522 def _GetProfileFilePath(self, run_label, test_case): 523 if self.args.output_dir: 524 output_filename = ( 525 'callgrind.out.%s.%s' % (test_case.replace('/', '_'), run_label)) 526 return os.path.join(self.args.output_dir, output_filename) 527 return None 528 529 def _DrawConclusions(self, times_before_branch, times_after_branch): 530 """Draws conclusions comparing results of test runs in two branches. 531 532 Args: 533 times_before_branch: A dict mapping each test case name to the 534 profiling values for that test case in the branch to be considered 535 as the baseline. 536 times_after_branch: A dict mapping each test case name to the 537 profiling values for that test case in the branch to be considered 538 as the new version. 539 540 Returns: 541 ComparisonConclusions with all test cases processed. 542 """ 543 conclusions = ComparisonConclusions(self.args.threshold_significant) 544 545 for test_case in sorted(self.test_cases): 546 before = times_before_branch.get(test_case) 547 after = times_after_branch.get(test_case) 548 conclusions.ProcessCase(test_case, before, after) 549 550 return conclusions 551 552 def _PrintConclusions(self, conclusions_dict): 553 """Prints the conclusions as the script output. 554 555 Depending on the script args, this can output a human or a machine-readable 556 version of the conclusions. 557 558 Args: 559 conclusions_dict: Dict to print returned from 560 ComparisonConclusions.GetOutputDict(). 561 """ 562 if self.args.machine_readable: 563 print(json.dumps(conclusions_dict)) 564 else: 565 PrintConclusionsDictHumanReadable( 566 conclusions_dict, colored=True, key=self.args.case_order) 567 568 def _CleanUp(self, conclusions): 569 """Removes profile output files for uninteresting cases. 570 571 Cases without significant regressions or improvements and considered 572 uninteresting. 573 574 Args: 575 conclusions: A ComparisonConclusions. 576 """ 577 if not self.args.output_dir: 578 return 579 580 if self.args.profiler != 'callgrind': 581 return 582 583 for case_result in conclusions.GetCaseResults().values(): 584 if case_result.rating not in [RATING_REGRESSION, RATING_IMPROVEMENT]: 585 self._CleanUpOutputFile('before', case_result.case_name) 586 self._CleanUpOutputFile('after', case_result.case_name) 587 588 def _CleanUpOutputFile(self, run_label, case_name): 589 """Removes one profile output file. 590 591 If the output file does not exist, fails silently. 592 593 Args: 594 run_label: String to differentiate a version of the code in output 595 files from other versions. 596 case_name: String identifying test case for which to remove the output 597 file. 598 """ 599 try: 600 os.remove(self._GetProfileFilePath(run_label, case_name)) 601 except OSError: 602 pass 603 604 605def main(): 606 parser = argparse.ArgumentParser() 607 parser.add_argument( 608 'input_paths', 609 nargs='+', 610 help='pdf files or directories to search for pdf files ' 611 'to run as test cases') 612 parser.add_argument( 613 '--branch-before', 614 help='git branch to use as "before" for comparison. ' 615 'Omitting this will use the current branch ' 616 'without uncommitted changes as the baseline.') 617 parser.add_argument( 618 '--branch-after', 619 help='git branch to use as "after" for comparison. ' 620 'Omitting this will use the current branch ' 621 'with uncommitted changes.') 622 parser.add_argument( 623 '--build-dir', 624 default=os.path.join('out', 'Release'), 625 help='relative path from the base source directory ' 626 'to the build directory') 627 parser.add_argument( 628 '--build-dir-before', 629 help='relative path from the base source directory ' 630 'to the build directory for the "before" branch, if ' 631 'different from the build directory for the ' 632 '"after" branch') 633 parser.add_argument( 634 '--cache-dir', 635 default=None, 636 help='directory with a new or preexisting cache for ' 637 'downloads. Default is to not use a cache.') 638 parser.add_argument( 639 '--this-repo', 640 action='store_true', 641 help='use the repository where the script is instead of ' 642 'checking out a temporary one. This is faster and ' 643 'does not require downloads, but although it ' 644 'restores the state of the local repo, if the ' 645 'script is killed or crashes the changes can remain ' 646 'stashed and you may be on another branch.') 647 parser.add_argument( 648 '--profiler', 649 default='callgrind', 650 help='which profiler to use. Supports callgrind, ' 651 'perfstat, and none. Default is callgrind.') 652 parser.add_argument( 653 '--interesting-section', 654 action='store_true', 655 help='whether to measure just the interesting section or ' 656 'the whole test harness. Limiting to only the ' 657 'interesting section does not work on Release since ' 658 'the delimiters are optimized out') 659 parser.add_argument( 660 '--pages', 661 help='selects some pages to be rendered. Page numbers ' 662 'are 0-based. "--pages A" will render only page A. ' 663 '"--pages A-B" will render pages A to B ' 664 '(inclusive).') 665 parser.add_argument( 666 '--num-workers', 667 default=multiprocessing.cpu_count(), 668 type=int, 669 help='run NUM_WORKERS jobs in parallel') 670 parser.add_argument( 671 '--output-dir', help='directory to write the profile data output files') 672 parser.add_argument( 673 '--png-dir', 674 default=None, 675 help='outputs pngs to the specified directory that can ' 676 'be compared with a static html generated. Will ' 677 'affect performance measurements.') 678 parser.add_argument( 679 '--png-threshold', 680 default=0.0, 681 type=float, 682 help='Requires --png-dir. Threshold above which a png ' 683 'is considered to have changed.') 684 parser.add_argument( 685 '--threshold-significant', 686 default=0.02, 687 type=float, 688 help='variations in performance above this factor are ' 689 'considered significant') 690 parser.add_argument( 691 '--machine-readable', 692 action='store_true', 693 help='whether to get output for machines. If enabled the ' 694 'output will be a json with the format specified in ' 695 'ComparisonConclusions.GetOutputDict(). Default is ' 696 'human-readable.') 697 parser.add_argument( 698 '--case-order', 699 default=None, 700 help='what key to use when sorting test cases in the ' 701 'output. Accepted values are "after", "before", ' 702 '"ratio" and "rating". Default is sorting by test ' 703 'case path.') 704 705 args = parser.parse_args() 706 707 # Always start at the pdfium src dir, which is assumed to be two level above 708 # this script. 709 pdfium_src_dir = os.path.join( 710 os.path.dirname(__file__), os.path.pardir, os.path.pardir) 711 os.chdir(pdfium_src_dir) 712 713 git = GitHelper() 714 715 if args.branch_after and not args.branch_before: 716 PrintErr('--branch-after requires --branch-before to be specified.') 717 return 1 718 719 if args.branch_after and not git.BranchExists(args.branch_after): 720 PrintErr('Branch "%s" does not exist' % args.branch_after) 721 return 1 722 723 if args.branch_before and not git.BranchExists(args.branch_before): 724 PrintErr('Branch "%s" does not exist' % args.branch_before) 725 return 1 726 727 if args.output_dir: 728 args.output_dir = os.path.expanduser(args.output_dir) 729 if not os.path.isdir(args.output_dir): 730 PrintErr('"%s" is not a directory' % args.output_dir) 731 return 1 732 733 if args.png_dir: 734 args.png_dir = os.path.expanduser(args.png_dir) 735 if not os.path.isdir(args.png_dir): 736 PrintErr('"%s" is not a directory' % args.png_dir) 737 return 1 738 739 if args.threshold_significant <= 0.0: 740 PrintErr('--threshold-significant should receive a positive float') 741 return 1 742 743 if args.png_threshold: 744 if not args.png_dir: 745 PrintErr('--png-threshold requires --png-dir to be specified.') 746 return 1 747 748 if args.png_threshold <= 0.0: 749 PrintErr('--png-threshold should receive a positive float') 750 return 1 751 752 if args.pages: 753 if not re.match(r'^\d+(-\d+)?$', args.pages): 754 PrintErr('Supported formats for --pages are "--pages 7" and ' 755 '"--pages 3-6"') 756 return 1 757 758 run = CompareRun(args) 759 return run.Run() 760 761 762if __name__ == '__main__': 763 sys.exit(main()) 764