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 @@

Classes

- 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 @@

- 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