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