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
6import glob
7import optparse
8import os.path
9import sys
10import textwrap
11import traceback
12
13from coverage import env
14from coverage.execfile import run_python_file, run_python_module
15from coverage.misc import CoverageException, ExceptionDuringRun, NoSource
16from coverage.debug import info_formatter, info_header
17
18
19class 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
137class 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
203class 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
215class 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
252GLOBAL_ARGS = [
253    Opts.debug,
254    Opts.help,
255    Opts.rcfile,
256    ]
257
258CMDS = {
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
376OK, ERR, FAIL_UNDER = 0, 1, 2
377
378
379class 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
673def 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
687def 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
700HELP_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
732def 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