diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index ce8f631e5..5eddd8137 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -51,7 +51,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3
+ uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -62,7 +62,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3
+ uses: github/codeql-action/autobuild@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -76,4 +76,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3
+ uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 07c2250fe..d94227f04 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -125,7 +125,7 @@ jobs:
mv .metacov .metacov.$MATRIX_ID
- name: "Upload coverage data"
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: metacov-${{ env.MATRIX_ID }}
path: .metacov.*
@@ -170,7 +170,7 @@ jobs:
python igor.py zip_mods
- name: "Download coverage data"
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
pattern: metacov-*
merge-multiple: true
@@ -184,7 +184,7 @@ jobs:
python igor.py combine_html
- name: "Upload HTML report"
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: html_report
path: htmlcov
@@ -239,7 +239,7 @@ jobs:
- name: "Download coverage HTML report"
if: ${{ github.ref == 'refs/heads/master' }}
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
name: html_report
path: reports_repo/${{ env.report_dir }}
diff --git a/.github/workflows/kit.yml b/.github/workflows/kit.yml
index 9462e80ff..421ea5af0 100644
--- a/.github/workflows/kit.yml
+++ b/.github/workflows/kit.yml
@@ -182,7 +182,7 @@ jobs:
python -m twine check wheelhouse/*
- name: "Upload binary wheels"
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: dist-${{ env.MATRIX_ID }}
path: wheelhouse/*.whl
@@ -223,7 +223,7 @@ jobs:
python -m twine check dist/*
- name: "Upload non-binary artifacts"
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: dist-non-binary
path: dist/*
@@ -255,7 +255,7 @@ jobs:
run: |
# One wheel works for all PyPy versions. PYVERSIONS
# yes, this is weird syntax: https://github.com/pypa/build/issues/202
- echo -e "[bdist_wheel]\npython_tag=pp39.pp310" > $DIST_EXTRA_CONFIG
+ echo -e "[bdist_wheel]\npython_tag=pp39.pp310.pp311" > $DIST_EXTRA_CONFIG
pypy3 -m build -w
- name: "List wheels"
@@ -267,7 +267,7 @@ jobs:
python -m twine check dist/*
- name: "Upload wheels"
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: dist-pypy
path: dist/*.whl
@@ -286,7 +286,7 @@ jobs:
id-token: write
steps:
- name: "Download artifacts"
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
pattern: dist-*
merge-multiple: true
@@ -308,7 +308,7 @@ jobs:
ls -alR
- name: "Upload signatures"
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: signatures
path: "*.sigstore.json"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index b920ae65d..e6098e027 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -64,7 +64,7 @@ jobs:
steps:
- name: "Download dists"
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
repository: "nedbat/coveragepy"
run-id: ${{ needs.find-run.outputs.run-id }}
@@ -81,7 +81,7 @@ jobs:
files=$(ls dist 2>/dev/null | wc -l) && [ "$files" -eq $EXPECTED ] || exit 1
- name: "Generate attestations"
- uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
+ uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
with:
subject-path: "dist/*"
@@ -104,7 +104,7 @@ jobs:
steps:
- name: "Download dists"
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
repository: "nedbat/coveragepy"
run-id: ${{ needs.find-run.outputs.run-id }}
@@ -121,7 +121,7 @@ jobs:
files=$(ls dist 2>/dev/null | wc -l) && [ "$files" -eq $EXPECTED ] || exit 1
- name: "Generate attestations"
- uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
+ uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
with:
subject-path: "dist/*"
diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml
index 84f1b6be0..0f831e81d 100644
--- a/.github/workflows/testsuite.yml
+++ b/.github/workflows/testsuite.yml
@@ -54,15 +54,14 @@ jobs:
- "3.14"
- "pypy-3.9"
- "pypy-3.10"
- exclude:
- # Windows pypy 3.9 and 3.10 get stuck with PyPy 7.3.15. I hope to
- # unstick them, but I don't want that to block all other progress, so
- # skip them for now. These excludes can be removed once GitHub uses
- # PyPy 7.3.16 on Windows. https://github.com/pypy/pypy/issues/4876
- - os: windows
- python-version: "pypy-3.9"
- - os: windows
- python-version: "pypy-3.10"
+ - "pypy-3.11"
+ #
+ # If we need to exclude any combinations, do it like this:
+ # exclude:
+ # # Windows pypy 3.9 and 3.10 get stuck with PyPy 7.3.15.
+ # - os: windows
+ # python-version: "pypy-3.10"
+ #
# If we need to tweak the os version we can do it with an include like
# this:
# include:
diff --git a/CHANGES.rst b/CHANGES.rst
index fcd12b863..27b90e8ca 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -22,6 +22,34 @@ upgrading your version of coverage.py.
.. start-releases
+.. _changes_7-7-0:
+
+Version 7.7.0 — 2025-03-16
+--------------------------
+
+- The Coverage object has a new method, :meth:`.Coverage.branch_stats` for
+ getting simple branch information for a module. Closes `issue 1888`_.
+
+- The :class:`Coverage constructor<.Coverage>` now has a ``plugins`` parameter
+ for passing in plugin objects directly, thanks to `Alex Gaynor `_.
+
+- Many constant tests in if statements are now recognized as being optimized
+ away. For example, previously ``if 13:`` would have been considered a branch
+ with one path not taken. Now it is understood as always true and no coverage
+ is missing.
+
+- The experimental sys.monitoring support now works for branch coverage if you
+ are using Python 3.14.0 alpha 6 or newer. This should reduce the overhead
+ coverage.py imposes on your test suite. Set the environment variable
+ ``COVERAGE_CORE=sysmon`` to try it out.
+
+- Confirmed support for PyPy 3.11. Thanks Michał Górny.
+
+.. _issue 1888: https://github.com/nedbat/coveragepy/issues/1888
+.. _pull 1919: https://github.com/nedbat/coveragepy/pull/1919
+
+
.. _changes_7-6-12:
Version 7.6.12 — 2025-02-11
@@ -1695,7 +1723,7 @@ Version 5.4 — 2021-01-24
- Combining files on Windows across drives now works properly, fixing `issue
577`_. Thanks, `Valentin Lab `_.
-- Fix an obscure warning from deep in the _decimal module, as reported in
+- Fix an obscure warning from deep in the decimal module, as reported in
`issue 1084`_.
- Update to support Python 3.10 alphas in progress, including `PEP 626: Precise
diff --git a/Makefile b/Makefile
index 5ece540ca..e03d5e5f7 100644
--- a/Makefile
+++ b/Makefile
@@ -288,6 +288,7 @@ RELNOTES_JSON = tmp/relnotes.json
$(CHANGES_MD): CHANGES.rst $(DOCBIN)
$(SPHINXBUILD) -b rst doc tmp/rst_rst
+ pandoc --version
pandoc -frst -tmarkdown_strict --markdown-headings=atx --wrap=none tmp/rst_rst/changes.rst > $(CHANGES_MD)
relnotes_json: $(RELNOTES_JSON) ## Convert changelog to JSON for further parsing.
diff --git a/README.rst b/README.rst
index 9877294a3..cb5f41b2d 100644
--- a/README.rst
+++ b/README.rst
@@ -25,8 +25,8 @@ Coverage.py runs on these versions of Python:
.. PYVERSIONS
-* Python 3.9 through 3.14 alpha 4, including free-threading.
-* PyPy3 versions 3.9 and 3.10.
+* Python 3.9 through 3.14 alpha 6, including free-threading.
+* PyPy3 versions 3.9, 3.10, and 3.11.
Documentation is on `Read the Docs`_. Code repository and issue tracker are on
`GitHub`_.
@@ -35,6 +35,7 @@ Documentation is on `Read the Docs`_. Code repository and issue tracker are on
.. _GitHub: https://github.com/nedbat/coveragepy
**New in 7.x:**
+``Coverage.branch_stats()``;
multi-line exclusion patterns;
function/class reporting;
experimental support for sys.monitoring;
diff --git a/benchmark/README.rst b/benchmark/README.rst
index 3e1588547..2372a7c6c 100644
--- a/benchmark/README.rst
+++ b/benchmark/README.rst
@@ -50,6 +50,15 @@ for the table. There will be a row for each combination of the two dimensions.
The `column` argument is the remaining dimension that is used to add columns to
the table, one for each item in that dimension.
+To run a benchmark, create a Python file with a run_experiment call in it.
+Many are in run.py, guarded by ``if 0:`` and ``if 1:`` clauses. In the
+benchmark directory, run your Python file. If you haven't provided the
+``num_runs`` argument to run_experiment, put the number of runs on the command
+line::
+
+ % cd benchmark
+ % python3 run.py 3
+
For example::
run_experiment(
diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py
index 0a17218db..35cf938c7 100644
--- a/benchmark/benchmark.py
+++ b/benchmark/benchmark.py
@@ -191,11 +191,15 @@ class ProjectToTest:
# Where can we clone the project from?
git_url: str = ""
+ local_git: str = ""
slug: str = ""
env_vars: Env_VarsType = {}
def __init__(self) -> None:
- url_must_exist(self.git_url)
+ if self.git_url:
+ url_must_exist(self.git_url)
+ if self.local_git:
+ file_must_exist(self.local_git)
if not self.slug:
if self.git_url:
self.slug = self.git_url.split("/")[-1]
@@ -211,12 +215,13 @@ def make_dir(self) -> None:
def get_source(self, shell: ShellSession, retries: int = 5) -> None:
"""Get the source of the project."""
+ git_source = self.local_git or self.git_url
for retry in range(retries):
try:
- shell.run_command(f"git clone {self.git_url} {self.dir}")
+ shell.run_command(f"git clone {git_source} {self.dir}")
return
except Exception as e:
- print(f"Retrying to clone {self.git_url} due to error:\n{e}")
+ print(f"Retrying to clone {git_source} due to error:\n{e}")
if retry == retries - 1:
raise e
@@ -423,6 +428,38 @@ def __init__(self, more_pytest_args: str = ""):
self.slug = "mashbranch"
+class ProjectPillow(ProjectToTest):
+ git_url = "https://github.com/python-pillow/Pillow"
+ local_git = "/src/Pillow"
+
+ def __init__(self, more_pytest_args: str = ""):
+ super().__init__()
+ self.more_pytest_args = more_pytest_args
+
+ def prep_environment(self, env: Env) -> None:
+ env.shell.run_command(f"{env.python} -m pip install '.[tests]'")
+
+ def run_no_coverage(self, env: Env) -> float:
+ env.shell.run_command(f"{env.python} -m pytest {self.more_pytest_args}")
+ return env.shell.last_duration
+
+ def run_with_coverage(self, env: Env, cov_ver: Coverage) -> float:
+ env.shell.run_command(f"{env.python} -m pip install {cov_ver.pip_args}")
+ env.shell.run_command(
+ f"{env.python} -m pytest --cov=PIL --cov=Tests {self.more_pytest_args}"
+ )
+ duration = env.shell.last_duration
+ report = env.shell.run_command(f"{env.python} -m coverage report --precision=6")
+ print("Results:", report.splitlines()[-1])
+ return duration
+
+
+class ProjectPillowBranch(ProjectPillow):
+ def __init__(self, more_pytest_args: str = ""):
+ super().__init__(more_pytest_args="--cov-branch " + more_pytest_args)
+ self.slug = "Pilbranch"
+
+
class ProjectOperator(ProjectToTest):
git_url = "https://github.com/nedbat/operator"
@@ -823,7 +860,9 @@ def __init__(
tweaks: TweaksType = None,
env_vars: Env_VarsType = None,
):
+ # Check that it really is a coverage source directory.
directory = file_must_exist(directory_name, "coverage directory")
+ file_must_exist(str(directory / "igor.py"))
super().__init__(
slug=slug,
pip_args=str(directory),
diff --git a/benchmark/run.py b/benchmark/run.py
index e606cade9..075196a6d 100644
--- a/benchmark/run.py
+++ b/benchmark/run.py
@@ -72,7 +72,7 @@
],
)
-if 1:
+if 0:
# Compare N Python versions
vers = [10, 11, 12, 13]
run_experiment(
@@ -93,12 +93,13 @@
],
)
-if 0:
+if 1:
# Compare sysmon on many projects
run_experiment(
py_versions=[
- Python(3, 12),
+ # Python(3, 12),
+ AdHocPython("/usr/local/cpython", "main"),
],
cov_versions=[
NoCoverage("nocov"),
@@ -106,15 +107,17 @@
CoverageSource(slug="sysmon", env_vars={"COVERAGE_CORE": "sysmon"}),
],
projects=[
+ ProjectPillow(), #"-k test_pickle"),
+ ProjectPillowBranch(), #"-k test_pickle"),
# ProjectSphinx(), # Works, slow
- ProjectPygments(), # Works
+ # ProjectPygments(), # Doesn't work on 3.14
# ProjectRich(), # Doesn't work
# ProjectTornado(), # Works, tests fail
# ProjectDulwich(), # Works
# ProjectBlack(), # Works, slow
# ProjectMpmath(), # Works, slow
- ProjectMypy(), # Works, slow
- # ProjectHtml5lib(), # Works
+ # ProjectMypy(), # Works, slow
+ # ProjectHtml5lib(), # Doesn't work on 3.14
# ProjectUrllib3(), # Works
],
rows=["pyver", "proj"],
diff --git a/coverage/cmdline.py b/coverage/cmdline.py
index 7c01ccbf0..783345e01 100644
--- a/coverage/cmdline.py
+++ b/coverage/cmdline.py
@@ -6,7 +6,7 @@
from __future__ import annotations
import glob
-import optparse # pylint: disable=deprecated-module
+import optparse
import os
import os.path
import shlex
diff --git a/coverage/control.py b/coverage/control.py
index 54b90aa28..d79c97ace 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -19,7 +19,7 @@
import warnings
from types import FrameType
-from typing import cast, Any, Callable, IO
+from typing import cast, Any, Callable, IO, Union
from collections.abc import Iterable, Iterator
from coverage import env
@@ -43,14 +43,14 @@
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.plugin_support import Plugins, TCoverageInit
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,
+ TFileDisposition, TLineNo, TMorf
)
from coverage.xmlreport import XmlReporter
@@ -138,6 +138,7 @@ def __init__( # pylint: disable=too-many-arguments
check_preimported: bool = False,
context: str | None = None,
messages: bool = False,
+ plugins: Iterable[Callable[..., None]] | None = None,
) -> None:
"""
Many of these arguments duplicate and override values that can be
@@ -211,6 +212,11 @@ def __init__( # pylint: disable=too-many-arguments
If `messages` is true, some messages will be printed to stdout
indicating what is happening.
+ If `plugins` are passed, they are an iterable of function objects
+ accepting a `reg` object to register plugins, as described in
+ :ref:`api_plugin`. When they are provided, they will override the
+ plugins found in the coverage configuration file.
+
.. versionadded:: 4.0
The `concurrency` parameter.
@@ -226,6 +232,9 @@ def __init__( # pylint: disable=too-many-arguments
.. versionadded:: 6.0
The `messages` parameter.
+ .. versionadded:: 7.7
+ The `plugins` parameter.
+
"""
# Start self.config as a usable default configuration. It will soon be
# replaced with the real configuration.
@@ -249,7 +258,7 @@ def __init__( # pylint: disable=too-many-arguments
self._warn_no_data = True
self._warn_unimported_source = True
self._warn_preimported_source = check_preimported
- self._no_warn_slugs: list[str] = []
+ self._no_warn_slugs: set[str] = set()
self._messages = messages
# A record of all the warnings that have been issued.
@@ -260,6 +269,7 @@ def __init__( # pylint: disable=too-many-arguments
self._debug: DebugControl = NoDebugging()
self._inorout: InOrOut | None = None
self._plugins: Plugins = Plugins()
+ self._plugin_override = cast(Union[Iterable[TCoverageInit], None], plugins)
self._data: CoverageData | None = None
self._core: Core | None = None
self._collector: Collector | None = None
@@ -340,7 +350,11 @@ def _init(self) -> None:
self._file_mapper = relative_filename
# Load plugins
- self._plugins = Plugins.load_plugins(self.config.plugins, self.config, self._debug)
+ self._plugins = Plugins(self._debug)
+ if self._plugin_override:
+ self._plugins.load_from_callables(self._plugin_override)
+ else:
+ self._plugins.load_from_config(self.config.plugins, self.config)
# Run configuring plugins.
for plugin in self._plugins.configurers:
@@ -424,7 +438,7 @@ def _warn(self, msg: str, slug: str | None = None, once: bool = False) -> None:
"""
if not self._no_warn_slugs:
- self._no_warn_slugs = list(self.config.disable_warnings)
+ self._no_warn_slugs = set(self.config.disable_warnings)
if slug in self._no_warn_slugs:
# Don't issue the warning
@@ -439,7 +453,7 @@ def _warn(self, msg: str, slug: str | None = None, once: bool = False) -> None:
if once:
assert slug is not None
- self._no_warn_slugs.append(slug)
+ self._no_warn_slugs.add(slug)
def _message(self, msg: str) -> None:
"""Write a message to the user, if configured to do so."""
@@ -534,7 +548,8 @@ def _init_for_start(self) -> None:
self._core = Core(
warn=self._warn,
- timid=self.config.timid,
+ config=self.config,
+ dynamic_contexts=(should_start_context is not None),
metacov=self._metacov,
)
self._collector = Collector(
@@ -935,6 +950,7 @@ def analysis2(
analysis.missing_formatted(),
)
+ @functools.lru_cache(maxsize=1)
def _analyze(self, morf: TMorf) -> Analysis:
"""Analyze a module or file. Private for now."""
self._init()
@@ -945,6 +961,20 @@ def _analyze(self, morf: TMorf) -> Analysis:
filename = self._file_mapper(file_reporter.filename)
return analysis_from_file_reporter(data, self.config.precision, file_reporter, filename)
+ def branch_stats(self, morf: TMorf) -> dict[TLineNo, tuple[int, int]]:
+ """Get branch statistics about a module.
+
+ `morf` is a module or a file name.
+
+ Returns a dict mapping line numbers to a tuple:
+ (total_exits, taken_exits).
+
+ .. versionadded:: 7.7
+
+ """
+ analysis = self._analyze(morf)
+ return analysis.branch_stats()
+
@functools.lru_cache(maxsize=1)
def _get_file_reporter(self, morf: TMorf) -> FileReporter:
"""Get a FileReporter for a module or file name."""
diff --git a/coverage/core.py b/coverage/core.py
index b19ecd532..38c27578b 100644
--- a/coverage/core.py
+++ b/coverage/core.py
@@ -10,6 +10,7 @@
from typing import Any
from coverage import env
+from coverage.config import CoverageConfig
from coverage.disposition import FileDisposition
from coverage.exceptions import ConfigError
from coverage.misc import isolate_module
@@ -17,8 +18,8 @@
from coverage.sysmon import SysMonitor
from coverage.types import (
TFileDisposition,
- Tracer,
TWarnFn,
+ Tracer,
)
@@ -52,36 +53,47 @@ class Core:
packed_arcs: bool
systrace: bool
- def __init__(self,
+ def __init__(
+ self,
warn: TWarnFn,
- timid: bool,
+ config: CoverageConfig,
+ dynamic_contexts: bool,
metacov: bool,
) -> None:
- # Defaults
- self.tracer_kwargs = {}
+ # Check the conditions that preclude us from using sys.monitoring.
+ reason_no_sysmon = ""
+ if not env.PYBEHAVIOR.pep669:
+ reason_no_sysmon = "isn't available in this version"
+ elif config.branch and not env.PYBEHAVIOR.branch_right_left:
+ reason_no_sysmon = "can't measure branches in this version"
+ elif dynamic_contexts:
+ reason_no_sysmon = "doesn't yet support dynamic contexts"
- core_name: str | None
- if timid:
+ core_name: str | None = None
+ if config.timid:
core_name = "pytrace"
- else:
+
+ if core_name is None:
core_name = os.getenv("COVERAGE_CORE")
- if core_name == "sysmon" and not env.PYBEHAVIOR.pep669:
- warn("sys.monitoring isn't available, using default core", slug="no-sysmon")
- core_name = None
+ if core_name == "sysmon" and reason_no_sysmon:
+ warn(f"sys.monitoring {reason_no_sysmon}, using default core", slug="no-sysmon")
+ core_name = None
+
+ if core_name is None:
+ # Someday we will default to sysmon, but it's still experimental:
+ # if not reason_no_sysmon:
+ # core_name = "sysmon"
+ if HAS_CTRACER:
+ core_name = "ctrace"
+ else:
+ core_name = "pytrace"
- if not core_name:
- # Once we're comfortable with sysmon as a default:
- # if env.PYBEHAVIOR.pep669 and self.should_start_context is None:
- # core_name = "sysmon"
- if HAS_CTRACER:
- core_name = "ctrace"
- else:
- core_name = "pytrace"
+ self.tracer_kwargs = {}
if core_name == "sysmon":
self.tracer_class = SysMonitor
- self.tracer_kwargs = {"tool_id": 3 if metacov else 1}
+ self.tracer_kwargs["tool_id"] = 3 if metacov else 1
self.file_disposition_class = FileDisposition
self.supports_plugins = False
self.packed_arcs = False
diff --git a/coverage/env.py b/coverage/env.py
index 6f0a9aa8b..a88161b38 100644
--- a/coverage/env.py
+++ b/coverage/env.py
@@ -156,6 +156,11 @@ class PYBEHAVIOR:
# PEP649 and PEP749: Deferred annotations
deferred_annotations = (PYVERSION >= (3, 14))
+ # Does sys.monitoring support BRANCH_RIGHT and BRANCH_LEFT? The names
+ # were added in early 3.14 alphas, but didn't work entirely correctly until
+ # after 3.14.0a5.
+ branch_right_left = (pep669 and (PYVERSION > (3, 14, 0, "alpha", 5, 0)))
+
# Coverage.py specifics, about testing scenarios. See tests/testenv.py also.
diff --git a/coverage/parser.py b/coverage/parser.py
index a8bcbf1a6..431ae829e 100644
--- a/coverage/parser.py
+++ b/coverage/parser.py
@@ -654,23 +654,28 @@ def __init__(self, body: Sequence[ast.AST]) -> None:
# TODO: Shouldn't the cause messages join with "and" instead of "or"?
-def is_constant_test_expr(node: ast.AST) -> bool:
- """Is this a compile-time constant test expression?"""
- node_name = node.__class__.__name__
- if node_name in [ # in PYVERSIONS:
- "Constant", # all
- "NameConstant", # 9 10 11, gone in 12
- "Num", # 9 10 11, gone in 12
- ]:
- return True
+def is_constant_test_expr(node: ast.AST) -> tuple[bool, bool]:
+ """Is this a compile-time constant test expression?
+
+ We don't try to mimic all of CPython's optimizations. We just have to
+ handle the kinds of constant expressions people might actually use.
+
+ """
+ if isinstance(node, ast.Constant):
+ return True, bool(node.value)
elif isinstance(node, ast.Name):
if node.id in ["True", "False", "None", "__debug__"]:
- return True
+ return True, eval(node.id) # pylint: disable=eval-used
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
- return is_constant_test_expr(node.operand)
+ is_constant, val = is_constant_test_expr(node.operand)
+ return is_constant, not val
elif isinstance(node, ast.BoolOp):
- return all(is_constant_test_expr(v) for v in node.values)
- return False
+ rets = [is_constant_test_expr(v) for v in node.values]
+ is_constant = all(is_const for is_const, _ in rets)
+ if is_constant:
+ op = any if isinstance(node.op, ast.Or) else all
+ return True, op(v for _, v in rets)
+ return False, False
class AstArcAnalyzer:
@@ -1106,8 +1111,8 @@ def _handle_decorated(self, node: ast.FunctionDef) -> set[ArcStart]:
last = None
for dec_node in decs:
dec_start = self.line_for_node(dec_node)
- if last is not None and dec_start != last: # type: ignore[unreachable]
- self.add_arc(last, dec_start) # type: ignore[unreachable]
+ if last is not None and dec_start != last:
+ self.add_arc(last, dec_start)
last = dec_start
assert last is not None
self.add_arc(last, main_line)
@@ -1156,10 +1161,14 @@ def _handle__For(self, node: ast.For) -> set[ArcStart]:
def _handle__If(self, node: ast.If) -> set[ArcStart]:
start = self.line_for_node(node.test)
- from_start = ArcStart(start, cause="the condition on line {lineno} was never true")
- exits = self.process_body(node.body, from_start=from_start)
- from_start = ArcStart(start, cause="the condition on line {lineno} was always true")
- exits |= self.process_body(node.orelse, from_start=from_start)
+ constant_test, val = is_constant_test_expr(node.test)
+ exits = set()
+ if not constant_test or val:
+ from_start = ArcStart(start, cause="the condition on line {lineno} was never true")
+ exits |= self.process_body(node.body, from_start=from_start)
+ if not constant_test or not val:
+ from_start = ArcStart(start, cause="the condition on line {lineno} was always true")
+ exits |= self.process_body(node.orelse, from_start=from_start)
return exits
if sys.version_info >= (3, 10):
@@ -1271,7 +1280,7 @@ def _handle__Try(self, node: ast.Try) -> set[ArcStart]:
def _handle__While(self, node: ast.While) -> set[ArcStart]:
start = to_top = self.line_for_node(node.test)
- constant_test = is_constant_test_expr(node.test)
+ constant_test, _ = is_constant_test_expr(node.test)
top_is_body0 = False
if constant_test:
top_is_body0 = True
diff --git a/coverage/plugin_support.py b/coverage/plugin_support.py
index 99e3bc22b..127e375eb 100644
--- a/coverage/plugin_support.py
+++ b/coverage/plugin_support.py
@@ -10,14 +10,14 @@
import sys
from types import FrameType
-from typing import Any
+from typing import Any, Callable
from collections.abc import Iterable, Iterator
from coverage.exceptions import PluginError
from coverage.misc import isolate_module
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter
from coverage.types import (
- TArc, TConfigurable, TDebugCtl, TLineNo, TPluginConfig, TSourceTokenLines,
+ TArc, TConfigurable, TDebugCtl, TLineNo, TPluginConfig, TSourceTokenLines
)
os = isolate_module(os)
@@ -26,7 +26,7 @@
class Plugins:
"""The currently loaded collection of coverage.py plugins."""
- def __init__(self) -> None:
+ def __init__(self, debug: TDebugCtl | None = None) -> None:
self.order: list[CoveragePlugin] = []
self.names: dict[str, CoveragePlugin] = {}
self.file_tracers: list[CoveragePlugin] = []
@@ -34,25 +34,17 @@ def __init__(self) -> None:
self.context_switchers: list[CoveragePlugin] = []
self.current_module: str | None = None
- self.debug: TDebugCtl | None
+ self.debug = debug
- @classmethod
- def load_plugins(
- cls,
+ def load_from_config(
+ self,
modules: Iterable[str],
config: TPluginConfig,
- debug: TDebugCtl | None = None,
- ) -> Plugins:
- """Load plugins from `modules`.
-
- Returns a Plugins object with the loaded and configured plugins.
-
- """
- plugins = cls()
- plugins.debug = debug
+ ) -> None:
+ """Load plugin modules, and read their settings from configuration."""
for module in modules:
- plugins.current_module = module
+ self.current_module = module
__import__(module)
mod = sys.modules[module]
@@ -63,10 +55,17 @@ def load_plugins(
)
options = config.get_plugin_options(module)
- coverage_init(plugins, options)
+ coverage_init(self, options)
- plugins.current_module = None
- return plugins
+ self.current_module = None
+
+ def load_from_callables(
+ self,
+ plugin_inits: Iterable[TCoverageInit],
+ ) -> None:
+ """Load plugins from callables provided."""
+ for fn in plugin_inits:
+ fn(self)
def add_file_tracer(self, plugin: CoveragePlugin) -> None:
"""Add a file tracer plugin.
@@ -138,6 +137,9 @@ def get(self, plugin_name: str) -> CoveragePlugin:
return self.names[plugin_name]
+TCoverageInit = Callable[[Plugins], None]
+
+
class LabelledDebug:
"""A Debug writer, but with labels for prepending to the messages."""
diff --git a/coverage/results.py b/coverage/results.py
index 74de21dbe..6d28e73f7 100644
--- a/coverage/results.py
+++ b/coverage/results.py
@@ -231,6 +231,7 @@ def branch_stats(self) -> dict[TLineNo, tuple[int, int]]:
Returns a dict mapping line numbers to a tuple:
(total_exits, taken_exits).
+
"""
missing_arcs = self.missing_branch_arcs()
diff --git a/coverage/sysmon.py b/coverage/sysmon.py
index 2809aa087..805b5f9a3 100644
--- a/coverage/sysmon.py
+++ b/coverage/sysmon.py
@@ -3,10 +3,9 @@
"""Callback functions and support for sys.monitoring data collection."""
-# TODO: https://github.com/python/cpython/issues/111963#issuecomment-2386584080
-
from __future__ import annotations
+import dis
import functools
import inspect
import os
@@ -16,14 +15,17 @@
import traceback
from dataclasses import dataclass
-from types import CodeType, FrameType
+from types import CodeType
from typing import (
Any,
Callable,
- TYPE_CHECKING,
+ Iterable,
+ NewType,
+ Optional,
cast,
)
+from coverage import env
from coverage.debug import short_filename, short_stack
from coverage.misc import isolate_module
from coverage.types import (
@@ -43,17 +45,31 @@
# pylint: disable=unused-argument
-LOG = False
+# $set_env.py: COVERAGE_SYSMON_LOG - Log sys.monitoring activity
+LOG = bool(int(os.getenv("COVERAGE_SYSMON_LOG", 0)))
+
+# $set_env.py: COVERAGE_SYSMON_STATS - Collect sys.monitoring stats
+COLLECT_STATS = bool(int(os.getenv("COVERAGE_SYSMON_STATS", 0)))
# This module will be imported in all versions of Python, but only used in 3.12+
# It will be type-checked for 3.12, but not for earlier versions.
sys_monitoring = getattr(sys, "monitoring", None)
-if TYPE_CHECKING:
- assert sys_monitoring is not None
- # I want to say this but it's not allowed:
- # MonitorReturn = Literal[sys.monitoring.DISABLE] | None
- MonitorReturn = Any
+DISABLE_TYPE = NewType("DISABLE_TYPE", object)
+MonitorReturn = Optional[DISABLE_TYPE]
+DISABLE = cast(MonitorReturn, getattr(sys_monitoring, "DISABLE", None))
+TOffset = int
+
+ALWAYS_JUMPS: set[int] = set()
+RETURNS: set[int] = set()
+
+if env.PYBEHAVIOR.branch_right_left:
+ ALWAYS_JUMPS.update(
+ dis.opmap[name]
+ for name in ["JUMP_FORWARD", "JUMP_BACKWARD", "JUMP_BACKWARD_NO_INTERRUPT"]
+ )
+
+ RETURNS.update(dis.opmap[name] for name in ["RETURN_VALUE", "RETURN_GENERATOR"])
if LOG: # pragma: debugging
@@ -76,7 +92,10 @@ def _wrapped(*args: Any, **kwargs: Any) -> Any:
assert sys_monitoring is not None
short_stack = functools.partial(
- short_stack, full=True, short_filenames=True, frame_ids=True,
+ short_stack,
+ full=True,
+ short_filenames=True,
+ frame_ids=True,
)
seen_threads: set[int] = set()
@@ -99,7 +118,10 @@ def log(msg: str) -> None:
# f"{root}-{pid}-{tslug}.out",
]:
with open(filename, "a") as f:
- print(f"{pid}:{tslug}: {msg}", file=f, flush=True)
+ try:
+ print(f"{pid}:{tslug}: {msg}", file=f, flush=True)
+ except UnicodeError:
+ print(f"{pid}:{tslug}: {ascii(msg)}", file=f, flush=True)
def arg_repr(arg: Any) -> str:
"""Make a customized repr for logged values."""
@@ -130,7 +152,9 @@ def _wrapped(self: Any, *args: Any) -> Any:
return ret
except Exception as exc:
log(f"!!{exc.__class__.__name__}: {exc}")
- log("".join(traceback.format_exception(exc))) # pylint: disable=[no-value-for-parameter]
+ if 1:
+ # pylint: disable=no-value-for-parameter
+ log("".join(traceback.format_exception(exc)))
try:
assert sys_monitoring is not None
sys_monitoring.set_events(sys.monitoring.COVERAGE_ID, 0)
@@ -157,17 +181,153 @@ def _decorator(meth: AnyCallable) -> AnyCallable:
return _decorator
+class InstructionWalker:
+ """Utility to step through trails of instructions.
+
+ We have two reasons to need sequences of instructions from a code object:
+ First, in strict sequence to visit all the instructions in the object.
+ This is `walk(follow_jumps=False)`. Second, we want to follow jumps to
+ understand how execution will flow: `walk(follow_jumps=True)`.
+
+ """
+
+ def __init__(self, code: CodeType) -> None:
+ self.code = code
+ self.insts: dict[TOffset, dis.Instruction] = {}
+
+ inst = None
+ for inst in dis.get_instructions(code):
+ self.insts[inst.offset] = inst
+
+ assert inst is not None
+ self.max_offset = inst.offset
+
+ def walk(
+ self, *, start_at: TOffset = 0, follow_jumps: bool = True
+ ) -> Iterable[dis.Instruction]:
+ """
+ Yield instructions starting from `start_at`. Follow unconditional
+ jumps if `follow_jumps` is true.
+ """
+ seen = set()
+ offset = start_at
+ while offset < self.max_offset + 1:
+ if offset in seen:
+ break
+ seen.add(offset)
+ if inst := self.insts.get(offset):
+ yield inst
+ if follow_jumps and inst.opcode in ALWAYS_JUMPS:
+ offset = inst.jump_target
+ continue
+ offset += 2
+
+
+def populate_branch_trails(code: CodeType, code_info: CodeInfo) -> None:
+ """
+ Populate the `branch_trails` attribute on `code_info`.
+
+ Instructions can have a jump_target, where they might jump to next. Some
+ instructions with a jump_target are unconditional jumps (ALWAYS_JUMPS), so
+ they aren't interesting to us, since they aren't the start of a branch
+ possibility.
+
+ Instructions that might or might not jump somewhere else are branch
+ possibilities. For each of those, we track a trail of instructions. These
+ are lists of instruction offsets, the next instructions that can execute.
+ We follow the trail until we get to a new source line. That gives us the
+ arc from the original instruction's line to the new source line.
+
+ """
+ # log(f"populate_branch_trails: {code}")
+ iwalker = InstructionWalker(code)
+ for inst in iwalker.walk(follow_jumps=False):
+ # log(f"considering {inst=}")
+ if not inst.jump_target:
+ # We only care about instructions with jump targets.
+ # log("no jump_target")
+ continue
+ if inst.opcode in ALWAYS_JUMPS:
+ # We don't care about unconditional jumps.
+ # log("always jumps")
+ continue
+
+ from_line = inst.line_number
+ if from_line is None:
+ continue
+
+ def walk_one_branch(
+ start_at: TOffset, branch_kind: str
+ ) -> tuple[list[TOffset], TArc | None]:
+ # pylint: disable=cell-var-from-loop
+ inst_offsets: list[TOffset] = []
+ to_line = None
+ for inst2 in iwalker.walk(start_at=start_at):
+ inst_offsets.append(inst2.offset)
+ if inst2.line_number and inst2.line_number != from_line:
+ to_line = inst2.line_number
+ break
+ elif inst2.jump_target and (inst2.opcode not in ALWAYS_JUMPS):
+ # log(
+ # f"stop: {inst2.jump_target=}, "
+ # + f"{inst2.opcode=} ({dis.opname[inst2.opcode]}), "
+ # + f"{ALWAYS_JUMPS=}"
+ # )
+ break
+ elif inst2.opcode in RETURNS:
+ to_line = -code.co_firstlineno
+ break
+ if to_line is not None:
+ # log(
+ # f"possible branch from @{start_at}: "
+ # + f"{inst_offsets}, {(from_line, to_line)} {code}"
+ # )
+ return inst_offsets, (from_line, to_line)
+ else:
+ # log(f"no possible branch from @{start_at}: {inst_offsets}")
+ return [], None
+
+ # Calculate two trails: one from the next instruction, and one from the
+ # jump_target instruction.
+ trails = [
+ walk_one_branch(start_at=inst.offset + 2, branch_kind="not-taken"),
+ walk_one_branch(start_at=inst.jump_target, branch_kind="taken"),
+ ]
+ code_info.branch_trails[inst.offset] = trails
+
+ # Sometimes we get BRANCH_RIGHT or BRANCH_LEFT events from instructions
+ # other than the original jump possibility instruction. Register each
+ # trail under all of their offsets so we can pick up in the middle of a
+ # trail if need be.
+ for trail in trails:
+ for offset in trail[0]:
+ if offset not in code_info.branch_trails:
+ code_info.branch_trails[offset] = []
+ code_info.branch_trails[offset].append(trail)
+
+
@dataclass
class CodeInfo:
"""The information we want about each code object."""
tracing: bool
file_data: TTraceFileData | None
- # TODO: what is byte_to_line for?
- byte_to_line: dict[int, int] | None
-
-
-def bytes_to_lines(code: CodeType) -> dict[int, int]:
+ byte_to_line: dict[TOffset, TLineNo] | None
+
+ # Keys are start instruction offsets for branches.
+ # Values are lists:
+ # [
+ # ([offset, offset, ...], (from_line, to_line)),
+ # ([offset, offset, ...], (from_line, to_line)),
+ # ]
+ # Two possible trails from the branch point, left and right.
+ branch_trails: dict[
+ TOffset,
+ list[tuple[list[TOffset], TArc | None]],
+ ]
+
+
+def bytes_to_lines(code: CodeType) -> dict[TOffset, TLineNo]:
"""Make a dict mapping byte code offsets to line numbers."""
b2l = {}
for bstart, bend, lineno in code.co_lines():
@@ -204,15 +364,14 @@ def __init__(self, tool_id: int) -> None:
# A list of code_objects, just to keep them alive so that id's are
# useful as identity.
self.code_objects: list[CodeType] = []
- self.last_lines: dict[FrameType, int] = {}
- # Map id(code_object) -> code_object
- self.local_event_codes: dict[int, CodeType] = {}
self.sysmon_on = False
self.lock = threading.Lock()
- self.stats = {
- "starts": 0,
- }
+ self.stats: dict[str, int] | None = None
+ if COLLECT_STATS:
+ self.stats = {
+ "starts": 0,
+ }
self.stopped = False
self._activity = False
@@ -230,20 +389,23 @@ def start(self) -> None:
assert sys_monitoring is not None
sys_monitoring.use_tool_id(self.myid, "coverage.py")
register = functools.partial(sys_monitoring.register_callback, self.myid)
- events = sys_monitoring.events
+ events = sys.monitoring.events
+
+ sys_monitoring.set_events(self.myid, events.PY_START)
+ register(events.PY_START, self.sysmon_py_start)
if self.trace_arcs:
- sys_monitoring.set_events(
- self.myid,
- events.PY_START | events.PY_UNWIND,
- )
- register(events.PY_START, self.sysmon_py_start)
- register(events.PY_RESUME, self.sysmon_py_resume_arcs)
- register(events.PY_RETURN, self.sysmon_py_return_arcs)
- register(events.PY_UNWIND, self.sysmon_py_unwind_arcs)
+ register(events.PY_RETURN, self.sysmon_py_return)
register(events.LINE, self.sysmon_line_arcs)
+ if env.PYBEHAVIOR.branch_right_left:
+ register(
+ events.BRANCH_RIGHT, # type:ignore[attr-defined]
+ self.sysmon_branch_either,
+ )
+ register(
+ events.BRANCH_LEFT, # type:ignore[attr-defined]
+ self.sysmon_branch_either,
+ )
else:
- sys_monitoring.set_events(self.myid, events.PY_START)
- register(events.PY_START, self.sysmon_py_start)
register(events.LINE, self.sysmon_line_lines)
sys_monitoring.restart_events()
self.sysmon_on = True
@@ -257,11 +419,7 @@ def stop(self) -> None:
return
assert sys_monitoring is not None
sys_monitoring.set_events(self.myid, 0)
- with self.lock:
- self.sysmon_on = False
- for code in self.local_event_codes.values():
- sys_monitoring.set_local_events(self.myid, code, 0)
- self.local_event_codes = {}
+ self.sysmon_on = False
sys_monitoring.free_tool_id(self.myid)
@panopticon()
@@ -279,29 +437,17 @@ def reset_activity(self) -> None:
def get_stats(self) -> dict[str, int] | None:
"""Return a dictionary of statistics, or None."""
- return None
-
- # The number of frames in callers_frame takes @panopticon into account.
- if LOG:
-
- def callers_frame(self) -> FrameType:
- """Get the frame of the Python code we're monitoring."""
- return (
- inspect.currentframe().f_back.f_back.f_back # type: ignore[union-attr,return-value]
- )
-
- else:
-
- def callers_frame(self) -> FrameType:
- """Get the frame of the Python code we're monitoring."""
- return inspect.currentframe().f_back.f_back # type: ignore[union-attr,return-value]
+ return self.stats
@panopticon("code", "@")
- def sysmon_py_start(self, code: CodeType, instruction_offset: int) -> MonitorReturn:
+ def sysmon_py_start(
+ self, code: CodeType, instruction_offset: TOffset
+ ) -> MonitorReturn:
"""Handle sys.monitoring.events.PY_START events."""
# Entering a new frame. Decide if we should trace in this file.
self._activity = True
- self.stats["starts"] += 1
+ if self.stats is not None:
+ self.stats["starts"] += 1
code_info = self.code_infos.get(id(code))
tracing_code: bool | None = None
@@ -337,11 +483,13 @@ def sysmon_py_start(self, code: CodeType, instruction_offset: int) -> MonitorRet
file_data = None
b2l = None
- self.code_infos[id(code)] = CodeInfo(
+ code_info = CodeInfo(
tracing=tracing_code,
file_data=file_data,
byte_to_line=b2l,
+ branch_trails={},
)
+ self.code_infos[id(code)] = code_info
self.code_objects.append(code)
if tracing_code:
@@ -349,90 +497,85 @@ def sysmon_py_start(self, code: CodeType, instruction_offset: int) -> MonitorRet
with self.lock:
if self.sysmon_on:
assert sys_monitoring is not None
- sys_monitoring.set_local_events(
- self.myid,
- code,
- events.PY_RETURN
- #
- | events.PY_RESUME
- # | events.PY_YIELD
- | events.LINE,
- # | events.BRANCH
- # | events.JUMP
- )
- self.local_event_codes[id(code)] = code
-
- if tracing_code and self.trace_arcs:
- frame = self.callers_frame()
- self.last_lines[frame] = -code.co_firstlineno
- return None
- else:
- return sys.monitoring.DISABLE
+ local_events = events.PY_RETURN | events.PY_RESUME | events.LINE
+ if self.trace_arcs:
+ assert env.PYBEHAVIOR.branch_right_left
+ local_events |= (
+ events.BRANCH_RIGHT # type:ignore[attr-defined]
+ | events.BRANCH_LEFT # type:ignore[attr-defined]
+ )
+ sys_monitoring.set_local_events(self.myid, code, local_events)
- @panopticon("code", "@")
- def sysmon_py_resume_arcs(
- self, code: CodeType, instruction_offset: int,
- ) -> MonitorReturn:
- """Handle sys.monitoring.events.PY_RESUME events for branch coverage."""
- frame = self.callers_frame()
- self.last_lines[frame] = frame.f_lineno
+ return DISABLE
@panopticon("code", "@", None)
- def sysmon_py_return_arcs(
- self, code: CodeType, instruction_offset: int, retval: object,
+ def sysmon_py_return(
+ self,
+ code: CodeType,
+ instruction_offset: TOffset,
+ retval: object,
) -> MonitorReturn:
"""Handle sys.monitoring.events.PY_RETURN events for branch coverage."""
- frame = self.callers_frame()
code_info = self.code_infos.get(id(code))
if code_info is not None and code_info.file_data is not None:
- last_line = self.last_lines.get(frame)
+ assert code_info.byte_to_line is not None
+ last_line = code_info.byte_to_line[instruction_offset]
if last_line is not None:
arc = (last_line, -code.co_firstlineno)
- # log(f"adding {arc=}")
cast(set[TArc], code_info.file_data).add(arc)
-
- # Leaving this function, no need for the frame any more.
- self.last_lines.pop(frame, None)
-
- @panopticon("code", "@", "exc")
- def sysmon_py_unwind_arcs(
- self, code: CodeType, instruction_offset: int, exception: BaseException,
- ) -> MonitorReturn:
- """Handle sys.monitoring.events.PY_UNWIND events for branch coverage."""
- frame = self.callers_frame()
- # Leaving this function.
- last_line = self.last_lines.pop(frame, None)
- if isinstance(exception, GeneratorExit):
- # We don't want to count generator exits as arcs.
- return
- code_info = self.code_infos.get(id(code))
- if code_info is not None and code_info.file_data is not None:
- if last_line is not None:
- arc = (last_line, -code.co_firstlineno)
# log(f"adding {arc=}")
- cast(set[TArc], code_info.file_data).add(arc)
-
+ return DISABLE
@panopticon("code", "line")
- def sysmon_line_lines(self, code: CodeType, line_number: int) -> MonitorReturn:
+ def sysmon_line_lines(self, code: CodeType, line_number: TLineNo) -> MonitorReturn:
"""Handle sys.monitoring.events.LINE events for line coverage."""
- code_info = self.code_infos[id(code)]
- if code_info.file_data is not None:
+ code_info = self.code_infos.get(id(code))
+ if code_info is not None and code_info.file_data is not None:
cast(set[TLineNo], code_info.file_data).add(line_number)
# log(f"adding {line_number=}")
- return sys.monitoring.DISABLE
+ return DISABLE
@panopticon("code", "line")
- def sysmon_line_arcs(self, code: CodeType, line_number: int) -> MonitorReturn:
+ def sysmon_line_arcs(self, code: CodeType, line_number: TLineNo) -> MonitorReturn:
"""Handle sys.monitoring.events.LINE events for branch coverage."""
code_info = self.code_infos[id(code)]
- ret = None
if code_info.file_data is not None:
- frame = self.callers_frame()
- last_line = self.last_lines.get(frame)
- if last_line is not None:
- arc = (last_line, line_number)
- cast(set[TArc], code_info.file_data).add(arc)
+ arc = (line_number, line_number)
+ cast(set[TArc], code_info.file_data).add(arc)
# log(f"adding {arc=}")
- self.last_lines[frame] = line_number
- return ret
+ return DISABLE
+
+ @panopticon("code", "@", "@")
+ def sysmon_branch_either(
+ self, code: CodeType, instruction_offset: TOffset, destination_offset: TOffset
+ ) -> MonitorReturn:
+ """Handle BRANCH_RIGHT and BRANCH_LEFT events."""
+ code_info = self.code_infos[id(code)]
+ if code_info.file_data is not None:
+ if not code_info.branch_trails:
+ populate_branch_trails(code, code_info)
+ # log(f"branch_trails for {code}:\n {code_info.branch_trails}")
+ added_arc = False
+ dest_info = code_info.branch_trails.get(instruction_offset)
+ # log(f"{dest_info = }")
+ if dest_info is not None:
+ for offsets, arc in dest_info:
+ if arc is None:
+ continue
+ if destination_offset in offsets:
+ cast(set[TArc], code_info.file_data).add(arc)
+ # log(f"adding {arc=}")
+ added_arc = True
+ break
+
+ if not added_arc:
+ # This could be an exception jumping from line to line.
+ assert code_info.byte_to_line is not None
+ l1 = code_info.byte_to_line[instruction_offset]
+ l2 = code_info.byte_to_line[destination_offset]
+ if l1 != l2:
+ arc = (l1, l2)
+ cast(set[TArc], code_info.file_data).add(arc)
+ # log(f"adding unforeseen {arc=}")
+
+ return DISABLE
diff --git a/coverage/version.py b/coverage/version.py
index e81549634..d9c814811 100644
--- a/coverage/version.py
+++ b/coverage/version.py
@@ -8,7 +8,7 @@
# version_info: same semantics as sys.version_info.
# _dev: the .devN suffix if any.
-version_info = (7, 6, 12, "final", 0)
+version_info = (7, 7, 0, "final", 0)
_dev = 0
diff --git a/doc/cmd.rst b/doc/cmd.rst
index fa6565678..3629322e2 100644
--- a/doc/cmd.rst
+++ b/doc/cmd.rst
@@ -273,14 +273,26 @@ Conflicting dynamic contexts (dynamic-conflict)
:meth:`.Coverage.switch_context` function to change the context. Only one of
these mechanisms should be in use at a time.
-sys.monitoring isn't available, using default core (no-sysmon)
+sys.monitoring isn't available in this version, using default core (no-sysmon)
You requested to use the sys.monitoring measurement core, but are running on
Python 3.11 or lower where it isn't available. A default core will be used
instead.
+sys.monitoring can't measure branches in this version, using default core (no-sysmon)
+ You requested the sys.monitoring measurement core and also branch coverage.
+ This isn't supported until the later alphas of Python 3.14. A default core
+ will be used instead.
+
+sys.monitoring doesn't yet support dynamic contexts, using default core (no-sysmon)
+ You requested the sys.monitoring measurement core and also dynamic contexts.
+ This isn't supported by coverage.py yet. A default core will be used
+ instead.
+
Individual warnings can be disabled with the :ref:`disable_warnings
-` configuration setting. To silence "No data was
-collected," add this to your configuration file:
+` configuration setting. It is a list of the
+short parenthetical nicknames in the warning messages. For example, to silence
+"No data was collected (no-data-collected)", add this to your configuration
+file:
.. [[[cog
show_configs(
diff --git a/doc/conf.py b/doc/conf.py
index 79c68d962..f94d30f37 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -67,11 +67,11 @@
# @@@ editable
copyright = "2009–2025, Ned Batchelder" # pylint: disable=redefined-builtin
# The short X.Y.Z version.
-version = "7.6.12"
+version = "7.7.0"
# The full version, including alpha/beta/rc tags.
-release = "7.6.12"
+release = "7.7.0"
# The date of release, in "monthname day, year" format.
-release_date = "February 11, 2025"
+release_date = "March 16, 2025"
# @@@ end
rst_epilog = f"""
diff --git a/doc/index.rst b/doc/index.rst
index 0dd80371b..ea3486e12 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -18,8 +18,8 @@ supported on:
.. PYVERSIONS
-* Python 3.9 through 3.14 alpha 4, including free-threading.
-* PyPy3 versions 3.9 and 3.10.
+* Python 3.9 through 3.14 alpha 6, including free-threading.
+* PyPy3 versions 3.9, 3.10, and 3.11.
.. ifconfig:: prerelease
diff --git a/doc/requirements.pip b/doc/requirements.pip
index 9d48239a6..ae5de5420 100644
--- a/doc/requirements.pip
+++ b/doc/requirements.pip
@@ -10,9 +10,9 @@ anyio==4.8.0
# via
# starlette
# watchfiles
-babel==2.16.0
+babel==2.17.0
# via sphinx
-certifi==2024.12.14
+certifi==2025.1.31
# via requests
charset-normalizer==3.4.1
# via requests
@@ -38,13 +38,13 @@ idna==3.10
# requests
imagesize==1.4.1
# via sphinx
-jinja2==3.1.5
+jinja2==3.1.6
# via sphinx
markupsafe==3.0.2
# via jinja2
packaging==24.2
# via sphinx
-pbr==6.1.0
+pbr==6.1.1
# via stevedore
polib==1.2.0
# via sphinx-lint
@@ -64,11 +64,13 @@ requests==2.32.3
# sphinxcontrib-spelling
restructuredtext-lint==1.4.0
# via doc8
+roman-numerals-py==3.1.0
+ # via sphinx
sniffio==1.3.1
# via anyio
snowballstemmer==2.2.0
# via sphinx
-sphinx==8.1.3
+sphinx==8.2.3
# via
# -r doc/requirements.in
# sphinx-autobuild
@@ -103,9 +105,9 @@ sphinxcontrib-serializinghtml==2.0.0
# via sphinx
sphinxcontrib-spelling==8.0.1
# via -r doc/requirements.in
-starlette==0.45.3
+starlette==0.46.1
# via sphinx-autobuild
-stevedore==5.4.0
+stevedore==5.4.1
# via doc8
typing-extensions==4.12.2
# via anyio
@@ -115,5 +117,9 @@ uvicorn==0.34.0
# via sphinx-autobuild
watchfiles==1.0.4
# via sphinx-autobuild
-websockets==14.2
+websockets==15.0.1
# via sphinx-autobuild
+
+# The following packages are considered to be unsafe in a requirements file:
+setuptools==76.0.0
+ # via pbr
diff --git a/doc/sample_html/class_index.html b/doc/sample_html/class_index.html
index b0648001c..d41b6c268 100644
--- a/doc/sample_html/class_index.html
+++ b/doc/sample_html/class_index.html
@@ -56,8 +56,8 @@
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -537,8 +537,8 @@
@@ -97,8 +97,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80___main___py.html b/doc/sample_html/z_7b071bdc2a35fa80___main___py.html
index 86c241e01..1bbd252bd 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80___main___py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80___main___py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -97,8 +97,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_cogapp_py.html b/doc/sample_html/z_7b071bdc2a35fa80_cogapp_py.html
index 870380e0e..5bb5d7fa6 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_cogapp_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_cogapp_py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -928,8 +928,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html b/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html
index 754c3e398..d89a20036 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -127,8 +127,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html
index f1d157609..d48c4569e 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -2737,8 +2737,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html
index 76a862b2c..319aae177 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -205,8 +205,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html
index 96cbab5f3..a1f3a3ade 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -186,8 +186,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html
index a3a1f7013..2a6e08c45 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -159,8 +159,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html
index 3d3002616..106337d0e 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html
@@ -66,8 +66,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
@@ -159,8 +159,8 @@
^ index
» next
- coverage.py v7.6.12,
- created at 2025-02-11 08:59 -0500
+ coverage.py v7.7.0,
+ created at 2025-03-16 13:28 -0400
diff --git a/igor.py b/igor.py
index 11b295cc4..aecd46299 100644
--- a/igor.py
+++ b/igor.py
@@ -133,7 +133,10 @@ def should_skip(core):
only_one = os.getenv("COVERAGE_ONE_CORE")
if only_one:
if CPYTHON:
- if core != "ctrace":
+ if sys.version_info >= (3, 12):
+ if core != "sysmon":
+ skipper = f"Only one core: not running {core}"
+ elif core != "ctrace":
skipper = f"Only one core: not running {core}"
else:
if core != "pytrace":
diff --git a/lab/run_sysmon.py b/lab/run_sysmon.py
index fbbd6a315..f88988bbe 100644
--- a/lab/run_sysmon.py
+++ b/lab/run_sysmon.py
@@ -27,6 +27,15 @@ def bytes_to_lines(code):
return b2l
+MY_EVENTS = (
+ events.PY_RETURN
+ | events.PY_RESUME
+ | events.LINE
+ | events.BRANCH_RIGHT
+ | events.BRANCH_LEFT
+ | events.JUMP
+)
+
def show_off(label, code, instruction_offset):
if code.co_filename == the_program:
b2l = bytes_to_lines(code)
@@ -49,12 +58,7 @@ def sysmon_py_start(code, instruction_offset):
sys.monitoring.set_local_events(
my_id,
code,
- events.PY_RETURN
- | events.PY_RESUME
- | events.LINE
- | events.BRANCH_TAKEN
- | events.BRANCH_NOT_TAKEN
- | events.JUMP,
+ MY_EVENTS,
)
@@ -78,13 +82,13 @@ def sysmon_branch(code, instruction_offset, destination_offset):
return sys.monitoring.DISABLE
-def sysmon_branch_taken(code, instruction_offset, destination_offset):
- show_off_off("BRANCH_TAKEN", code, instruction_offset, destination_offset)
+def sysmon_branch_right(code, instruction_offset, destination_offset):
+ show_off_off("BRANCH_RIGHT", code, instruction_offset, destination_offset)
return sys.monitoring.DISABLE
-def sysmon_branch_not_taken(code, instruction_offset, destination_offset):
- show_off_off("BRANCH_NOT_TAKEN", code, instruction_offset, destination_offset)
+def sysmon_branch_left(code, instruction_offset, destination_offset):
+ show_off_off("BRANCH_LEFT", code, instruction_offset, destination_offset)
return sys.monitoring.DISABLE
@@ -93,18 +97,19 @@ def sysmon_jump(code, instruction_offset, destination_offset):
return sys.monitoring.DISABLE
-sys.monitoring.set_events(
- my_id,
- events.PY_START | events.PY_UNWIND,
-)
-register(events.PY_START, sysmon_py_start)
-register(events.PY_RESUME, sysmon_py_resume)
-register(events.PY_RETURN, sysmon_py_return)
-# register(events.PY_UNWIND, sysmon_py_unwind_arcs)
-register(events.LINE, sysmon_line)
-#register(events.BRANCH, sysmon_branch)
-register(events.BRANCH_TAKEN, sysmon_branch_taken)
-register(events.BRANCH_NOT_TAKEN, sysmon_branch_not_taken)
-register(events.JUMP, sysmon_jump)
+if 1:
+ sys.monitoring.set_events(
+ my_id,
+ events.PY_START | events.PY_UNWIND,
+ )
+ register(events.PY_START, sysmon_py_start)
+ register(events.PY_RESUME, sysmon_py_resume)
+ register(events.PY_RETURN, sysmon_py_return)
+ # register(events.PY_UNWIND, sysmon_py_unwind_arcs)
+ register(events.LINE, sysmon_line)
+ register(events.BRANCH, sysmon_branch)
+ register(events.BRANCH_RIGHT, sysmon_branch_right)
+ register(events.BRANCH_LEFT, sysmon_branch_left)
+ register(events.JUMP, sysmon_jump)
exec(code)
diff --git a/metacov.ini b/metacov.ini
index 8e00747ba..1aa05926b 100644
--- a/metacov.ini
+++ b/metacov.ini
@@ -9,6 +9,7 @@
[run]
branch = true
data_file = ${COVERAGE_METAFILE-.metacov}
+disable_warnings = no-sysmon
parallel = true
relative_files = true
source =
diff --git a/pyproject.toml b/pyproject.toml
index b08f23242..1d3199d12 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -120,10 +120,16 @@ markers = [
# How come these warnings are suppressed successfully here, but not in conftest.py??
filterwarnings = [
# Sample 'ignore':
- #"ignore:the imp module is deprecated in favour of importlib:DeprecationWarning",
+ # "ignore:the imp module is deprecated in favour of importlib:DeprecationWarning",
+
+ # Note: when writing the regex for the message, it's matched with re.match,
+ # so it has to match the beginning of the message. Add ".*" to make it
+ # match something in the middle of the message.
## Pytest warns if it can't collect things that seem to be tests. This should be an error.
"error::pytest.PytestCollectionWarning",
+
+ "ignore:.*no-sysmon"
]
# xfail tests that pass should fail the test suite
diff --git a/requirements/dev.pip b/requirements/dev.pip
index 6a49e82ab..13e6b0d81 100644
--- a/requirements/dev.pip
+++ b/requirements/dev.pip
@@ -4,7 +4,7 @@
#
# make upgrade
#
-astroid==3.3.8
+astroid==3.3.9
# via pylint
attrs==25.1.0
# via hypothesis
@@ -12,7 +12,7 @@ backports-tarfile==1.2.0
# via jaraco-context
build==1.2.2.post1
# via check-manifest
-cachetools==5.5.1
+cachetools==5.5.2
# via tox
certifi==2025.1.31
# via requests
@@ -49,7 +49,7 @@ flaky==3.8.1
# via -r /Users/ned/coverage/trunk/requirements/pytest.in
greenlet==3.1.1
# via -r requirements/dev.in
-hypothesis==6.125.1
+hypothesis==6.128.1
# via -r /Users/ned/coverage/trunk/requirements/pytest.in
id==1.5.0
# via twine
@@ -62,7 +62,7 @@ importlib-metadata==8.6.1
# twine
iniconfig==2.0.0
# via pytest
-isort==6.0.0
+isort==6.0.1
# via pylint
jaraco-classes==3.4.0
# via keyring
@@ -86,7 +86,7 @@ more-itertools==10.6.0
# via
# jaraco-classes
# jaraco-functools
-nh3==0.2.20
+nh3==0.2.21
# via readme-renderer
packaging==24.2
# via
@@ -115,13 +115,13 @@ pygments==2.19.1
# pudb
# readme-renderer
# rich
-pylint==3.3.4
+pylint==3.3.5
# via -r requirements/dev.in
pyproject-api==1.9.0
# via tox
pyproject-hooks==1.2.0
# via build
-pytest==8.3.4
+pytest==8.3.5
# via
# -r /Users/ned/coverage/trunk/requirements/pytest.in
# pytest-xdist
@@ -193,9 +193,9 @@ zipp==3.21.0
# via importlib-metadata
# The following packages are considered to be unsafe in a requirements file:
-pip==25.0
+pip==25.0.1
# via -r /Users/ned/coverage/trunk/requirements/pip.in
-setuptools==75.8.0
+setuptools==76.0.0
# via
# -r /Users/ned/coverage/trunk/requirements/pip.in
# check-manifest
diff --git a/requirements/kit.pip b/requirements/kit.pip
index 33e6e8828..7b7c8a6a3 100644
--- a/requirements/kit.pip
+++ b/requirements/kit.pip
@@ -20,7 +20,7 @@ certifi==2025.1.31
# requests
charset-normalizer==3.4.1
# via requests
-cibuildwheel==2.22.0
+cibuildwheel==2.23.0
# via -r requirements/kit.in
colorama==0.4.6
# via -r requirements/kit.in
@@ -55,7 +55,7 @@ more-itertools==10.6.0
# via
# jaraco-classes
# jaraco-functools
-nh3==0.2.20
+nh3==0.2.21
# via readme-renderer
packaging==24.2
# via
@@ -66,7 +66,7 @@ packaging==24.2
# twine
platformdirs==4.3.6
# via cibuildwheel
-pyelftools==0.31
+pyelftools==0.32
# via auditwheel
pygments==2.19.1
# via
@@ -108,5 +108,5 @@ zipp==3.21.0
# via importlib-metadata
# The following packages are considered to be unsafe in a requirements file:
-setuptools==75.8.0
+setuptools==76.0.0
# via -r requirements/kit.in
diff --git a/requirements/light-threads.pip b/requirements/light-threads.pip
index dfb02c03c..aff5c3b49 100644
--- a/requirements/light-threads.pip
+++ b/requirements/light-threads.pip
@@ -8,7 +8,7 @@ cffi==1.17.1
# via -r requirements/light-threads.in
dnspython==2.7.0
# via eventlet
-eventlet==0.39.0
+eventlet==0.39.1
# via -r requirements/light-threads.in
gevent==24.11.1
# via -r requirements/light-threads.in
@@ -25,7 +25,7 @@ zope-interface==7.2
# via gevent
# The following packages are considered to be unsafe in a requirements file:
-setuptools==75.8.0
+setuptools==76.0.0
# via
# zope-event
# zope-interface
diff --git a/requirements/mypy.pip b/requirements/mypy.pip
index 53eb29101..6d63badc7 100644
--- a/requirements/mypy.pip
+++ b/requirements/mypy.pip
@@ -16,11 +16,11 @@ execnet==2.1.1
# via pytest-xdist
flaky==3.8.1
# via -r /Users/ned/coverage/trunk/requirements/pytest.in
-hypothesis==6.125.1
+hypothesis==6.128.1
# via -r /Users/ned/coverage/trunk/requirements/pytest.in
iniconfig==2.0.0
# via pytest
-mypy==1.14.1
+mypy==1.15.0
# via -r requirements/mypy.in
mypy-extensions==1.0.0
# via mypy
@@ -30,7 +30,7 @@ pluggy==1.5.0
# via pytest
pygments==2.19.1
# via -r /Users/ned/coverage/trunk/requirements/pytest.in
-pytest==8.3.4
+pytest==8.3.5
# via
# -r /Users/ned/coverage/trunk/requirements/pytest.in
# pytest-xdist
@@ -42,7 +42,7 @@ tomli==2.2.1
# via
# mypy
# pytest
-types-requests==2.32.0.20241016
+types-requests==2.32.0.20250306
# via -r requirements/mypy.in
types-tabulate==0.9.0.20241207
# via -r requirements/mypy.in
diff --git a/requirements/pip-tools.pip b/requirements/pip-tools.pip
index 5ddf2973d..72ccd349b 100644
--- a/requirements/pip-tools.pip
+++ b/requirements/pip-tools.pip
@@ -28,7 +28,7 @@ zipp==3.21.0
# via importlib-metadata
# The following packages are considered to be unsafe in a requirements file:
-pip==25.0
+pip==25.0.1
# via pip-tools
-setuptools==75.8.0
+setuptools==76.0.0
# via pip-tools
diff --git a/requirements/pip.pip b/requirements/pip.pip
index 9d37a7727..4827fc3c1 100644
--- a/requirements/pip.pip
+++ b/requirements/pip.pip
@@ -16,7 +16,7 @@ virtualenv==20.28.1
# -r requirements/pip.in
# The following packages are considered to be unsafe in a requirements file:
-pip==25.0
+pip==25.0.1
# via -r requirements/pip.in
-setuptools==75.8.0
+setuptools==76.0.0
# via -r requirements/pip.in
diff --git a/requirements/pytest.pip b/requirements/pytest.pip
index b11451f74..4d12a374a 100644
--- a/requirements/pytest.pip
+++ b/requirements/pytest.pip
@@ -16,7 +16,7 @@ execnet==2.1.1
# via pytest-xdist
flaky==3.8.1
# via -r requirements/pytest.in
-hypothesis==6.125.1
+hypothesis==6.128.1
# via -r requirements/pytest.in
iniconfig==2.0.0
# via pytest
@@ -26,7 +26,7 @@ pluggy==1.5.0
# via pytest
pygments==2.19.1
# via -r requirements/pytest.in
-pytest==8.3.4
+pytest==8.3.5
# via
# -r requirements/pytest.in
# pytest-xdist
diff --git a/requirements/tox.pip b/requirements/tox.pip
index fa4634d70..a2387c529 100644
--- a/requirements/tox.pip
+++ b/requirements/tox.pip
@@ -4,7 +4,7 @@
#
# make upgrade
#
-cachetools==5.5.1
+cachetools==5.5.2
# via tox
chardet==5.2.0
# via tox
diff --git a/tests/conftest.py b/tests/conftest.py
index eff1d27d6..876dc0827 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -19,7 +19,6 @@
import pytest
-from coverage import env
from coverage.files import set_relative_directory
# Pytest will rewrite assertions in test modules, but not elsewhere.
@@ -45,9 +44,9 @@ def set_warnings() -> None:
# Warnings to suppress:
# How come these warnings are successfully suppressed here, but not in pyproject.toml??
- if env.PYPY:
- # pypy3 warns about unclosed files a lot.
- warnings.filterwarnings("ignore", r".*unclosed file", category=ResourceWarning)
+ # Note: when writing the regex for the message, it's matched with re.match,
+ # so it has to match the beginning of the message. Add ".*" to make it
+ # match something in the middle of the message.
# Don't warn about unclosed SQLite connections.
# We don't close ":memory:" databases because we don't have a way to connect
@@ -59,6 +58,8 @@ def set_warnings() -> None:
# https://github.com/python/cpython/issues/105539
warnings.filterwarnings("ignore", r"unclosed database", category=ResourceWarning)
+ warnings.filterwarnings("ignore", r".*no-sysmon")
+
@pytest.fixture(autouse=True)
def reset_sys_path() -> Iterator[None]:
@@ -87,6 +88,7 @@ def reset_filesdotpy_globals() -> Iterator[None]:
def pytest_sessionstart() -> None:
"""Run once at the start of the test session."""
+ warnings.filterwarnings("ignore", r".*no-sysmon")
# Only in the main process...
if WORKER == "none":
# Create a .pth file for measuring subprocess coverage.
diff --git a/tests/coveragetest.py b/tests/coveragetest.py
index c5db35882..8179433f7 100644
--- a/tests/coveragetest.py
+++ b/tests/coveragetest.py
@@ -60,7 +60,7 @@ def arcs_to_branches(arcs: Iterable[TArc]) -> dict[TLineNo, list[TLineNo]]:
def branches_to_arcs(branches: dict[TLineNo, list[TLineNo]]) -> list[TArc]:
- """Convert a dict od branches into a list of arcs."""
+ """Convert a dict of branches into a list of arcs."""
return [(fromno, tono) for fromno, tonos in branches.items() for tono in tonos]
@@ -146,6 +146,7 @@ def get_module_name(self) -> str:
def check_coverage(
self,
text: str,
+ *,
lines: Sequence[TLineNo] | Sequence[list[TLineNo]] | None = None,
missing: str = "",
report: str = "",
@@ -208,8 +209,31 @@ def check_coverage(
else:
# lines is a list of possible line number lists, one of them
# must match.
- for line_list in lines:
+ for i, line_list in enumerate(lines): # pylint: disable=unused-variable
if statements == line_list:
+ # PYVERSIONS: we might be able to trim down multiple
+ # lines passed into this function.
+ # Uncomment this code, run the whole test suite, then
+ # sort /tmp/check_coverage_multi_line.out to group the
+ # tests together and see if any of the `lines` elements
+ # haven't been used.
+ # One of the calls in test_successful_coverage passes
+ # three `lines` elements, only one of which is right.
+ # We need to keep that test until we can delete the
+ # multi-lines option entirely.
+ #
+ # import inspect, platform
+ # frinfo = inspect.getframeinfo(inspect.currentframe().f_back)
+ # version = "{} {}.{}".format(
+ # platform.python_implementation(),
+ # *sys.version_info[:2],
+ # )
+ # with open("/tmp/check_coverage_multi_line.out", "a") as f:
+ # print(
+ # f"{frinfo.filename}@{frinfo.lineno}: "
+ # + f"lines {i + 1}/{len(lines)}: {version}",
+ # file=f,
+ # )
break
else:
assert False, f"None of the lines choices matched {statements!r}"
diff --git a/tests/gold/README.rst b/tests/gold/README.rst
index 00b43ff28..b80bc0f77 100644
--- a/tests/gold/README.rst
+++ b/tests/gold/README.rst
@@ -14,19 +14,18 @@ output is in the tests/actual directory. Those files are ignored by git.
There's a Makefile in the html directory for working with gold files and their
associated support files.
-To view the tests/actual files, you need to tentatively copy them to the gold
-directories, and then add the supporting files so they can be viewed as
-complete output. For example::
+The gold files and the actual output files are not viewable as-is: they are
+missing the support files (css etc) they need. You can copy those support
+files with::
- cp tests/actual/html/contexts/* tests/gold/html/contexts
cd tests/gold/html
make complete
-If the new actual output is correct, you can use "make update-gold" to copy the
-actual output as the new gold files.
+If the new actual output is correct, you can use ``make update-gold`` to copy
+the actual output as the new gold files.
-If you have changed some of the supporting files (.css or .js), then "make
-update-support" will copy the updated files to the tests/gold/html/support
+If you have changed some of the supporting files (.css or .js), then ``make
+update-support`` will copy the updated files to the tests/gold/html/support
directory for checking test output.
If you have added a gold test, you'll need to manually copy the tests/actual
@@ -39,11 +38,11 @@ again, you can run just the failed tests again with::
The saved HTML files in the html directories can't be viewed properly without
the supporting CSS and Javascript files. But we don't want to save copies of
-those files in every subdirectory. The make target "make complete" in
+those files in every subdirectory. The make target ``make complete`` in
tests/gold/html will copy the support files so you can open the HTML files to
-see how they look. When you are done checking the output, you can use "make
-clean" to remove the support files from the gold directories.
+see how they look. When you are done checking the output, you can use ``make
+clean`` to remove the support files from the gold directories.
-If the output files are correct, you can update the gold files with "make
-update-gold". If there are version-specific gold files (for example,
+If the output files are correct, you can update the gold files with ``make
+update-gold``. If there are version-specific gold files (for example,
bom/2/\*), you'll need to update them manually.
diff --git a/tests/gold/html/Makefile b/tests/gold/html/Makefile
index 5ae08b44e..d9668ce68 100644
--- a/tests/gold/html/Makefile
+++ b/tests/gold/html/Makefile
@@ -6,7 +6,7 @@ help:
@grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf " %-26s%s\n", $$1, $$2}'
complete: ## Copy support files into directories so the HTML can be viewed properly.
- @for sub in *; do \
+ @for sub in * ../../actual/html/*; do \
if [ -f "$$sub/index.html" ]; then \
echo Copying into $$sub ; \
cp -n support/* $$sub ; \
diff --git a/tests/gold/html/partial/class_index.html b/tests/gold/html/partial/class_index.html
index e455ca6ba..14307fd8a 100644
--- a/tests/gold/html/partial/class_index.html
+++ b/tests/gold/html/partial/class_index.html
@@ -4,8 +4,8 @@
Coverage report
-
-
+
+
@@ -107,8 +107,8 @@