| # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
| # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
| |
| """Central control stuff for coverage.py.""" |
| |
| from __future__ import annotations |
| |
| import atexit |
| import collections |
| import contextlib |
| import functools |
| import os |
| import os.path |
| import platform |
| import signal |
| import sys |
| import threading |
| import time |
| import warnings |
| |
| from types import FrameType |
| from typing import ( |
| cast, |
| Any, Callable, IO, Iterable, Iterator, List, |
| ) |
| |
| from coverage import env |
| from coverage.annotate import AnnotateReporter |
| from coverage.collector import Collector, HAS_CTRACER |
| from coverage.config import CoverageConfig, read_coverage_config |
| from coverage.context import should_start_context_test_function, combine_context_switchers |
| from coverage.data import CoverageData, combine_parallel_data |
| from coverage.debug import ( |
| DebugControl, NoDebugging, short_stack, write_formatted_info, relevant_environment_display, |
| ) |
| from coverage.disposition import disposition_debug_msg |
| from coverage.exceptions import ConfigError, CoverageException, CoverageWarning, PluginError |
| from coverage.files import PathAliases, abs_file, relative_filename, set_relative_directory |
| from coverage.html import HtmlReporter |
| from coverage.inorout import InOrOut |
| from coverage.jsonreport import JsonReporter |
| from coverage.lcovreport import LcovReporter |
| from coverage.misc import bool_or_none, join_regex |
| from coverage.misc import DefaultValue, ensure_dir_for_file, isolate_module |
| from coverage.multiproc import patch_multiprocessing |
| from coverage.plugin import FileReporter |
| from coverage.plugin_support import Plugins |
| from coverage.python import PythonFileReporter |
| from coverage.report import SummaryReporter |
| from coverage.report_core import render_report |
| from coverage.results import Analysis, analysis_from_file_reporter |
| from coverage.types import ( |
| FilePath, TConfigurable, TConfigSectionIn, TConfigValueIn, TConfigValueOut, |
| TFileDisposition, TLineNo, TMorf, |
| ) |
| from coverage.xmlreport import XmlReporter |
| |
| os = isolate_module(os) |
| |
| @contextlib.contextmanager |
| def override_config(cov: Coverage, **kwargs: TConfigValueIn) -> Iterator[None]: |
| """Temporarily tweak the configuration of `cov`. |
| |
| The arguments are applied to `cov.config` with the `from_args` method. |
| At the end of the with-statement, the old configuration is restored. |
| """ |
| original_config = cov.config |
| cov.config = cov.config.copy() |
| try: |
| cov.config.from_args(**kwargs) |
| yield |
| finally: |
| cov.config = original_config |
| |
| |
| DEFAULT_DATAFILE = DefaultValue("MISSING") |
| _DEFAULT_DATAFILE = DEFAULT_DATAFILE # Just in case, for backwards compatibility |
| |
| class Coverage(TConfigurable): |
| """Programmatic access to coverage.py. |
| |
| To use:: |
| |
| from coverage import Coverage |
| |
| cov = Coverage() |
| cov.start() |
| #.. call your code .. |
| cov.stop() |
| cov.html_report(directory="covhtml") |
| |
| A context manager is available to do the same thing:: |
| |
| cov = Coverage() |
| with cov.collect(): |
| #.. call your code .. |
| cov.html_report(directory="covhtml") |
| |
| Note: in keeping with Python custom, names starting with underscore are |
| not part of the public API. They might stop working at any point. Please |
| limit yourself to documented methods to avoid problems. |
| |
| Methods can raise any of the exceptions described in :ref:`api_exceptions`. |
| |
| """ |
| |
| # The stack of started Coverage instances. |
| _instances: list[Coverage] = [] |
| |
| @classmethod |
| def current(cls) -> Coverage | None: |
| """Get the latest started `Coverage` instance, if any. |
| |
| Returns: a `Coverage` instance, or None. |
| |
| .. versionadded:: 5.0 |
| |
| """ |
| if cls._instances: |
| return cls._instances[-1] |
| else: |
| return None |
| |
| def __init__( # pylint: disable=too-many-arguments |
| self, |
| data_file: FilePath | DefaultValue | None = DEFAULT_DATAFILE, |
| data_suffix: str | bool | None = None, |
| cover_pylib: bool | None = None, |
| auto_data: bool = False, |
| timid: bool | None = None, |
| branch: bool | None = None, |
| config_file: FilePath | bool = True, |
| source: Iterable[str] | None = None, |
| source_pkgs: Iterable[str] | None = None, |
| omit: str | Iterable[str] | None = None, |
| include: str | Iterable[str] | None = None, |
| debug: Iterable[str] | None = None, |
| concurrency: str | Iterable[str] | None = None, |
| check_preimported: bool = False, |
| context: str | None = None, |
| messages: bool = False, |
| ) -> None: |
| """ |
| Many of these arguments duplicate and override values that can be |
| provided in a configuration file. Parameters that are missing here |
| will use values from the config file. |
| |
| `data_file` is the base name of the data file to use. The config value |
| defaults to ".coverage". None can be provided to prevent writing a data |
| file. `data_suffix` is appended (with a dot) to `data_file` to create |
| the final file name. If `data_suffix` is simply True, then a suffix is |
| created with the machine and process identity included. |
| |
| `cover_pylib` is a boolean determining whether Python code installed |
| with the Python interpreter is measured. This includes the Python |
| standard library and any packages installed with the interpreter. |
| |
| If `auto_data` is true, then any existing data file will be read when |
| coverage measurement starts, and data will be saved automatically when |
| measurement stops. |
| |
| If `timid` is true, then a slower and simpler trace function will be |
| used. This is important for some environments where manipulation of |
| tracing functions breaks the faster trace function. |
| |
| If `branch` is true, then branch coverage will be measured in addition |
| to the usual statement coverage. |
| |
| `config_file` determines what configuration file to read: |
| |
| * If it is ".coveragerc", it is interpreted as if it were True, |
| for backward compatibility. |
| |
| * If it is a string, it is the name of the file to read. If the |
| file can't be read, it is an error. |
| |
| * If it is True, then a few standard files names are tried |
| (".coveragerc", "setup.cfg", "tox.ini"). It is not an error for |
| these files to not be found. |
| |
| * If it is False, then no configuration file is read. |
| |
| `source` is a list of file paths or package names. Only code located |
| in the trees indicated by the file paths or package names will be |
| measured. |
| |
| `source_pkgs` is a list of package names. It works the same as |
| `source`, but can be used to name packages where the name can also be |
| interpreted as a file path. |
| |
| `include` and `omit` are lists of file name patterns. Files that match |
| `include` will be measured, files that match `omit` will not. Each |
| will also accept a single string argument. |
| |
| `debug` is a list of strings indicating what debugging information is |
| desired. |
| |
| `concurrency` is a string indicating the concurrency library being used |
| in the measured code. Without this, coverage.py will get incorrect |
| results if these libraries are in use. Valid strings are "greenlet", |
| "eventlet", "gevent", "multiprocessing", or "thread" (the default). |
| This can also be a list of these strings. |
| |
| If `check_preimported` is true, then when coverage is started, the |
| already-imported files will be checked to see if they should be |
| measured by coverage. Importing measured files before coverage is |
| started can mean that code is missed. |
| |
| `context` is a string to use as the :ref:`static context |
| <static_contexts>` label for collected data. |
| |
| If `messages` is true, some messages will be printed to stdout |
| indicating what is happening. |
| |
| .. versionadded:: 4.0 |
| The `concurrency` parameter. |
| |
| .. versionadded:: 4.2 |
| The `concurrency` parameter can now be a list of strings. |
| |
| .. versionadded:: 5.0 |
| The `check_preimported` and `context` parameters. |
| |
| .. versionadded:: 5.3 |
| The `source_pkgs` parameter. |
| |
| .. versionadded:: 6.0 |
| The `messages` parameter. |
| |
| """ |
| # Start self.config as a usable default configuration. It will soon be |
| # replaced with the real configuration. |
| self.config = CoverageConfig() |
| |
| # data_file=None means no disk file at all. data_file missing means |
| # use the value from the config file. |
| self._no_disk = data_file is None |
| if isinstance(data_file, DefaultValue): |
| data_file = None |
| if data_file is not None: |
| data_file = os.fspath(data_file) |
| |
| # This is injectable by tests. |
| self._debug_file: IO[str] | None = None |
| |
| self._auto_load = self._auto_save = auto_data |
| self._data_suffix_specified = data_suffix |
| |
| # Is it ok for no data to be collected? |
| self._warn_no_data = True |
| self._warn_unimported_source = True |
| self._warn_preimported_source = check_preimported |
| self._no_warn_slugs: list[str] = [] |
| self._messages = messages |
| |
| # A record of all the warnings that have been issued. |
| self._warnings: list[str] = [] |
| |
| # Other instance attributes, set with placebos or placeholders. |
| # More useful objects will be created later. |
| self._debug: DebugControl = NoDebugging() |
| self._inorout: InOrOut | None = None |
| self._plugins: Plugins = Plugins() |
| self._data: CoverageData | None = None |
| self._collector: Collector | None = None |
| self._metacov = False |
| |
| self._file_mapper: Callable[[str], str] = abs_file |
| self._data_suffix = self._run_suffix = None |
| self._exclude_re: dict[str, str] = {} |
| self._old_sigterm: Callable[[int, FrameType | None], Any] | None = None |
| |
| # State machine variables: |
| # Have we initialized everything? |
| self._inited = False |
| self._inited_for_start = False |
| # Have we started collecting and not stopped it? |
| self._started = False |
| # Should we write the debug output? |
| self._should_write_debug = True |
| |
| # Build our configuration from a number of sources. |
| if not isinstance(config_file, bool): |
| config_file = os.fspath(config_file) |
| self.config = read_coverage_config( |
| config_file=config_file, |
| warn=self._warn, |
| data_file=data_file, |
| cover_pylib=cover_pylib, |
| timid=timid, |
| branch=branch, |
| parallel=bool_or_none(data_suffix), |
| source=source, |
| source_pkgs=source_pkgs, |
| run_omit=omit, |
| run_include=include, |
| debug=debug, |
| report_omit=omit, |
| report_include=include, |
| concurrency=concurrency, |
| context=context, |
| ) |
| |
| # If we have sub-process measurement happening automatically, then we |
| # want any explicit creation of a Coverage object to mean, this process |
| # is already coverage-aware, so don't auto-measure it. By now, the |
| # auto-creation of a Coverage object has already happened. But we can |
| # find it and tell it not to save its data. |
| if not env.METACOV: |
| _prevent_sub_process_measurement() |
| |
| def _init(self) -> None: |
| """Set all the initial state. |
| |
| This is called by the public methods to initialize state. This lets us |
| construct a :class:`Coverage` object, then tweak its state before this |
| function is called. |
| |
| """ |
| if self._inited: |
| return |
| |
| self._inited = True |
| |
| # Create and configure the debugging controller. |
| self._debug = DebugControl(self.config.debug, self._debug_file, self.config.debug_file) |
| if self._debug.should("process"): |
| self._debug.write("Coverage._init") |
| |
| if "multiprocessing" in (self.config.concurrency or ()): |
| # Multi-processing uses parallel for the subprocesses, so also use |
| # it for the main process. |
| self.config.parallel = True |
| |
| # _exclude_re is a dict that maps exclusion list names to compiled regexes. |
| self._exclude_re = {} |
| |
| set_relative_directory() |
| if self.config.relative_files: |
| self._file_mapper = relative_filename |
| |
| # Load plugins |
| self._plugins = Plugins.load_plugins(self.config.plugins, self.config, self._debug) |
| |
| # Run configuring plugins. |
| for plugin in self._plugins.configurers: |
| # We need an object with set_option and get_option. Either self or |
| # self.config will do. Choosing randomly stops people from doing |
| # other things with those objects, against the public API. Yes, |
| # this is a bit childish. :) |
| plugin.configure([self, self.config][int(time.time()) % 2]) |
| |
| def _post_init(self) -> None: |
| """Stuff to do after everything is initialized.""" |
| if self._should_write_debug: |
| self._should_write_debug = False |
| self._write_startup_debug() |
| |
| # "[run] _crash" will raise an exception if the value is close by in |
| # the call stack, for testing error handling. |
| if self.config._crash and self.config._crash in short_stack(): |
| raise RuntimeError(f"Crashing because called by {self.config._crash}") |
| |
| def _write_startup_debug(self) -> None: |
| """Write out debug info at startup if needed.""" |
| wrote_any = False |
| with self._debug.without_callers(): |
| if self._debug.should("config"): |
| config_info = self.config.debug_info() |
| write_formatted_info(self._debug.write, "config", config_info) |
| wrote_any = True |
| |
| if self._debug.should("sys"): |
| write_formatted_info(self._debug.write, "sys", self.sys_info()) |
| for plugin in self._plugins: |
| header = "sys: " + plugin._coverage_plugin_name |
| info = plugin.sys_info() |
| write_formatted_info(self._debug.write, header, info) |
| wrote_any = True |
| |
| if self._debug.should("pybehave"): |
| write_formatted_info(self._debug.write, "pybehave", env.debug_info()) |
| wrote_any = True |
| |
| if wrote_any: |
| write_formatted_info(self._debug.write, "end", ()) |
| |
| def _should_trace(self, filename: str, frame: FrameType) -> TFileDisposition: |
| """Decide whether to trace execution in `filename`. |
| |
| Calls `_should_trace_internal`, and returns the FileDisposition. |
| |
| """ |
| assert self._inorout is not None |
| disp = self._inorout.should_trace(filename, frame) |
| if self._debug.should("trace"): |
| self._debug.write(disposition_debug_msg(disp)) |
| return disp |
| |
| def _check_include_omit_etc(self, filename: str, frame: FrameType) -> bool: |
| """Check a file name against the include/omit/etc, rules, verbosely. |
| |
| Returns a boolean: True if the file should be traced, False if not. |
| |
| """ |
| assert self._inorout is not None |
| reason = self._inorout.check_include_omit_etc(filename, frame) |
| if self._debug.should("trace"): |
| if not reason: |
| msg = f"Including {filename!r}" |
| else: |
| msg = f"Not including {filename!r}: {reason}" |
| self._debug.write(msg) |
| |
| return not reason |
| |
| def _warn(self, msg: str, slug: str | None = None, once: bool = False) -> None: |
| """Use `msg` as a warning. |
| |
| For warning suppression, use `slug` as the shorthand. |
| |
| If `once` is true, only show this warning once (determined by the |
| slug.) |
| |
| """ |
| if not self._no_warn_slugs: |
| self._no_warn_slugs = list(self.config.disable_warnings) |
| |
| if slug in self._no_warn_slugs: |
| # Don't issue the warning |
| return |
| |
| self._warnings.append(msg) |
| if slug: |
| msg = f"{msg} ({slug})" |
| if self._debug.should("pid"): |
| msg = f"[{os.getpid()}] {msg}" |
| warnings.warn(msg, category=CoverageWarning, stacklevel=2) |
| |
| if once: |
| assert slug is not None |
| self._no_warn_slugs.append(slug) |
| |
| def _message(self, msg: str) -> None: |
| """Write a message to the user, if configured to do so.""" |
| if self._messages: |
| print(msg) |
| |
| def get_option(self, option_name: str) -> TConfigValueOut | None: |
| """Get an option from the configuration. |
| |
| `option_name` is a colon-separated string indicating the section and |
| option name. For example, the ``branch`` option in the ``[run]`` |
| section of the config file would be indicated with `"run:branch"`. |
| |
| Returns the value of the option. The type depends on the option |
| selected. |
| |
| As a special case, an `option_name` of ``"paths"`` will return an |
| dictionary with the entire ``[paths]`` section value. |
| |
| .. versionadded:: 4.0 |
| |
| """ |
| return self.config.get_option(option_name) |
| |
| def set_option(self, option_name: str, value: TConfigValueIn | TConfigSectionIn) -> None: |
| """Set an option in the configuration. |
| |
| `option_name` is a colon-separated string indicating the section and |
| option name. For example, the ``branch`` option in the ``[run]`` |
| section of the config file would be indicated with ``"run:branch"``. |
| |
| `value` is the new value for the option. This should be an |
| appropriate Python value. For example, use True for booleans, not the |
| string ``"True"``. |
| |
| As an example, calling: |
| |
| .. code-block:: python |
| |
| cov.set_option("run:branch", True) |
| |
| has the same effect as this configuration file: |
| |
| .. code-block:: ini |
| |
| [run] |
| branch = True |
| |
| As a special case, an `option_name` of ``"paths"`` will replace the |
| entire ``[paths]`` section. The value should be a dictionary. |
| |
| .. versionadded:: 4.0 |
| |
| """ |
| self.config.set_option(option_name, value) |
| |
| def load(self) -> None: |
| """Load previously-collected coverage data from the data file.""" |
| self._init() |
| if self._collector is not None: |
| self._collector.reset() |
| should_skip = self.config.parallel and not os.path.exists(self.config.data_file) |
| if not should_skip: |
| self._init_data(suffix=None) |
| self._post_init() |
| if not should_skip: |
| assert self._data is not None |
| self._data.read() |
| |
| def _init_for_start(self) -> None: |
| """Initialization for start()""" |
| # Construct the collector. |
| concurrency: list[str] = self.config.concurrency or [] |
| if "multiprocessing" in concurrency: |
| if self.config.config_file is None: |
| raise ConfigError("multiprocessing requires a configuration file") |
| patch_multiprocessing(rcfile=self.config.config_file) |
| |
| dycon = self.config.dynamic_context |
| if not dycon or dycon == "none": |
| context_switchers = [] |
| elif dycon == "test_function": |
| context_switchers = [should_start_context_test_function] |
| else: |
| raise ConfigError(f"Don't understand dynamic_context setting: {dycon!r}") |
| |
| context_switchers.extend( |
| plugin.dynamic_context for plugin in self._plugins.context_switchers |
| ) |
| |
| should_start_context = combine_context_switchers(context_switchers) |
| |
| self._collector = Collector( |
| should_trace=self._should_trace, |
| check_include=self._check_include_omit_etc, |
| should_start_context=should_start_context, |
| file_mapper=self._file_mapper, |
| timid=self.config.timid, |
| branch=self.config.branch, |
| warn=self._warn, |
| concurrency=concurrency, |
| metacov=self._metacov, |
| ) |
| |
| suffix = self._data_suffix_specified |
| if suffix: |
| if not isinstance(suffix, str): |
| # if data_suffix=True, use .machinename.pid.random |
| suffix = True |
| elif self.config.parallel: |
| if suffix is None: |
| suffix = True |
| elif not isinstance(suffix, str): |
| suffix = bool(suffix) |
| else: |
| suffix = None |
| |
| self._init_data(suffix) |
| |
| assert self._data is not None |
| self._collector.use_data(self._data, self.config.context) |
| |
| # Early warning if we aren't going to be able to support plugins. |
| if self._plugins.file_tracers and not self._collector.supports_plugins: |
| self._warn( |
| "Plugin file tracers ({}) aren't supported with {}".format( |
| ", ".join( |
| plugin._coverage_plugin_name |
| for plugin in self._plugins.file_tracers |
| ), |
| self._collector.tracer_name(), |
| ), |
| ) |
| for plugin in self._plugins.file_tracers: |
| plugin._coverage_enabled = False |
| |
| # Create the file classifying substructure. |
| self._inorout = InOrOut( |
| config=self.config, |
| warn=self._warn, |
| debug=(self._debug if self._debug.should("trace") else None), |
| include_namespace_packages=self.config.include_namespace_packages, |
| ) |
| self._inorout.plugins = self._plugins |
| self._inorout.disp_class = self._collector.file_disposition_class |
| |
| # It's useful to write debug info after initing for start. |
| self._should_write_debug = True |
| |
| # Register our clean-up handlers. |
| atexit.register(self._atexit) |
| if self.config.sigterm: |
| is_main = (threading.current_thread() == threading.main_thread()) |
| if is_main and not env.WINDOWS: |
| # The Python docs seem to imply that SIGTERM works uniformly even |
| # on Windows, but that's not my experience, and this agrees: |
| # https://stackoverflow.com/questions/35772001/x/35792192#35792192 |
| self._old_sigterm = signal.signal( # type: ignore[assignment] |
| signal.SIGTERM, self._on_sigterm, |
| ) |
| |
| def _init_data(self, suffix: str | bool | None) -> None: |
| """Create a data file if we don't have one yet.""" |
| if self._data is None: |
| # Create the data file. We do this at construction time so that the |
| # data file will be written into the directory where the process |
| # started rather than wherever the process eventually chdir'd to. |
| ensure_dir_for_file(self.config.data_file) |
| self._data = CoverageData( |
| basename=self.config.data_file, |
| suffix=suffix, |
| warn=self._warn, |
| debug=self._debug, |
| no_disk=self._no_disk, |
| ) |
| |
| def start(self) -> None: |
| """Start measuring code coverage. |
| |
| Coverage measurement is only collected in functions called after |
| :meth:`start` is invoked. Statements in the same scope as |
| :meth:`start` won't be measured. |
| |
| Once you invoke :meth:`start`, you must also call :meth:`stop` |
| eventually, or your process might not shut down cleanly. |
| |
| The :meth:`collect` method is a context manager to handle both |
| starting and stopping collection. |
| |
| """ |
| self._init() |
| if not self._inited_for_start: |
| self._inited_for_start = True |
| self._init_for_start() |
| self._post_init() |
| |
| assert self._collector is not None |
| assert self._inorout is not None |
| |
| # Issue warnings for possible problems. |
| self._inorout.warn_conflicting_settings() |
| |
| # See if we think some code that would eventually be measured has |
| # already been imported. |
| if self._warn_preimported_source: |
| self._inorout.warn_already_imported_files() |
| |
| if self._auto_load: |
| self.load() |
| |
| self._collector.start() |
| self._started = True |
| self._instances.append(self) |
| |
| def stop(self) -> None: |
| """Stop measuring code coverage.""" |
| if self._instances: |
| if self._instances[-1] is self: |
| self._instances.pop() |
| if self._started: |
| assert self._collector is not None |
| self._collector.stop() |
| self._started = False |
| |
| @contextlib.contextmanager |
| def collect(self) -> Iterator[None]: |
| """A context manager to start/stop coverage measurement collection. |
| |
| .. versionadded:: 7.3 |
| |
| """ |
| self.start() |
| try: |
| yield |
| finally: |
| self.stop() # pragma: nested |
| |
| def _atexit(self, event: str = "atexit") -> None: |
| """Clean up on process shutdown.""" |
| if self._debug.should("process"): |
| self._debug.write(f"{event}: pid: {os.getpid()}, instance: {self!r}") |
| if self._started: |
| self.stop() |
| if self._auto_save or event == "sigterm": |
| self.save() |
| |
| def _on_sigterm(self, signum_unused: int, frame_unused: FrameType | None) -> None: |
| """A handler for signal.SIGTERM.""" |
| self._atexit("sigterm") |
| # Statements after here won't be seen by metacov because we just wrote |
| # the data, and are about to kill the process. |
| signal.signal(signal.SIGTERM, self._old_sigterm) # pragma: not covered |
| os.kill(os.getpid(), signal.SIGTERM) # pragma: not covered |
| |
| def erase(self) -> None: |
| """Erase previously collected coverage data. |
| |
| This removes the in-memory data collected in this session as well as |
| discarding the data file. |
| |
| """ |
| self._init() |
| self._post_init() |
| if self._collector is not None: |
| self._collector.reset() |
| self._init_data(suffix=None) |
| assert self._data is not None |
| self._data.erase(parallel=self.config.parallel) |
| self._data = None |
| self._inited_for_start = False |
| |
| def switch_context(self, new_context: str) -> None: |
| """Switch to a new dynamic context. |
| |
| `new_context` is a string to use as the :ref:`dynamic context |
| <dynamic_contexts>` label for collected data. If a :ref:`static |
| context <static_contexts>` is in use, the static and dynamic context |
| labels will be joined together with a pipe character. |
| |
| Coverage collection must be started already. |
| |
| .. versionadded:: 5.0 |
| |
| """ |
| if not self._started: # pragma: part started |
| raise CoverageException("Cannot switch context, coverage is not started") |
| |
| assert self._collector is not None |
| if self._collector.should_start_context: |
| self._warn("Conflicting dynamic contexts", slug="dynamic-conflict", once=True) |
| |
| self._collector.switch_context(new_context) |
| |
| def clear_exclude(self, which: str = "exclude") -> None: |
| """Clear the exclude list.""" |
| self._init() |
| setattr(self.config, which + "_list", []) |
| self._exclude_regex_stale() |
| |
| def exclude(self, regex: str, which: str = "exclude") -> None: |
| """Exclude source lines from execution consideration. |
| |
| A number of lists of regular expressions are maintained. Each list |
| selects lines that are treated differently during reporting. |
| |
| `which` determines which list is modified. The "exclude" list selects |
| lines that are not considered executable at all. The "partial" list |
| indicates lines with branches that are not taken. |
| |
| `regex` is a regular expression. The regex is added to the specified |
| list. If any of the regexes in the list is found in a line, the line |
| is marked for special treatment during reporting. |
| |
| """ |
| self._init() |
| excl_list = getattr(self.config, which + "_list") |
| excl_list.append(regex) |
| self._exclude_regex_stale() |
| |
| def _exclude_regex_stale(self) -> None: |
| """Drop all the compiled exclusion regexes, a list was modified.""" |
| self._exclude_re.clear() |
| |
| def _exclude_regex(self, which: str) -> str: |
| """Return a regex string for the given exclusion list.""" |
| if which not in self._exclude_re: |
| excl_list = getattr(self.config, which + "_list") |
| self._exclude_re[which] = join_regex(excl_list) |
| return self._exclude_re[which] |
| |
| def get_exclude_list(self, which: str = "exclude") -> list[str]: |
| """Return a list of excluded regex strings. |
| |
| `which` indicates which list is desired. See :meth:`exclude` for the |
| lists that are available, and their meaning. |
| |
| """ |
| self._init() |
| return cast(List[str], getattr(self.config, which + "_list")) |
| |
| def save(self) -> None: |
| """Save the collected coverage data to the data file.""" |
| data = self.get_data() |
| data.write() |
| |
| def _make_aliases(self) -> PathAliases: |
| """Create a PathAliases from our configuration.""" |
| aliases = PathAliases( |
| debugfn=(self._debug.write if self._debug.should("pathmap") else None), |
| relative=self.config.relative_files, |
| ) |
| for paths in self.config.paths.values(): |
| result = paths[0] |
| for pattern in paths[1:]: |
| aliases.add(pattern, result) |
| return aliases |
| |
| def combine( |
| self, |
| data_paths: Iterable[str] | None = None, |
| strict: bool = False, |
| keep: bool = False, |
| ) -> None: |
| """Combine together a number of similarly-named coverage data files. |
| |
| All coverage data files whose name starts with `data_file` (from the |
| coverage() constructor) will be read, and combined together into the |
| current measurements. |
| |
| `data_paths` is a list of files or directories from which data should |
| be combined. If no list is passed, then the data files from the |
| directory indicated by the current data file (probably the current |
| directory) will be combined. |
| |
| If `strict` is true, then it is an error to attempt to combine when |
| there are no data files to combine. |
| |
| If `keep` is true, then original input data files won't be deleted. |
| |
| .. versionadded:: 4.0 |
| The `data_paths` parameter. |
| |
| .. versionadded:: 4.3 |
| The `strict` parameter. |
| |
| .. versionadded: 5.5 |
| The `keep` parameter. |
| """ |
| self._init() |
| self._init_data(suffix=None) |
| self._post_init() |
| self.get_data() |
| |
| assert self._data is not None |
| combine_parallel_data( |
| self._data, |
| aliases=self._make_aliases(), |
| data_paths=data_paths, |
| strict=strict, |
| keep=keep, |
| message=self._message, |
| ) |
| |
| def get_data(self) -> CoverageData: |
| """Get the collected data. |
| |
| Also warn about various problems collecting data. |
| |
| Returns a :class:`coverage.CoverageData`, the collected coverage data. |
| |
| .. versionadded:: 4.0 |
| |
| """ |
| self._init() |
| self._init_data(suffix=None) |
| self._post_init() |
| |
| if self._collector is not None: |
| for plugin in self._plugins: |
| if not plugin._coverage_enabled: |
| self._collector.plugin_was_disabled(plugin) |
| |
| if self._collector.flush_data(): |
| self._post_save_work() |
| |
| assert self._data is not None |
| return self._data |
| |
| def _post_save_work(self) -> None: |
| """After saving data, look for warnings, post-work, etc. |
| |
| Warn about things that should have happened but didn't. |
| Look for un-executed files. |
| |
| """ |
| assert self._data is not None |
| assert self._inorout is not None |
| |
| # If there are still entries in the source_pkgs_unmatched list, |
| # then we never encountered those packages. |
| if self._warn_unimported_source: |
| self._inorout.warn_unimported_source() |
| |
| # Find out if we got any data. |
| if not self._data and self._warn_no_data: |
| self._warn("No data was collected.", slug="no-data-collected") |
| |
| # Touch all the files that could have executed, so that we can |
| # mark completely un-executed files as 0% covered. |
| file_paths = collections.defaultdict(list) |
| for file_path, plugin_name in self._inorout.find_possibly_unexecuted_files(): |
| file_path = self._file_mapper(file_path) |
| file_paths[plugin_name].append(file_path) |
| for plugin_name, paths in file_paths.items(): |
| self._data.touch_files(paths, plugin_name) |
| |
| # Backward compatibility with version 1. |
| def analysis(self, morf: TMorf) -> tuple[str, list[TLineNo], list[TLineNo], str]: |
| """Like `analysis2` but doesn't return excluded line numbers.""" |
| f, s, _, m, mf = self.analysis2(morf) |
| return f, s, m, mf |
| |
| def analysis2( |
| self, |
| morf: TMorf, |
| ) -> tuple[str, list[TLineNo], list[TLineNo], list[TLineNo], str]: |
| """Analyze a module. |
| |
| `morf` is a module or a file name. It will be analyzed to determine |
| its coverage statistics. The return value is a 5-tuple: |
| |
| * The file name for the module. |
| * A list of line numbers of executable statements. |
| * A list of line numbers of excluded statements. |
| * A list of line numbers of statements not run (missing from |
| execution). |
| * A readable formatted string of the missing line numbers. |
| |
| The analysis uses the source file itself and the current measured |
| coverage data. |
| |
| """ |
| analysis = self._analyze(morf) |
| return ( |
| analysis.filename, |
| sorted(analysis.statements), |
| sorted(analysis.excluded), |
| sorted(analysis.missing), |
| analysis.missing_formatted(), |
| ) |
| |
| def _analyze(self, morf: TMorf) -> Analysis: |
| """Analyze a module or file. Private for now.""" |
| self._init() |
| self._post_init() |
| |
| data = self.get_data() |
| file_reporter = self._get_file_reporter(morf) |
| filename = self._file_mapper(file_reporter.filename) |
| return analysis_from_file_reporter(data, self.config.precision, file_reporter, filename) |
| |
| @functools.lru_cache(maxsize=1) |
| def _get_file_reporter(self, morf: TMorf) -> FileReporter: |
| """Get a FileReporter for a module or file name.""" |
| assert self._data is not None |
| plugin = None |
| file_reporter: str | FileReporter = "python" |
| |
| if isinstance(morf, str): |
| mapped_morf = self._file_mapper(morf) |
| plugin_name = self._data.file_tracer(mapped_morf) |
| if plugin_name: |
| plugin = self._plugins.get(plugin_name) |
| |
| if plugin: |
| file_reporter = plugin.file_reporter(mapped_morf) |
| if file_reporter is None: |
| raise PluginError( |
| "Plugin {!r} did not provide a file reporter for {!r}.".format( |
| plugin._coverage_plugin_name, morf, |
| ), |
| ) |
| |
| if file_reporter == "python": |
| file_reporter = PythonFileReporter(morf, self) |
| |
| assert isinstance(file_reporter, FileReporter) |
| return file_reporter |
| |
| def _get_file_reporters( |
| self, |
| morfs: Iterable[TMorf] | None = None, |
| ) -> list[tuple[FileReporter, TMorf]]: |
| """Get FileReporters for a list of modules or file names. |
| |
| For each module or file name in `morfs`, find a FileReporter. Return |
| a list pairing FileReporters with the morfs. |
| |
| If `morfs` is a single module or file name, this returns a list of one |
| FileReporter. If `morfs` is empty or None, then the list of all files |
| measured is used to find the FileReporters. |
| |
| """ |
| assert self._data is not None |
| if not morfs: |
| morfs = self._data.measured_files() |
| |
| # Be sure we have a collection. |
| if not isinstance(morfs, (list, tuple, set)): |
| morfs = [morfs] # type: ignore[list-item] |
| |
| return [(self._get_file_reporter(morf), morf) for morf in morfs] |
| |
| def _prepare_data_for_reporting(self) -> None: |
| """Re-map data before reporting, to get implicit "combine" behavior.""" |
| if self.config.paths: |
| mapped_data = CoverageData(warn=self._warn, debug=self._debug, no_disk=True) |
| if self._data is not None: |
| mapped_data.update(self._data, map_path=self._make_aliases().map) |
| self._data = mapped_data |
| |
| def report( |
| self, |
| morfs: Iterable[TMorf] | None = None, |
| show_missing: bool | None = None, |
| ignore_errors: bool | None = None, |
| file: IO[str] | None = None, |
| omit: str | list[str] | None = None, |
| include: str | list[str] | None = None, |
| skip_covered: bool | None = None, |
| contexts: list[str] | None = None, |
| skip_empty: bool | None = None, |
| precision: int | None = None, |
| sort: str | None = None, |
| output_format: str | None = None, |
| ) -> float: |
| """Write a textual summary report to `file`. |
| |
| Each module in `morfs` is listed, with counts of statements, executed |
| statements, missing statements, and a list of lines missed. |
| |
| If `show_missing` is true, then details of which lines or branches are |
| missing will be included in the report. If `ignore_errors` is true, |
| then a failure while reporting a single file will not stop the entire |
| report. |
| |
| `file` is a file-like object, suitable for writing. |
| |
| `output_format` determines the format, either "text" (the default), |
| "markdown", or "total". |
| |
| `include` is a list of file name patterns. Files that match will be |
| included in the report. Files matching `omit` will not be included in |
| the report. |
| |
| If `skip_covered` is true, don't report on files with 100% coverage. |
| |
| If `skip_empty` is true, don't report on empty files (those that have |
| no statements). |
| |
| `contexts` is a list of regular expression strings. Only data from |
| :ref:`dynamic contexts <dynamic_contexts>` that match one of those |
| expressions (using :func:`re.search <python:re.search>`) will be |
| included in the report. |
| |
| `precision` is the number of digits to display after the decimal |
| point for percentages. |
| |
| All of the arguments default to the settings read from the |
| :ref:`configuration file <config>`. |
| |
| Returns a float, the total percentage covered. |
| |
| .. versionadded:: 4.0 |
| The `skip_covered` parameter. |
| |
| .. versionadded:: 5.0 |
| The `contexts` and `skip_empty` parameters. |
| |
| .. versionadded:: 5.2 |
| The `precision` parameter. |
| |
| .. versionadded:: 7.0 |
| The `format` parameter. |
| |
| """ |
| self._prepare_data_for_reporting() |
| with override_config( |
| self, |
| ignore_errors=ignore_errors, |
| report_omit=omit, |
| report_include=include, |
| show_missing=show_missing, |
| skip_covered=skip_covered, |
| report_contexts=contexts, |
| skip_empty=skip_empty, |
| precision=precision, |
| sort=sort, |
| format=output_format, |
| ): |
| reporter = SummaryReporter(self) |
| return reporter.report(morfs, outfile=file) |
| |
| def annotate( |
| self, |
| morfs: Iterable[TMorf] | None = None, |
| directory: str | None = None, |
| ignore_errors: bool | None = None, |
| omit: str | list[str] | None = None, |
| include: str | list[str] | None = None, |
| contexts: list[str] | None = None, |
| ) -> None: |
| """Annotate a list of modules. |
| |
| Each module in `morfs` is annotated. The source is written to a new |
| file, named with a ",cover" suffix, with each line prefixed with a |
| marker to indicate the coverage of the line. Covered lines have ">", |
| excluded lines have "-", and missing lines have "!". |
| |
| See :meth:`report` for other arguments. |
| |
| """ |
| self._prepare_data_for_reporting() |
| with override_config( |
| self, |
| ignore_errors=ignore_errors, |
| report_omit=omit, |
| report_include=include, |
| report_contexts=contexts, |
| ): |
| reporter = AnnotateReporter(self) |
| reporter.report(morfs, directory=directory) |
| |
| def html_report( |
| self, |
| morfs: Iterable[TMorf] | None = None, |
| directory: str | None = None, |
| ignore_errors: bool | None = None, |
| omit: str | list[str] | None = None, |
| include: str | list[str] | None = None, |
| extra_css: str | None = None, |
| title: str | None = None, |
| skip_covered: bool | None = None, |
| show_contexts: bool | None = None, |
| contexts: list[str] | None = None, |
| skip_empty: bool | None = None, |
| precision: int | None = None, |
| ) -> float: |
| """Generate an HTML report. |
| |
| The HTML is written to `directory`. The file "index.html" is the |
| overview starting point, with links to more detailed pages for |
| individual modules. |
| |
| `extra_css` is a path to a file of other CSS to apply on the page. |
| It will be copied into the HTML directory. |
| |
| `title` is a text string (not HTML) to use as the title of the HTML |
| report. |
| |
| See :meth:`report` for other arguments. |
| |
| Returns a float, the total percentage covered. |
| |
| .. note:: |
| |
| The HTML report files are generated incrementally based on the |
| source files and coverage results. If you modify the report files, |
| the changes will not be considered. You should be careful about |
| changing the files in the report folder. |
| |
| """ |
| self._prepare_data_for_reporting() |
| with override_config( |
| self, |
| ignore_errors=ignore_errors, |
| report_omit=omit, |
| report_include=include, |
| html_dir=directory, |
| extra_css=extra_css, |
| html_title=title, |
| html_skip_covered=skip_covered, |
| show_contexts=show_contexts, |
| report_contexts=contexts, |
| html_skip_empty=skip_empty, |
| precision=precision, |
| ): |
| reporter = HtmlReporter(self) |
| ret = reporter.report(morfs) |
| return ret |
| |
| def xml_report( |
| self, |
| morfs: Iterable[TMorf] | None = None, |
| outfile: str | None = None, |
| ignore_errors: bool | None = None, |
| omit: str | list[str] | None = None, |
| include: str | list[str] | None = None, |
| contexts: list[str] | None = None, |
| skip_empty: bool | None = None, |
| ) -> float: |
| """Generate an XML report of coverage results. |
| |
| The report is compatible with Cobertura reports. |
| |
| Each module in `morfs` is included in the report. `outfile` is the |
| path to write the file to, "-" will write to stdout. |
| |
| See :meth:`report` for other arguments. |
| |
| Returns a float, the total percentage covered. |
| |
| """ |
| self._prepare_data_for_reporting() |
| with override_config( |
| self, |
| ignore_errors=ignore_errors, |
| report_omit=omit, |
| report_include=include, |
| xml_output=outfile, |
| report_contexts=contexts, |
| skip_empty=skip_empty, |
| ): |
| return render_report(self.config.xml_output, XmlReporter(self), morfs, self._message) |
| |
| def json_report( |
| self, |
| morfs: Iterable[TMorf] | None = None, |
| outfile: str | None = None, |
| ignore_errors: bool | None = None, |
| omit: str | list[str] | None = None, |
| include: str | list[str] | None = None, |
| contexts: list[str] | None = None, |
| pretty_print: bool | None = None, |
| show_contexts: bool | None = None, |
| ) -> float: |
| """Generate a JSON report of coverage results. |
| |
| Each module in `morfs` is included in the report. `outfile` is the |
| path to write the file to, "-" will write to stdout. |
| |
| `pretty_print` is a boolean, whether to pretty-print the JSON output or not. |
| |
| See :meth:`report` for other arguments. |
| |
| Returns a float, the total percentage covered. |
| |
| .. versionadded:: 5.0 |
| |
| """ |
| self._prepare_data_for_reporting() |
| with override_config( |
| self, |
| ignore_errors=ignore_errors, |
| report_omit=omit, |
| report_include=include, |
| json_output=outfile, |
| report_contexts=contexts, |
| json_pretty_print=pretty_print, |
| json_show_contexts=show_contexts, |
| ): |
| return render_report(self.config.json_output, JsonReporter(self), morfs, self._message) |
| |
| def lcov_report( |
| self, |
| morfs: Iterable[TMorf] | None = None, |
| outfile: str | None = None, |
| ignore_errors: bool | None = None, |
| omit: str | list[str] | None = None, |
| include: str | list[str] | None = None, |
| contexts: list[str] | None = None, |
| ) -> float: |
| """Generate an LCOV report of coverage results. |
| |
| Each module in `morfs` is included in the report. `outfile` is the |
| path to write the file to, "-" will write to stdout. |
| |
| See :meth:`report` for other arguments. |
| |
| .. versionadded:: 6.3 |
| """ |
| self._prepare_data_for_reporting() |
| with override_config( |
| self, |
| ignore_errors=ignore_errors, |
| report_omit=omit, |
| report_include=include, |
| lcov_output=outfile, |
| report_contexts=contexts, |
| ): |
| return render_report(self.config.lcov_output, LcovReporter(self), morfs, self._message) |
| |
| def sys_info(self) -> Iterable[tuple[str, Any]]: |
| """Return a list of (key, value) pairs showing internal information.""" |
| |
| import coverage as covmod |
| |
| self._init() |
| self._post_init() |
| |
| def plugin_info(plugins: list[Any]) -> list[str]: |
| """Make an entry for the sys_info from a list of plug-ins.""" |
| entries = [] |
| for plugin in plugins: |
| entry = plugin._coverage_plugin_name |
| if not plugin._coverage_enabled: |
| entry += " (disabled)" |
| entries.append(entry) |
| return entries |
| |
| info = [ |
| ("coverage_version", covmod.__version__), |
| ("coverage_module", covmod.__file__), |
| ("core", self._collector.tracer_name() if self._collector is not None else "-none-"), |
| ("CTracer", "available" if HAS_CTRACER else "unavailable"), |
| ("plugins.file_tracers", plugin_info(self._plugins.file_tracers)), |
| ("plugins.configurers", plugin_info(self._plugins.configurers)), |
| ("plugins.context_switchers", plugin_info(self._plugins.context_switchers)), |
| ("configs_attempted", self.config.config_files_attempted), |
| ("configs_read", self.config.config_files_read), |
| ("config_file", self.config.config_file), |
| ("config_contents", |
| repr(self.config._config_contents) if self.config._config_contents else "-none-", |
| ), |
| ("data_file", self._data.data_filename() if self._data is not None else "-none-"), |
| ("python", sys.version.replace("\n", "")), |
| ("platform", platform.platform()), |
| ("implementation", platform.python_implementation()), |
| ("gil_enabled", getattr(sys, '_is_gil_enabled', lambda: True)()), |
| ("executable", sys.executable), |
| ("def_encoding", sys.getdefaultencoding()), |
| ("fs_encoding", sys.getfilesystemencoding()), |
| ("pid", os.getpid()), |
| ("cwd", os.getcwd()), |
| ("path", sys.path), |
| ("environment", [f"{k} = {v}" for k, v in relevant_environment_display(os.environ)]), |
| ("command_line", " ".join(getattr(sys, "argv", ["-none-"]))), |
| ] |
| |
| if self._inorout is not None: |
| info.extend(self._inorout.sys_info()) |
| |
| info.extend(CoverageData.sys_info()) |
| |
| return info |
| |
| |
| # Mega debugging... |
| # $set_env.py: COVERAGE_DEBUG_CALLS - Lots and lots of output about calls to Coverage. |
| if int(os.getenv("COVERAGE_DEBUG_CALLS", 0)): # pragma: debugging |
| from coverage.debug import decorate_methods, show_calls |
| |
| Coverage = decorate_methods( # type: ignore[misc] |
| show_calls(show_args=True), |
| butnot=["get_data"], |
| )(Coverage) |
| |
| |
| def process_startup() -> Coverage | None: |
| """Call this at Python start-up to perhaps measure coverage. |
| |
| If the environment variable COVERAGE_PROCESS_START is defined, coverage |
| measurement is started. The value of the variable is the config file |
| to use. |
| |
| There are two ways to configure your Python installation to invoke this |
| function when Python starts: |
| |
| #. Create or append to sitecustomize.py to add these lines:: |
| |
| import coverage |
| coverage.process_startup() |
| |
| #. Create a .pth file in your Python installation containing:: |
| |
| import coverage; coverage.process_startup() |
| |
| Returns the :class:`Coverage` instance that was started, or None if it was |
| not started by this call. |
| |
| """ |
| cps = os.getenv("COVERAGE_PROCESS_START") |
| if not cps: |
| # No request for coverage, nothing to do. |
| return None |
| |
| # This function can be called more than once in a process. This happens |
| # because some virtualenv configurations make the same directory visible |
| # twice in sys.path. This means that the .pth file will be found twice, |
| # and executed twice, executing this function twice. We set a global |
| # flag (an attribute on this function) to indicate that coverage.py has |
| # already been started, so we can avoid doing it twice. |
| # |
| # https://github.com/nedbat/coveragepy/issues/340 has more details. |
| |
| if hasattr(process_startup, "coverage"): |
| # We've annotated this function before, so we must have already |
| # started coverage.py in this process. Nothing to do. |
| return None |
| |
| cov = Coverage(config_file=cps) |
| process_startup.coverage = cov # type: ignore[attr-defined] |
| cov._warn_no_data = False |
| cov._warn_unimported_source = False |
| cov._warn_preimported_source = False |
| cov._auto_save = True |
| cov.start() |
| |
| return cov |
| |
| |
| def _prevent_sub_process_measurement() -> None: |
| """Stop any subprocess auto-measurement from writing data.""" |
| auto_created_coverage = getattr(process_startup, "coverage", None) |
| if auto_created_coverage is not None: |
| auto_created_coverage._auto_save = False |