Skip to content

Commit

Permalink
Make branch coverage accessible via the API
Browse files Browse the repository at this point in the history
  • Loading branch information
eleanorjboyd committed Nov 20, 2024
1 parent 3ed5915 commit 735f995
Show file tree
Hide file tree
Showing 7 changed files with 633 additions and 367 deletions.
60 changes: 37 additions & 23 deletions coverage/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@
from coverage.core import Core, HAS_CTRACER
from coverage.data import CoverageData, combine_parallel_data
from coverage.debug import (
DebugControl, NoDebugging, short_stack, write_formatted_info, relevant_environment_display,
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
Expand All @@ -49,13 +53,20 @@
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,
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`.
Expand All @@ -75,6 +86,7 @@ def override_config(cov: Coverage, **kwargs: TConfigValueIn) -> Iterator[None]:
DEFAULT_DATAFILE = DefaultValue("MISSING")
_DEFAULT_DATAFILE = DEFAULT_DATAFILE # Just in case, for backwards compatibility


class Coverage(TConfigurable):
"""Programmatic access to coverage.py.
Expand Down Expand Up @@ -120,7 +132,7 @@ def current(cls) -> Coverage | None:
else:
return None

def __init__( # pylint: disable=too-many-arguments
def __init__( # pylint: disable=too-many-arguments
self,
data_file: FilePath | DefaultValue | None = DEFAULT_DATAFILE,
data_suffix: str | bool | None = None,
Expand Down Expand Up @@ -571,8 +583,7 @@ def _init_for_start(self) -> None:
self._warn(
"Plugin file tracers ({}) aren't supported with {}".format(
", ".join(
plugin._coverage_plugin_name
for plugin in self._plugins.file_tracers
plugin._coverage_plugin_name for plugin in self._plugins.file_tracers
),
self._collector.tracer_name(),
),
Expand All @@ -596,13 +607,14 @@ def _init_for_start(self) -> None:
# Register our clean-up handlers.
atexit.register(self._atexit)
if self.config.sigterm:
is_main = (threading.current_thread() == threading.main_thread())
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,
self._old_sigterm = signal.signal( # type: ignore[assignment]
signal.SIGTERM,
self._on_sigterm,
)

def _init_data(self, suffix: str | bool | None) -> None:
Expand Down Expand Up @@ -679,7 +691,7 @@ def collect(self) -> Iterator[None]:
try:
yield
finally:
self.stop() # pragma: nested
self.stop() # pragma: nested

def _atexit(self, event: str = "atexit") -> None:
"""Clean up on process shutdown."""
Expand All @@ -695,8 +707,8 @@ def _on_sigterm(self, signum_unused: int, frame_unused: FrameType | None) -> Non
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
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.
Expand Down Expand Up @@ -728,7 +740,7 @@ def switch_context(self, new_context: str) -> None:
.. versionadded:: 5.0
"""
if not self._started: # pragma: part started
if not self._started: # pragma: part started
raise CoverageException("Cannot switch context, coverage is not started")

assert self._collector is not None
Expand Down Expand Up @@ -926,7 +938,7 @@ def analysis2(
coverage data.
"""
analysis = self._analyze(morf)
analysis = self.analyze(morf)
return (
analysis.filename,
sorted(analysis.statements),
Expand All @@ -935,8 +947,8 @@ def analysis2(
analysis.missing_formatted(),
)

def _analyze(self, morf: TMorf) -> Analysis:
"""Analyze a module or file. Private for now."""
def analyze(self, morf: TMorf) -> Analysis:
"""Analyze a module or file."""
self._init()
self._post_init()

Expand All @@ -963,7 +975,8 @@ def _get_file_reporter(self, morf: TMorf) -> FileReporter:
if file_reporter is None:
raise PluginError(
"Plugin {!r} did not provide a file reporter for {!r}.".format(
plugin._coverage_plugin_name, morf,
plugin._coverage_plugin_name,
morf,
),
)

Expand Down Expand Up @@ -993,7 +1006,7 @@ def _get_file_reporters(

# Be sure we have a collection.
if not isinstance(morfs, (list, tuple, set)):
morfs = [morfs] # type: ignore[list-item]
morfs = [morfs] # type: ignore[list-item]

return [(self._get_file_reporter(morf), morf) for morf in morfs]

Expand Down Expand Up @@ -1305,14 +1318,15 @@ def plugin_info(plugins: list[Any]) -> list[str]:
("configs_attempted", self.config.config_files_attempted),
("configs_read", self.config.config_files_read),
("config_file", self.config.config_file),
("config_contents",
(
"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)()),
("gil_enabled", getattr(sys, "_is_gil_enabled", lambda: True)()),
("executable", sys.executable),
("def_encoding", sys.getdefaultencoding()),
("fs_encoding", sys.getfilesystemencoding()),
Expand All @@ -1333,10 +1347,10 @@ def plugin_info(plugins: list[Any]) -> list[str]:

# 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
if int(os.getenv("COVERAGE_DEBUG_CALLS", 0)): # pragma: debugging
from coverage.debug import decorate_methods, show_calls

Coverage = decorate_methods( # type: ignore[misc]
Coverage = decorate_methods( # type: ignore[misc]
show_calls(show_args=True),
butnot=["get_data"],
)(Coverage)
Expand Down Expand Up @@ -1385,7 +1399,7 @@ def process_startup() -> Coverage | None:
return None

cov = Coverage(config_file=cps)
process_startup.coverage = cov # type: ignore[attr-defined]
process_startup.coverage = cov # type: ignore[attr-defined]
cov._warn_no_data = False
cov._warn_unimported_source = False
cov._warn_preimported_source = False
Expand Down
11 changes: 7 additions & 4 deletions coverage/report_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
import sys

from typing import (
Callable, IO, Protocol, TYPE_CHECKING,
Callable,
IO,
Protocol,
TYPE_CHECKING,
)
from collections.abc import Iterable, Iterator

Expand Down Expand Up @@ -68,7 +71,7 @@ def render_report(
if file_to_close is not None:
file_to_close.close()
if delete_file:
file_be_gone(output_path) # pragma: part covered (doesn't return)
file_be_gone(output_path) # pragma: part covered (doesn't return)


def get_analysis_to_report(
Expand Down Expand Up @@ -98,13 +101,13 @@ def get_analysis_to_report(

for fr, morf in sorted(fr_morfs):
try:
analysis = coverage._analyze(morf)
analysis = coverage.analyze(morf)
except NotPython:
# Only report errors for .py files, and only if we didn't
# explicitly suppress those errors.
# NotPython is only raised by PythonFileReporter, which has a
# should_be_python() method.
if fr.should_be_python(): # type: ignore[attr-defined]
if fr.should_be_python(): # type: ignore[attr-defined]
if config.ignore_errors:
msg = f"Couldn't parse Python file '{fr.filename}'"
coverage._warn(msg, slug="couldnt-parse")
Expand Down
1 change: 1 addition & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ only. :ref:`dbschema` explains more.
api_module
api_plugin
api_coveragedata
api_analysis
dbschema
10 changes: 10 additions & 0 deletions doc/api_analysis.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.. Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
.. For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
.. _api_analysis:

The Analysis class
------------------

.. autoclass:: coverage.results.Analysis
:members: branch_stats
Loading

0 comments on commit 735f995

Please sign in to comment.