• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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