Home | History | Annotate | Download | only in coverage
      1 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
      2 # For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt
      3 
      4 """Command-line support for coverage.py."""
      5 
      6 import glob
      7 import optparse
      8 import os.path
      9 import sys
     10 import textwrap
     11 import traceback
     12 
     13 from coverage import env
     14 from coverage.execfile import run_python_file, run_python_module
     15 from coverage.misc import CoverageException, ExceptionDuringRun, NoSource
     16 from coverage.debug import info_formatter, info_header
     17 
     18 
     19 class Opts(object):
     20     """A namespace class for individual options we'll build parsers from."""
     21 
     22     append = optparse.make_option(
     23         '-a', '--append', action='store_true',
     24         help="Append coverage data to .coverage, otherwise it is started clean with each run.",
     25     )
     26     branch = optparse.make_option(
     27         '', '--branch', action='store_true',
     28         help="Measure branch coverage in addition to statement coverage.",
     29     )
     30     CONCURRENCY_CHOICES = [
     31         "thread", "gevent", "greenlet", "eventlet", "multiprocessing",
     32     ]
     33     concurrency = optparse.make_option(
     34         '', '--concurrency', action='store', metavar="LIB",
     35         choices=CONCURRENCY_CHOICES,
     36         help=(
     37             "Properly measure code using a concurrency library. "
     38             "Valid values are: %s."
     39         ) % ", ".join(CONCURRENCY_CHOICES),
     40     )
     41     debug = optparse.make_option(
     42         '', '--debug', action='store', metavar="OPTS",
     43         help="Debug options, separated by commas",
     44     )
     45     directory = optparse.make_option(
     46         '-d', '--directory', action='store', metavar="DIR",
     47         help="Write the output files to DIR.",
     48     )
     49     fail_under = optparse.make_option(
     50         '', '--fail-under', action='store', metavar="MIN", type="int",
     51         help="Exit with a status of 2 if the total coverage is less than MIN.",
     52     )
     53     help = optparse.make_option(
     54         '-h', '--help', action='store_true',
     55         help="Get help on this command.",
     56     )
     57     ignore_errors = optparse.make_option(
     58         '-i', '--ignore-errors', action='store_true',
     59         help="Ignore errors while reading source files.",
     60     )
     61     include = optparse.make_option(
     62         '', '--include', action='store',
     63         metavar="PAT1,PAT2,...",
     64         help=(
     65             "Include only files whose paths match one of these patterns. "
     66             "Accepts shell-style wildcards, which must be quoted."
     67         ),
     68     )
     69     pylib = optparse.make_option(
     70         '-L', '--pylib', action='store_true',
     71         help=(
     72             "Measure coverage even inside the Python installed library, "
     73             "which isn't done by default."
     74         ),
     75     )
     76     show_missing = optparse.make_option(
     77         '-m', '--show-missing', action='store_true',
     78         help="Show line numbers of statements in each module that weren't executed.",
     79     )
     80     skip_covered = optparse.make_option(
     81         '--skip-covered', action='store_true',
     82         help="Skip files with 100% coverage.",
     83     )
     84     omit = optparse.make_option(
     85         '', '--omit', action='store',
     86         metavar="PAT1,PAT2,...",
     87         help=(
     88             "Omit files whose paths match one of these patterns. "
     89             "Accepts shell-style wildcards, which must be quoted."
     90         ),
     91     )
     92     output_xml = optparse.make_option(
     93         '-o', '', action='store', dest="outfile",
     94         metavar="OUTFILE",
     95         help="Write the XML report to this file. Defaults to 'coverage.xml'",
     96     )
     97     parallel_mode = optparse.make_option(
     98         '-p', '--parallel-mode', action='store_true',
     99         help=(
    100             "Append the machine name, process id and random number to the "
    101             ".coverage data file name to simplify collecting data from "
    102             "many processes."
    103         ),
    104     )
    105     module = optparse.make_option(
    106         '-m', '--module', action='store_true',
    107         help=(
    108             "<pyfile> is an importable Python module, not a script path, "
    109             "to be run as 'python -m' would run it."
    110         ),
    111     )
    112     rcfile = optparse.make_option(
    113         '', '--rcfile', action='store',
    114         help="Specify configuration file.  Defaults to '.coveragerc'",
    115     )
    116     source = optparse.make_option(
    117         '', '--source', action='store', metavar="SRC1,SRC2,...",
    118         help="A list of packages or directories of code to be measured.",
    119     )
    120     timid = optparse.make_option(
    121         '', '--timid', action='store_true',
    122         help=(
    123             "Use a simpler but slower trace method.  Try this if you get "
    124             "seemingly impossible results!"
    125         ),
    126     )
    127     title = optparse.make_option(
    128         '', '--title', action='store', metavar="TITLE",
    129         help="A text string to use as the title on the HTML.",
    130     )
    131     version = optparse.make_option(
    132         '', '--version', action='store_true',
    133         help="Display version information and exit.",
    134     )
    135 
    136 
    137 class CoverageOptionParser(optparse.OptionParser, object):
    138     """Base OptionParser for coverage.py.
    139 
    140     Problems don't exit the program.
    141     Defaults are initialized for all options.
    142 
    143     """
    144 
    145     def __init__(self, *args, **kwargs):
    146         super(CoverageOptionParser, self).__init__(
    147             add_help_option=False, *args, **kwargs
    148             )
    149         self.set_defaults(
    150             action=None,
    151             append=None,
    152             branch=None,
    153             concurrency=None,
    154             debug=None,
    155             directory=None,
    156             fail_under=None,
    157             help=None,
    158             ignore_errors=None,
    159             include=None,
    160             module=None,
    161             omit=None,
    162             parallel_mode=None,
    163             pylib=None,
    164             rcfile=True,
    165             show_missing=None,
    166             skip_covered=None,
    167             source=None,
    168             timid=None,
    169             title=None,
    170             version=None,
    171             )
    172 
    173         self.disable_interspersed_args()
    174         self.help_fn = self.help_noop
    175 
    176     def help_noop(self, error=None, topic=None, parser=None):
    177         """No-op help function."""
    178         pass
    179 
    180     class OptionParserError(Exception):
    181         """Used to stop the optparse error handler ending the process."""
    182         pass
    183 
    184     def parse_args_ok(self, args=None, options=None):
    185         """Call optparse.parse_args, but return a triple:
    186 
    187         (ok, options, args)
    188 
    189         """
    190         try:
    191             options, args = \
    192                 super(CoverageOptionParser, self).parse_args(args, options)
    193         except self.OptionParserError:
    194             return False, None, None
    195         return True, options, args
    196 
    197     def error(self, msg):
    198         """Override optparse.error so sys.exit doesn't get called."""
    199         self.help_fn(msg)
    200         raise self.OptionParserError
    201 
    202 
    203 class GlobalOptionParser(CoverageOptionParser):
    204     """Command-line parser for coverage.py global option arguments."""
    205 
    206     def __init__(self):
    207         super(GlobalOptionParser, self).__init__()
    208 
    209         self.add_options([
    210             Opts.help,
    211             Opts.version,
    212         ])
    213 
    214 
    215 class CmdOptionParser(CoverageOptionParser):
    216     """Parse one of the new-style commands for coverage.py."""
    217 
    218     def __init__(self, action, options=None, defaults=None, usage=None, description=None):
    219         """Create an OptionParser for a coverage.py command.
    220 
    221         `action` is the slug to put into `options.action`.
    222         `options` is a list of Option's for the command.
    223         `defaults` is a dict of default value for options.
    224         `usage` is the usage string to display in help.
    225         `description` is the description of the command, for the help text.
    226 
    227         """
    228         if usage:
    229             usage = "%prog " + usage
    230         super(CmdOptionParser, self).__init__(
    231             usage=usage,
    232             description=description,
    233         )
    234         self.set_defaults(action=action, **(defaults or {}))
    235         if options:
    236             self.add_options(options)
    237         self.cmd = action
    238 
    239     def __eq__(self, other):
    240         # A convenience equality, so that I can put strings in unit test
    241         # results, and they will compare equal to objects.
    242         return (other == "<CmdOptionParser:%s>" % self.cmd)
    243 
    244     def get_prog_name(self):
    245         """Override of an undocumented function in optparse.OptionParser."""
    246         program_name = super(CmdOptionParser, self).get_prog_name()
    247 
    248         # Include the sub-command for this parser as part of the command.
    249         return "%(command)s %(subcommand)s" % {'command': program_name, 'subcommand': self.cmd}
    250 
    251 
    252 GLOBAL_ARGS = [
    253     Opts.debug,
    254     Opts.help,
    255     Opts.rcfile,
    256     ]
    257 
    258 CMDS = {
    259     'annotate': CmdOptionParser(
    260         "annotate",
    261         [
    262             Opts.directory,
    263             Opts.ignore_errors,
    264             Opts.include,
    265             Opts.omit,
    266             ] + GLOBAL_ARGS,
    267         usage="[options] [modules]",
    268         description=(
    269             "Make annotated copies of the given files, marking statements that are executed "
    270             "with > and statements that are missed with !."
    271         ),
    272     ),
    273 
    274     'combine': CmdOptionParser(
    275         "combine",
    276         GLOBAL_ARGS,
    277         usage="<path1> <path2> ... <pathN>",
    278         description=(
    279             "Combine data from multiple coverage files collected "
    280             "with 'run -p'.  The combined results are written to a single "
    281             "file representing the union of the data. The positional "
    282             "arguments are data files or directories containing data files. "
    283             "If no paths are provided, data files in the default data file's "
    284             "directory are combined."
    285         ),
    286     ),
    287 
    288     'debug': CmdOptionParser(
    289         "debug", GLOBAL_ARGS,
    290         usage="<topic>",
    291         description=(
    292             "Display information on the internals of coverage.py, "
    293             "for diagnosing problems. "
    294             "Topics are 'data' to show a summary of the collected data, "
    295             "or 'sys' to show installation information."
    296         ),
    297     ),
    298 
    299     'erase': CmdOptionParser(
    300         "erase", GLOBAL_ARGS,
    301         usage=" ",
    302         description="Erase previously collected coverage data.",
    303     ),
    304 
    305     'help': CmdOptionParser(
    306         "help", GLOBAL_ARGS,
    307         usage="[command]",
    308         description="Describe how to use coverage.py",
    309     ),
    310 
    311     'html': CmdOptionParser(
    312         "html",
    313         [
    314             Opts.directory,
    315             Opts.fail_under,
    316             Opts.ignore_errors,
    317             Opts.include,
    318             Opts.omit,
    319             Opts.title,
    320             ] + GLOBAL_ARGS,
    321         usage="[options] [modules]",
    322         description=(
    323             "Create an HTML report of the coverage of the files.  "
    324             "Each file gets its own page, with the source decorated to show "
    325             "executed, excluded, and missed lines."
    326         ),
    327     ),
    328 
    329     'report': CmdOptionParser(
    330         "report",
    331         [
    332             Opts.fail_under,
    333             Opts.ignore_errors,
    334             Opts.include,
    335             Opts.omit,
    336             Opts.show_missing,
    337             Opts.skip_covered,
    338             ] + GLOBAL_ARGS,
    339         usage="[options] [modules]",
    340         description="Report coverage statistics on modules."
    341     ),
    342 
    343     'run': CmdOptionParser(
    344         "run",
    345         [
    346             Opts.append,
    347             Opts.branch,
    348             Opts.concurrency,
    349             Opts.include,
    350             Opts.module,
    351             Opts.omit,
    352             Opts.pylib,
    353             Opts.parallel_mode,
    354             Opts.source,
    355             Opts.timid,
    356             ] + GLOBAL_ARGS,
    357         usage="[options] <pyfile> [program options]",
    358         description="Run a Python program, measuring code execution."
    359     ),
    360 
    361     'xml': CmdOptionParser(
    362         "xml",
    363         [
    364             Opts.fail_under,
    365             Opts.ignore_errors,
    366             Opts.include,
    367             Opts.omit,
    368             Opts.output_xml,
    369             ] + GLOBAL_ARGS,
    370         usage="[options] [modules]",
    371         description="Generate an XML report of coverage results."
    372     ),
    373 }
    374 
    375 
    376 OK, ERR, FAIL_UNDER = 0, 1, 2
    377 
    378 
    379 class CoverageScript(object):
    380     """The command-line interface to coverage.py."""
    381 
    382     def __init__(self, _covpkg=None, _run_python_file=None,
    383                  _run_python_module=None, _help_fn=None, _path_exists=None):
    384         # _covpkg is for dependency injection, so we can test this code.
    385         if _covpkg:
    386             self.covpkg = _covpkg
    387         else:
    388             import coverage
    389             self.covpkg = coverage
    390 
    391         # For dependency injection:
    392         self.run_python_file = _run_python_file or run_python_file
    393         self.run_python_module = _run_python_module or run_python_module
    394         self.help_fn = _help_fn or self.help
    395         self.path_exists = _path_exists or os.path.exists
    396         self.global_option = False
    397 
    398         self.coverage = None
    399 
    400         self.program_name = os.path.basename(sys.argv[0])
    401         if env.WINDOWS:
    402             # entry_points={'console_scripts':...} on Windows makes files
    403             # called coverage.exe, coverage3.exe, and coverage-3.5.exe. These
    404             # invoke coverage-script.py, coverage3-script.py, and
    405             # coverage-3.5-script.py.  argv[0] is the .py file, but we want to
    406             # get back to the original form.
    407             auto_suffix = "-script.py"
    408             if self.program_name.endswith(auto_suffix):
    409                 self.program_name = self.program_name[:-len(auto_suffix)]
    410 
    411     def command_line(self, argv):
    412         """The bulk of the command line interface to coverage.py.
    413 
    414         `argv` is the argument list to process.
    415 
    416         Returns 0 if all is well, 1 if something went wrong.
    417 
    418         """
    419         # Collect the command-line options.
    420         if not argv:
    421             self.help_fn(topic='minimum_help')
    422             return OK
    423 
    424         # The command syntax we parse depends on the first argument.  Global
    425         # switch syntax always starts with an option.
    426         self.global_option = argv[0].startswith('-')
    427         if self.global_option:
    428             parser = GlobalOptionParser()
    429         else:
    430             parser = CMDS.get(argv[0])
    431             if not parser:
    432                 self.help_fn("Unknown command: '%s'" % argv[0])
    433                 return ERR
    434             argv = argv[1:]
    435 
    436         parser.help_fn = self.help_fn
    437         ok, options, args = parser.parse_args_ok(argv)
    438         if not ok:
    439             return ERR
    440 
    441         # Handle help and version.
    442         if self.do_help(options, args, parser):
    443             return OK
    444 
    445         # Check for conflicts and problems in the options.
    446         if not self.args_ok(options, args):
    447             return ERR
    448 
    449         # We need to be able to import from the current directory, because
    450         # plugins may try to, for example, to read Django settings.
    451         sys.path[0] = ''
    452 
    453         # Listify the list options.
    454         source = unshell_list(options.source)
    455         omit = unshell_list(options.omit)
    456         include = unshell_list(options.include)
    457         debug = unshell_list(options.debug)
    458 
    459         # Do something.
    460         self.coverage = self.covpkg.coverage(
    461             data_suffix=options.parallel_mode,
    462             cover_pylib=options.pylib,
    463             timid=options.timid,
    464             branch=options.branch,
    465             config_file=options.rcfile,
    466             source=source,
    467             omit=omit,
    468             include=include,
    469             debug=debug,
    470             concurrency=options.concurrency,
    471             )
    472 
    473         if options.action == "debug":
    474             return self.do_debug(args)
    475 
    476         elif options.action == "erase":
    477             self.coverage.erase()
    478             return OK
    479 
    480         elif options.action == "run":
    481             return self.do_run(options, args)
    482 
    483         elif options.action == "combine":
    484             self.coverage.load()
    485             data_dirs = args or None
    486             self.coverage.combine(data_dirs)
    487             self.coverage.save()
    488             return OK
    489 
    490         # Remaining actions are reporting, with some common options.
    491         report_args = dict(
    492             morfs=unglob_args(args),
    493             ignore_errors=options.ignore_errors,
    494             omit=omit,
    495             include=include,
    496             )
    497 
    498         self.coverage.load()
    499 
    500         total = None
    501         if options.action == "report":
    502             total = self.coverage.report(
    503                 show_missing=options.show_missing,
    504                 skip_covered=options.skip_covered, **report_args)
    505         elif options.action == "annotate":
    506             self.coverage.annotate(
    507                 directory=options.directory, **report_args)
    508         elif options.action == "html":
    509             total = self.coverage.html_report(
    510                 directory=options.directory, title=options.title,
    511                 **report_args)
    512         elif options.action == "xml":
    513             outfile = options.outfile
    514             total = self.coverage.xml_report(outfile=outfile, **report_args)
    515 
    516         if total is not None:
    517             # Apply the command line fail-under options, and then use the config
    518             # value, so we can get fail_under from the config file.
    519             if options.fail_under is not None:
    520                 self.coverage.set_option("report:fail_under", options.fail_under)
    521 
    522             if self.coverage.get_option("report:fail_under"):
    523 
    524                 # Total needs to be rounded, but be careful of 0 and 100.
    525                 if 0 < total < 1:
    526                     total = 1
    527                 elif 99 < total < 100:
    528                     total = 99
    529                 else:
    530                     total = round(total)
    531 
    532                 if total >= self.coverage.get_option("report:fail_under"):
    533                     return OK
    534                 else:
    535                     return FAIL_UNDER
    536 
    537         return OK
    538 
    539     def help(self, error=None, topic=None, parser=None):
    540         """Display an error message, or the named topic."""
    541         assert error or topic or parser
    542         if error:
    543             print(error)
    544             print("Use '%s help' for help." % (self.program_name,))
    545         elif parser:
    546             print(parser.format_help().strip())
    547         else:
    548             help_params = dict(self.covpkg.__dict__)
    549             help_params['program_name'] = self.program_name
    550             help_msg = textwrap.dedent(HELP_TOPICS.get(topic, '')).strip()
    551             if help_msg:
    552                 print(help_msg % help_params)
    553             else:
    554                 print("Don't know topic %r" % topic)
    555 
    556     def do_help(self, options, args, parser):
    557         """Deal with help requests.
    558 
    559         Return True if it handled the request, False if not.
    560 
    561         """
    562         # Handle help.
    563         if options.help:
    564             if self.global_option:
    565                 self.help_fn(topic='help')
    566             else:
    567                 self.help_fn(parser=parser)
    568             return True
    569 
    570         if options.action == "help":
    571             if args:
    572                 for a in args:
    573                     parser = CMDS.get(a)
    574                     if parser:
    575                         self.help_fn(parser=parser)
    576                     else:
    577                         self.help_fn(topic=a)
    578             else:
    579                 self.help_fn(topic='help')
    580             return True
    581 
    582         # Handle version.
    583         if options.version:
    584             self.help_fn(topic='version')
    585             return True
    586 
    587         return False
    588 
    589     def args_ok(self, options, args):
    590         """Check for conflicts and problems in the options.
    591 
    592         Returns True if everything is OK, or False if not.
    593 
    594         """
    595         if options.action == "run" and not args:
    596             self.help_fn("Nothing to do.")
    597             return False
    598 
    599         return True
    600 
    601     def do_run(self, options, args):
    602         """Implementation of 'coverage run'."""
    603 
    604         if options.append and self.coverage.get_option("run:parallel"):
    605             self.help_fn("Can't append to data files in parallel mode.")
    606             return ERR
    607 
    608         if not self.coverage.get_option("run:parallel"):
    609             if not options.append:
    610                 self.coverage.erase()
    611 
    612         # Run the script.
    613         self.coverage.start()
    614         code_ran = True
    615         try:
    616             if options.module:
    617                 self.run_python_module(args[0], args)
    618             else:
    619                 filename = args[0]
    620                 self.run_python_file(filename, args)
    621         except NoSource:
    622             code_ran = False
    623             raise
    624         finally:
    625             self.coverage.stop()
    626             if code_ran:
    627                 if options.append:
    628                     data_file = self.coverage.get_option("run:data_file")
    629                     if self.path_exists(data_file):
    630                         self.coverage.combine(data_paths=[data_file])
    631                 self.coverage.save()
    632 
    633         return OK
    634 
    635     def do_debug(self, args):
    636         """Implementation of 'coverage debug'."""
    637 
    638         if not args:
    639             self.help_fn("What information would you like: data, sys?")
    640             return ERR
    641 
    642         for info in args:
    643             if info == 'sys':
    644                 sys_info = self.coverage.sys_info()
    645                 print(info_header("sys"))
    646                 for line in info_formatter(sys_info):
    647                     print(" %s" % line)
    648             elif info == 'data':
    649                 self.coverage.load()
    650                 data = self.coverage.data
    651                 print(info_header("data"))
    652                 print("path: %s" % self.coverage.data_files.filename)
    653                 if data:
    654                     print("has_arcs: %r" % data.has_arcs())
    655                     summary = data.line_counts(fullpath=True)
    656                     filenames = sorted(summary.keys())
    657                     print("\n%d files:" % len(filenames))
    658                     for f in filenames:
    659                         line = "%s: %d lines" % (f, summary[f])
    660                         plugin = data.file_tracer(f)
    661                         if plugin:
    662                             line += " [%s]" % plugin
    663                         print(line)
    664                 else:
    665                     print("No data collected")
    666             else:
    667                 self.help_fn("Don't know what you mean by %r" % info)
    668                 return ERR
    669 
    670         return OK
    671 
    672 
    673 def unshell_list(s):
    674     """Turn a command-line argument into a list."""
    675     if not s:
    676         return None
    677     if env.WINDOWS:
    678         # When running coverage.py as coverage.exe, some of the behavior
    679         # of the shell is emulated: wildcards are expanded into a list of
    680         # file names.  So you have to single-quote patterns on the command
    681         # line, but (not) helpfully, the single quotes are included in the
    682         # argument, so we have to strip them off here.
    683         s = s.strip("'")
    684     return s.split(',')
    685 
    686 
    687 def unglob_args(args):
    688     """Interpret shell wildcards for platforms that need it."""
    689     if env.WINDOWS:
    690         globbed = []
    691         for arg in args:
    692             if '?' in arg or '*' in arg:
    693                 globbed.extend(glob.glob(arg))
    694             else:
    695                 globbed.append(arg)
    696         args = globbed
    697     return args
    698 
    699 
    700 HELP_TOPICS = {
    701     'help': """\
    702     Coverage.py, version %(__version__)s
    703     Measure, collect, and report on code coverage in Python programs.
    704 
    705     usage: %(program_name)s <command> [options] [args]
    706 
    707     Commands:
    708         annotate    Annotate source files with execution information.
    709         combine     Combine a number of data files.
    710         erase       Erase previously collected coverage data.
    711         help        Get help on using coverage.py.
    712         html        Create an HTML report.
    713         report      Report coverage stats on modules.
    714         run         Run a Python program and measure code execution.
    715         xml         Create an XML report of coverage results.
    716 
    717     Use "%(program_name)s help <command>" for detailed help on any command.
    718     For full documentation, see %(__url__)s
    719     """,
    720 
    721     'minimum_help': """\
    722     Code coverage for Python.  Use '%(program_name)s help' for help.
    723     """,
    724 
    725     'version': """\
    726     Coverage.py, version %(__version__)s.
    727     Documentation at %(__url__)s
    728     """,
    729 }
    730 
    731 
    732 def main(argv=None):
    733     """The main entry point to coverage.py.
    734 
    735     This is installed as the script entry point.
    736 
    737     """
    738     if argv is None:
    739         argv = sys.argv[1:]
    740     try:
    741         status = CoverageScript().command_line(argv)
    742     except ExceptionDuringRun as err:
    743         # An exception was caught while running the product code.  The
    744         # sys.exc_info() return tuple is packed into an ExceptionDuringRun
    745         # exception.
    746         traceback.print_exception(*err.args)
    747         status = ERR
    748     except CoverageException as err:
    749         # A controlled error inside coverage.py: print the message to the user.
    750         print(err)
    751         status = ERR
    752     except SystemExit as err:
    753         # The user called `sys.exit()`.  Exit with their argument, if any.
    754         if err.args:
    755             status = err.args[0]
    756         else:
    757             status = None
    758     return status
    759