From b72b06763d2f912ab0aac7704befc01301a58a73 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Fri, 21 Mar 2025 13:39:26 -0400
Subject: [PATCH 01/14] build: bump version to 7.7.2
---
CHANGES.rst | 6 ++++++
coverage/version.py | 4 ++--
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/CHANGES.rst b/CHANGES.rst
index 4d3e07702..3f09c0c41 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -20,6 +20,12 @@ upgrading your version of coverage.py.
.. Version 9.8.1 — 2027-07-27
.. --------------------------
+Unreleased
+----------
+
+Nothing yet.
+
+
.. start-releases
.. _changes_7-7-1:
diff --git a/coverage/version.py b/coverage/version.py
index b14eab49f..2e944bc97 100644
--- a/coverage/version.py
+++ b/coverage/version.py
@@ -8,8 +8,8 @@
# version_info: same semantics as sys.version_info.
# _dev: the .devN suffix if any.
-version_info = (7, 7, 1, "final", 0)
-_dev = 0
+version_info = (7, 7, 2, "alpha", 0)
+_dev = 1
def _make_version(
From ba685fb8d06a2052c4916749539ef501b8080804 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Sat, 22 Mar 2025 08:59:45 -0400
Subject: [PATCH 02/14] build: setuptools 77 doesn't like license classifiers.
#1939
---
setup.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/setup.py b/setup.py
index 9aa82bf91..8fcec5e2d 100644
--- a/setup.py
+++ b/setup.py
@@ -19,7 +19,6 @@
classifiers = """\
Environment :: Console
Intended Audience :: Developers
-License :: OSI Approved :: Apache Software License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3
From 7c2844c6c42b1f64ca876b3af937c14dc31323b7 Mon Sep 17 00:00:00 2001
From: Philipp A
Date: Mon, 13 Nov 2023 12:49:48 +0100
Subject: [PATCH 03/14] fix: support PYTHONSAFEPATH #1696
Fix
make test mostly work
changes from code review
changes from code review
Fix lint error
---
coverage/execfile.py | 5 ++++-
tests/test_execfile.py | 11 +++++++++++
tests/test_process.py | 19 +++++++++++++++++++
3 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/coverage/execfile.py b/coverage/execfile.py
index 0affda498..1da00ec4b 100644
--- a/coverage/execfile.py
+++ b/coverage/execfile.py
@@ -89,7 +89,10 @@ def prepare(self) -> None:
This needs to happen before any importing, and without importing anything.
"""
path0: str | None
- if self.as_module:
+ if env.PYVERSION >= (3, 11) and os.environ.get('PYTHONSAFEPATH', ''):
+ # See https://docs.python.org/3/using/cmdline.html#cmdoption-P
+ path0 = None
+ elif self.as_module:
path0 = os.getcwd()
elif os.path.isdir(self.arg0):
# Running a directory means running the __main__.py file in that
diff --git a/tests/test_execfile.py b/tests/test_execfile.py
index cd12dea99..ab089229c 100644
--- a/tests/test_execfile.py
+++ b/tests/test_execfile.py
@@ -14,6 +14,7 @@
import re
import sys
+from pathlib import Path
from typing import Any
from collections.abc import Iterator
@@ -24,6 +25,7 @@
from coverage.files import python_reported_file
from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin
+from tests.helpers import change_dir
TRY_EXECFILE = os.path.join(TESTS_DIR, "modules/process_test/try_execfile.py")
@@ -307,6 +309,15 @@ def test_pkg1_init(self) -> None:
assert out == "pkg1.__init__: pkg1\npkg1.__init__: __main__\n"
assert err == ""
+ def test_pythonpath(self, tmp_path: Path) -> None:
+ self.set_environ("PYTHONSAFEPATH", "1")
+ with change_dir(tmp_path):
+ run_python_module(["process_test.try_execfile"])
+ out, err = self.stdouterr()
+ mod_globs = json.loads(out)
+ assert tmp_path not in mod_globs["path"]
+ assert err == ""
+
def test_no_such_module(self) -> None:
with pytest.raises(NoSource, match="No module named '?i_dont_exist'?"):
run_python_module(["i_dont_exist"])
diff --git a/tests/test_process.py b/tests/test_process.py
index 082fa917f..6834fbbba 100644
--- a/tests/test_process.py
+++ b/tests/test_process.py
@@ -807,6 +807,25 @@ def test_coverage_zip_is_like_python(self) -> None:
actual = self.run_command(f"python {cov_main} run run_me.py")
self.assert_tryexecfile_output(expected, actual)
+ def test_pythonsafepath(self) -> None:
+ with open(TRY_EXECFILE) as f:
+ self.make_file("run_me.py", f.read())
+ self.set_environ("PYTHONSAFEPATH", "1")
+ expected = self.run_command("python run_me.py")
+ actual = self.run_command("coverage run run_me.py")
+ self.assert_tryexecfile_output(expected, actual)
+
+ @pytest.mark.skipif(env.PYVERSION < (3, 11), reason="PYTHONSAFEPATH is new in 3.11")
+ def test_pythonsafepath_dashm(self) -> None:
+ with open(TRY_EXECFILE) as f:
+ self.make_file("with_main/__main__.py", f.read())
+
+ self.set_environ("PYTHONSAFEPATH", "1")
+ expected = self.run_command("python -m with_main")
+ actual = self.run_command("coverage run -m with_main")
+ assert re.search("No module named '?with_main'?", actual)
+ assert re.search("No module named '?with_main'?", expected)
+
def test_coverage_custom_script(self) -> None:
# https://github.com/nedbat/coveragepy/issues/678
# If sys.path[0] isn't the Python default, then coverage.py won't
From 32e8d79fee5d7aa6f4a6508361e574ea4e823e9c Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Sat, 18 Nov 2023 12:40:48 -0500
Subject: [PATCH 04/14] fix: more details about PYTHONSAFEPATH. #1696
temp: fix lint errors from rebases
docs: a note to ourselves about interpreting test failures
test: one more safepath test
fix: use sys.flags instead of reading the PYTHONSAFEPATH
test: skip the test that show Windows gets PYTHONSAFEPATH wrong
---
CHANGES.rst | 9 ++++++++-
coverage/execfile.py | 3 ++-
tests/test_execfile.py | 11 -----------
tests/test_process.py | 15 +++++++++++++++
4 files changed, 25 insertions(+), 13 deletions(-)
diff --git a/CHANGES.rst b/CHANGES.rst
index 3f09c0c41..0fdbfc28e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -23,7 +23,14 @@ upgrading your version of coverage.py.
Unreleased
----------
-Nothing yet.
+- Fix: the PYTHONSAFEPATH environment variable new in Python 3.11 is properly
+ supported, closing `issue 1696`_. Thanks, `Philipp A. `_. This
+ works properly except for a detail when using the ``coverage`` command on
+ Windows. There you can use ``python -m coverage`` instead if you need exact
+ emulation.
+
+.. _issue 1696: https://github.com/nedbat/coveragepy/issues/1696
+.. _pull 1700: https://github.com/nedbat/coveragepy/pull/1700
.. start-releases
diff --git a/coverage/execfile.py b/coverage/execfile.py
index 1da00ec4b..b44c95280 100644
--- a/coverage/execfile.py
+++ b/coverage/execfile.py
@@ -17,6 +17,7 @@
from types import CodeType, ModuleType
from typing import Any
+from coverage import env
from coverage.exceptions import CoverageException, _ExceptionDuringRun, NoCode, NoSource
from coverage.files import canonical_filename, python_reported_file
from coverage.misc import isolate_module
@@ -89,7 +90,7 @@ def prepare(self) -> None:
This needs to happen before any importing, and without importing anything.
"""
path0: str | None
- if env.PYVERSION >= (3, 11) and os.environ.get('PYTHONSAFEPATH', ''):
+ if env.PYVERSION >= (3, 11) and getattr(sys.flags, "safe_path"):
# See https://docs.python.org/3/using/cmdline.html#cmdoption-P
path0 = None
elif self.as_module:
diff --git a/tests/test_execfile.py b/tests/test_execfile.py
index ab089229c..cd12dea99 100644
--- a/tests/test_execfile.py
+++ b/tests/test_execfile.py
@@ -14,7 +14,6 @@
import re
import sys
-from pathlib import Path
from typing import Any
from collections.abc import Iterator
@@ -25,7 +24,6 @@
from coverage.files import python_reported_file
from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin
-from tests.helpers import change_dir
TRY_EXECFILE = os.path.join(TESTS_DIR, "modules/process_test/try_execfile.py")
@@ -309,15 +307,6 @@ def test_pkg1_init(self) -> None:
assert out == "pkg1.__init__: pkg1\npkg1.__init__: __main__\n"
assert err == ""
- def test_pythonpath(self, tmp_path: Path) -> None:
- self.set_environ("PYTHONSAFEPATH", "1")
- with change_dir(tmp_path):
- run_python_module(["process_test.try_execfile"])
- out, err = self.stdouterr()
- mod_globs = json.loads(out)
- assert tmp_path not in mod_globs["path"]
- assert err == ""
-
def test_no_such_module(self) -> None:
with pytest.raises(NoSource, match="No module named '?i_dont_exist'?"):
run_python_module(["i_dont_exist"])
diff --git a/tests/test_process.py b/tests/test_process.py
index 6834fbbba..2466de081 100644
--- a/tests/test_process.py
+++ b/tests/test_process.py
@@ -666,6 +666,7 @@ def assert_tryexecfile_output(self, expected: str, actual: str) -> None:
"""
# First, is this even credible try_execfile.py output?
assert '"DATA": "xyzzy"' in actual
+ # If this fails, "+" is actual, and "-" is expected
assert actual == expected
def test_coverage_run_is_like_python(self) -> None:
@@ -807,6 +808,11 @@ def test_coverage_zip_is_like_python(self) -> None:
actual = self.run_command(f"python {cov_main} run run_me.py")
self.assert_tryexecfile_output(expected, actual)
+ @pytest.mark.skipif(env.PYVERSION < (3, 11), reason="PYTHONSAFEPATH is new in 3.11")
+ @pytest.mark.skipif(
+ env.WINDOWS,
+ reason="Windows gets this wrong: https://github.com/python/cpython/issues/131484",
+ )
def test_pythonsafepath(self) -> None:
with open(TRY_EXECFILE) as f:
self.make_file("run_me.py", f.read())
@@ -815,6 +821,15 @@ def test_pythonsafepath(self) -> None:
actual = self.run_command("coverage run run_me.py")
self.assert_tryexecfile_output(expected, actual)
+ @pytest.mark.skipif(env.PYVERSION < (3, 11), reason="PYTHONSAFEPATH is new in 3.11")
+ def test_pythonsafepath_dashm_runme(self) -> None:
+ with open(TRY_EXECFILE) as f:
+ self.make_file("run_me.py", f.read())
+ self.set_environ("PYTHONSAFEPATH", "1")
+ expected = self.run_command("python run_me.py")
+ actual = self.run_command("python -m coverage run run_me.py")
+ self.assert_tryexecfile_output(expected, actual)
+
@pytest.mark.skipif(env.PYVERSION < (3, 11), reason="PYTHONSAFEPATH is new in 3.11")
def test_pythonsafepath_dashm(self) -> None:
with open(TRY_EXECFILE) as f:
From d64ce5f95473ec2c24485bb0261c536f55d0cb4a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Mar 2025 10:32:33 -0400
Subject: [PATCH 05/14] chore: bump the action-dependencies group with 3
updates (#1940)
Bumps the action-dependencies group with 3 updates: [github/codeql-action](https://github.com/github/codeql-action), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).
Updates `github/codeql-action` from 3.28.11 to 3.28.12
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6bb031afdd8eb862ea3fc1848194185e076637e5...5f8171a638ada777af81d42b55959a643bb29017)
Updates `actions/upload-artifact` from 4.6.1 to 4.6.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1...ea165f8d65b6e75b540449e92b4886f43607fa02)
Updates `actions/download-artifact` from 4.1.9 to 4.2.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/cc203385981b70ca67e1cc392babf9cc229d5806...95815c38cf2ff2164869cbab79da8d1f422bc89e)
---
updated-dependencies:
- dependency-name: github/codeql-action
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: action-dependencies
- dependency-name: actions/upload-artifact
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: action-dependencies
- dependency-name: actions/download-artifact
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: action-dependencies
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/codeql-analysis.yml | 6 +++---
.github/workflows/coverage.yml | 8 ++++----
.github/workflows/kit.yml | 10 +++++-----
.github/workflows/publish.yml | 4 ++--
4 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 5eddd8137..25330d632 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@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3
+ uses: github/codeql-action/init@5f8171a638ada777af81d42b55959a643bb29017 # 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@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3
+ uses: github/codeql-action/autobuild@5f8171a638ada777af81d42b55959a643bb29017 # 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@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3
+ uses: github/codeql-action/analyze@5f8171a638ada777af81d42b55959a643bb29017 # v3
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index d94227f04..9a3cc59a9 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@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
pattern: metacov-*
merge-multiple: true
@@ -184,7 +184,7 @@ jobs:
python igor.py combine_html
- name: "Upload HTML report"
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: html_report
path: reports_repo/${{ env.report_dir }}
diff --git a/.github/workflows/kit.yml b/.github/workflows/kit.yml
index 421ea5af0..f5f45ef92 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@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dist-non-binary
path: dist/*
@@ -267,7 +267,7 @@ jobs:
python -m twine check dist/*
- name: "Upload wheels"
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dist-pypy
path: dist/*.whl
@@ -286,7 +286,7 @@ jobs:
id-token: write
steps:
- name: "Download artifacts"
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
pattern: dist-*
merge-multiple: true
@@ -308,7 +308,7 @@ jobs:
ls -alR
- name: "Upload signatures"
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: signatures
path: "*.sigstore.json"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index e6098e027..4e6032605 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -64,7 +64,7 @@ jobs:
steps:
- name: "Download dists"
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
repository: "nedbat/coveragepy"
run-id: ${{ needs.find-run.outputs.run-id }}
@@ -104,7 +104,7 @@ jobs:
steps:
- name: "Download dists"
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
repository: "nedbat/coveragepy"
run-id: ${{ needs.find-run.outputs.run-id }}
From a66bd61be0a01874dacf4238c1de5ef67ef325fe Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Sun, 23 Mar 2025 11:19:56 -0400
Subject: [PATCH 06/14] refactor: move bytecode code into bytecode.py
---
coverage/bytecode.py | 142 ++++++++++++++++++++++++++++++++++++++++-
coverage/sysmon.py | 149 ++-----------------------------------------
coverage/types.py | 3 +
tox.ini | 2 +-
4 files changed, 149 insertions(+), 147 deletions(-)
diff --git a/coverage/bytecode.py b/coverage/bytecode.py
index 764b29b80..944abc15c 100644
--- a/coverage/bytecode.py
+++ b/coverage/bytecode.py
@@ -1,13 +1,18 @@
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
-"""Bytecode manipulation for coverage.py"""
+"""Bytecode analysis for coverage.py"""
from __future__ import annotations
+import dis
+
from types import CodeType
+from typing import Iterable, Optional
from collections.abc import Iterator
+from coverage.types import TArc, TOffset
+
def code_objects(code: CodeType) -> Iterator[CodeType]:
"""Iterate over all the code objects in `code`."""
@@ -20,3 +25,138 @@ def code_objects(code: CodeType) -> Iterator[CodeType]:
if isinstance(c, CodeType):
stack.append(c)
yield code
+
+
+def op_set(*op_names: str) -> set[int]:
+ """Make a set of opcodes from instruction names.
+
+ The names might not exist in this version of Python, skip those if not.
+ """
+ return {op for name in op_names if (op := dis.opmap.get(name))}
+
+
+# Opcodes that are unconditional jumps elsewhere.
+ALWAYS_JUMPS = op_set(
+ "JUMP_BACKWARD",
+ "JUMP_BACKWARD_NO_INTERRUPT",
+ "JUMP_FORWARD",
+)
+
+# Opcodes that exit from a function.
+RETURNS = op_set("RETURN_VALUE", "RETURN_GENERATOR")
+
+
+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
+
+
+TBranchTrail = tuple[list[TOffset], Optional[TArc]]
+TBranchTrails = dict[TOffset, list[TBranchTrail]]
+
+
+def branch_trails(code: CodeType) -> TBranchTrails:
+ """
+ Calculate branch trails for `code`.
+
+ 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.
+
+ """
+ the_trails: TBranchTrails = {}
+ iwalker = InstructionWalker(code)
+ for inst in iwalker.walk(follow_jumps=False):
+ if not inst.jump_target:
+ # We only care about instructions with jump targets.
+ continue
+ if inst.opcode in ALWAYS_JUMPS:
+ # We don't care about unconditional jumps.
+ continue
+
+ from_line = inst.line_number
+ if from_line is None:
+ continue
+
+ def walk_one_branch(start_at: TOffset) -> TBranchTrail:
+ # 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):
+ break
+ elif inst2.opcode in RETURNS:
+ to_line = -code.co_firstlineno
+ break
+ if to_line is not None:
+ return inst_offsets, (from_line, to_line)
+ else:
+ 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),
+ walk_one_branch(start_at=inst.jump_target),
+ ]
+ the_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 the_trails:
+ the_trails[offset] = []
+ the_trails[offset].append(trail)
+
+ return the_trails
diff --git a/coverage/sysmon.py b/coverage/sysmon.py
index e7b5659a6..8e5376cf0 100644
--- a/coverage/sysmon.py
+++ b/coverage/sysmon.py
@@ -5,7 +5,6 @@
from __future__ import annotations
-import dis
import functools
import inspect
import os
@@ -19,20 +18,20 @@
from typing import (
Any,
Callable,
- Iterable,
NewType,
Optional,
cast,
)
from coverage import env
+from coverage.bytecode import TBranchTrails, branch_trails
from coverage.debug import short_filename, short_stack
from coverage.misc import isolate_module
from coverage.types import (
AnyCallable,
- TArc,
TFileDisposition,
TLineNo,
+ TOffset,
TShouldStartContextFn,
TShouldTraceFn,
TTraceData,
@@ -58,18 +57,6 @@
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
@@ -181,131 +168,6 @@ 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."""
@@ -321,10 +183,7 @@ class CodeInfo:
# ([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]],
- ]
+ branch_trails: TBranchTrails
def bytes_to_lines(code: CodeType) -> dict[TOffset, TLineNo]:
@@ -571,7 +430,7 @@ def sysmon_branch_either(
if not code_info.branch_trails:
if self.stats is not None:
self.stats["branch_trails"] += 1
- populate_branch_trails(code, code_info)
+ code_info.branch_trails = branch_trails(code)
# log(f"branch_trails for {code}:\n {code_info.branch_trails}")
added_arc = False
dest_info = code_info.branch_trails.get(instruction_offset)
diff --git a/coverage/types.py b/coverage/types.py
index ac1fc4c59..8b919a89b 100644
--- a/coverage/types.py
+++ b/coverage/types.py
@@ -53,6 +53,9 @@ def __call__(
# Line numbers are pervasive enough that they deserve their own type.
TLineNo = int
+# Bytecode offsets are pervasive enough that they deserve their own type.
+TOffset = int
+
TArc = tuple[TLineNo, TLineNo]
class TFileDisposition(Protocol):
diff --git a/tox.ini b/tox.ini
index 9314b3322..330a967a4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -116,7 +116,7 @@ setenv =
commands =
# PYVERSIONS
- mypy --python-version=3.9 --strict --exclude=sysmon {env:TYPEABLE}
+ mypy --python-version=3.9 --strict --exclude=sysmon --exclude=bytecode {env:TYPEABLE}
mypy --python-version=3.13 --strict {env:TYPEABLE}
[gh]
From 82cff3e34836ff7248f4fb2e348c5f954e82b78e Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Sun, 23 Mar 2025 12:41:28 -0400
Subject: [PATCH 07/14] perf: sets are better than lists
---
coverage/bytecode.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/coverage/bytecode.py b/coverage/bytecode.py
index 944abc15c..bea039c87 100644
--- a/coverage/bytecode.py
+++ b/coverage/bytecode.py
@@ -88,7 +88,7 @@ def walk(
offset += 2
-TBranchTrail = tuple[list[TOffset], Optional[TArc]]
+TBranchTrail = tuple[set[TOffset], Optional[TArc]]
TBranchTrails = dict[TOffset, list[TBranchTrail]]
@@ -124,10 +124,10 @@ def branch_trails(code: CodeType) -> TBranchTrails:
def walk_one_branch(start_at: TOffset) -> TBranchTrail:
# pylint: disable=cell-var-from-loop
- inst_offsets: list[TOffset] = []
+ inst_offsets: set[TOffset] = set()
to_line = None
for inst2 in iwalker.walk(start_at=start_at):
- inst_offsets.append(inst2.offset)
+ inst_offsets.add(inst2.offset)
if inst2.line_number and inst2.line_number != from_line:
to_line = inst2.line_number
break
@@ -139,7 +139,7 @@ def walk_one_branch(start_at: TOffset) -> TBranchTrail:
if to_line is not None:
return inst_offsets, (from_line, to_line)
else:
- return [], None
+ return set(), None
# Calculate two trails: one from the next instruction, and one from the
# jump_target instruction.
From a87605265039b46570ae617f06941cfdbb95cba6 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Sun, 23 Mar 2025 16:47:18 -0400
Subject: [PATCH 08/14] test: a general helper for iterating over our own
source files
---
tests/helpers.py | 14 ++++++++++++++
tests/test_regions.py | 34 ++++++++++++++++------------------
tests/test_testing.py | 11 +++++++++++
3 files changed, 41 insertions(+), 18 deletions(-)
diff --git a/tests/helpers.py b/tests/helpers.py
index 87160ed61..d1412afcd 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -26,6 +26,7 @@
import flaky
+import coverage
from coverage import env
from coverage.debug import DebugControl
from coverage.exceptions import CoverageWarning
@@ -373,3 +374,16 @@ def flaky_method(max_runs: int) -> Callable[[TestMethod], TestMethod]:
def _decorator(fn: TestMethod) -> TestMethod:
return cast(TestMethod, flaky.flaky(max_runs)(fn))
return _decorator
+
+
+def all_our_source_files() -> Iterator[tuple[Path, str]]:
+ """Iterate over all of our own source files.
+
+ Produces a stream of (filename, file contents) tuples.
+ """
+ cov_dir = Path(coverage.__file__).parent.parent
+ # To run against all the files in the tox venvs:
+ # for source_file in cov_dir.rglob("*.py"):
+ for sub in [".", "benchmark", "ci", "coverage", "lab", "tests"]:
+ for source_file in (cov_dir / sub).glob("*.py"):
+ yield (source_file, source_file.read_text(encoding="utf-8"))
diff --git a/tests/test_regions.py b/tests/test_regions.py
index b7ceacc64..67792d6c3 100644
--- a/tests/test_regions.py
+++ b/tests/test_regions.py
@@ -11,10 +11,11 @@
import pytest
-import coverage
from coverage.plugin import CodeRegion
from coverage.regions import code_regions
+from tests.helpers import all_our_source_files
+
def test_code_regions() -> None:
regions = code_regions(textwrap.dedent("""\
@@ -90,24 +91,21 @@ def test_real_code_regions() -> None:
# Run code_regions on most of the coverage source code, checking that it
# succeeds and there are no overlaps.
- cov_dir = Path(coverage.__file__).parent.parent
any_fails = False
- # To run against all the files in the tox venvs:
- # for source_file in cov_dir.rglob("*.py"):
- for sub in [".", "ci", "coverage", "lab", "tests"]:
- for source_file in (cov_dir / sub).glob("*.py"):
- regions = code_regions(source_file.read_text(encoding="utf-8"))
- for kind in ["function", "class"]:
- kind_regions = [reg for reg in regions if reg.kind == kind]
- line_counts = collections.Counter(
- lno for reg in kind_regions for lno in reg.lines
+ for source_file, source in all_our_source_files():
+ regions = code_regions(source)
+ for kind in ["function", "class"]:
+ kind_regions = [reg for reg in regions if reg.kind == kind]
+ line_counts = collections.Counter(
+ lno for reg in kind_regions for lno in reg.lines
+ )
+ overlaps = [line for line, count in line_counts.items() if count > 1]
+ if overlaps: # pragma: only failure
+ print(
+ f"{kind.title()} overlaps in {source_file.relative_to(Path.cwd())}: "
+ + f"{overlaps}"
)
- overlaps = [line for line, count in line_counts.items() if count > 1]
- if overlaps: # pragma: only failure
- print(
- f"{kind.title()} overlaps in {source_file.relative_to(Path.cwd())}: "
- + f"{overlaps}"
- )
- any_fails = True
+ any_fails = True
+
if any_fails:
pytest.fail("Overlaps were found") # pragma: only failure
diff --git a/tests/test_testing.py b/tests/test_testing.py
index c673a6410..183e66115 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -22,6 +22,7 @@
from tests.coveragetest import CoverageTest
from tests.helpers import (
CheckUniqueFilenames, FailingProxy,
+ all_our_source_files,
arcz_to_arcs, assert_count_equal, assert_coverage_warnings,
re_lines, re_lines_text, re_line,
)
@@ -450,3 +451,13 @@ def subtract(self, a, b): # type: ignore[no-untyped-def]
proxy.add(3, 4)
# then add starts working
assert proxy.add(5, 6) == 11
+
+
+def test_all_our_source_files() -> None:
+ # Twas brillig and the slithy toves
+ i = 0
+ for i, (source_file, source) in enumerate(all_our_source_files(), start=1):
+ has_toves = (source_file.name == "test_testing.py")
+ assert (("# Twas brillig " + "and the slithy toves") in source) == has_toves
+ assert len(source) > 190 # tests/__init__.py is shortest at 196
+ assert 120 < i < 200 # currently 125 files
From cf1dec0f05aaf581e9e6f7c707c7fa77ba77ade9 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Mon, 24 Mar 2025 21:15:27 -0400
Subject: [PATCH 09/14] refactor: these pypy modules are available in all our
versions
---
coverage/inorout.py | 22 +++++++---------------
1 file changed, 7 insertions(+), 15 deletions(-)
diff --git a/coverage/inorout.py b/coverage/inorout.py
index e2b4c8ca3..8814fb08d 100644
--- a/coverage/inorout.py
+++ b/coverage/inorout.py
@@ -36,26 +36,18 @@
from coverage.plugin_support import Plugins
-# Pypy has some unusual stuff in the "stdlib". Consider those locations
-# when deciding where the stdlib is. These modules are not used for anything,
-# they are modules importable from the pypy lib directories, so that we can
-# find those directories.
modules_we_happen_to_have: list[ModuleType] = [
inspect, itertools, os, platform, re, sysconfig, traceback,
]
if env.PYPY:
- try:
- import _structseq
- modules_we_happen_to_have.append(_structseq)
- except ImportError:
- pass
-
- try:
- import _pypy_irc_topic
- modules_we_happen_to_have.append(_pypy_irc_topic)
- except ImportError:
- pass
+ # Pypy has some unusual stuff in the "stdlib". Consider those locations
+ # when deciding where the stdlib is. These modules are not used for anything,
+ # they are modules importable from the pypy lib directories, so that we can
+ # find those directories.
+ import _pypy_irc_topic # pylint: disable=import-error
+ import _structseq # pylint: disable=import-error
+ modules_we_happen_to_have.extend([_structseq, _pypy_irc_topic])
os = isolate_module(os)
From f464155a3e43b4640c2ead9fb06674f33f61858a Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Tue, 25 Mar 2025 08:05:55 -0400
Subject: [PATCH 10/14] test: some simple bytecode tests
---
tests/test_bytecode.py | 49 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 tests/test_bytecode.py
diff --git a/tests/test_bytecode.py b/tests/test_bytecode.py
new file mode 100644
index 000000000..931d4fc1d
--- /dev/null
+++ b/tests/test_bytecode.py
@@ -0,0 +1,49 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
+
+"""Tests for coverage.py's bytecode analysis."""
+
+from __future__ import annotations
+
+import dis
+
+from textwrap import dedent
+
+from tests.coveragetest import CoverageTest
+
+from coverage import env
+from coverage.bytecode import code_objects, op_set
+
+
+class BytecodeTest(CoverageTest):
+ """Tests for bytecode.py"""
+
+ def test_code_objects(self) -> None:
+ code = compile(
+ dedent("""\
+ def f(x):
+ def g(y):
+ return {z for z in range(10)}
+ def j():
+ return [z for z in range(10)]
+ return g(x)
+ def h(x):
+ return x+1
+ """),
+ "",
+ "exec"
+ )
+
+ objs = list(code_objects(code))
+ assert code in objs
+
+ expected = {"", "f", "g", "j", "h"}
+ if env.PYVERSION < (3, 12):
+ # Comprehensions were compiled as implicit functions in earlier
+ # versions of Python.
+ expected.update({"", ""})
+ assert {c.co_name for c in objs} == expected
+
+ def test_op_set(self) -> None:
+ opcodes = op_set("LOAD_CONST", "NON_EXISTENT_OPCODE", "RETURN_VALUE")
+ assert opcodes == {dis.opmap["LOAD_CONST"], dis.opmap["RETURN_VALUE"]}
From 7aea2f311eb073a74b0efb26065933f8572b1a2a Mon Sep 17 00:00:00 2001
From: Jeremy Fleischman
Date: Fri, 28 Mar 2025 13:46:21 -0500
Subject: [PATCH 11/14] feat: add new `source_dirs` option (#1943)
This completes https://github.com/nedbat/coveragepy/issues/1942#issuecomment-2759164456
---
CHANGES.rst | 7 +++++++
coverage/config.py | 2 ++
coverage/control.py | 8 ++++++++
coverage/inorout.py | 31 +++++++++++++++++++++----------
doc/config.rst | 12 ++++++++++++
tests/test_api.py | 18 +++++++++++++++++-
tests/test_config.py | 2 ++
7 files changed, 69 insertions(+), 11 deletions(-)
diff --git a/CHANGES.rst b/CHANGES.rst
index 0fdbfc28e..01b6b1434 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -32,6 +32,13 @@ Unreleased
.. _issue 1696: https://github.com/nedbat/coveragepy/issues/1696
.. _pull 1700: https://github.com/nedbat/coveragepy/pull/1700
+- Added a new ``source_dirs`` setting for symmetry with the existing
+ ``source_pkgs`` setting. It's preferable to the existing ``source`` setting,
+ because you'll get a clear error when directories don't exist. Fixes `issue
+ 1942`_.
+
+.. _issue 1942: https://github.com/nedbat/coveragepy/issues/1942
+
.. start-releases
diff --git a/coverage/config.py b/coverage/config.py
index 75f314816..94831e070 100644
--- a/coverage/config.py
+++ b/coverage/config.py
@@ -211,6 +211,7 @@ def __init__(self) -> None:
self.sigterm = False
self.source: list[str] | None = None
self.source_pkgs: list[str] = []
+ self.source_dirs: list[str] = []
self.timid = False
self._crash: str | None = None
@@ -392,6 +393,7 @@ def copy(self) -> CoverageConfig:
("sigterm", "run:sigterm", "boolean"),
("source", "run:source", "list"),
("source_pkgs", "run:source_pkgs", "list"),
+ ("source_dirs", "run:source_dirs", "list"),
("timid", "run:timid", "boolean"),
("_crash", "run:_crash"),
diff --git a/coverage/control.py b/coverage/control.py
index d79c97ace..3547996ab 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -131,6 +131,7 @@ def __init__( # pylint: disable=too-many-arguments
config_file: FilePath | bool = True,
source: Iterable[str] | None = None,
source_pkgs: Iterable[str] | None = None,
+ source_dirs: Iterable[str] | None = None,
omit: str | Iterable[str] | None = None,
include: str | Iterable[str] | None = None,
debug: Iterable[str] | None = None,
@@ -188,6 +189,10 @@ def __init__( # pylint: disable=too-many-arguments
`source`, but can be used to name packages where the name can also be
interpreted as a file path.
+ `source_dirs` is a list of file paths. It works the same as
+ `source`, but raises an error if the path doesn't exist, rather
+ than being treated as a package name.
+
`include` and `omit` are lists of file name patterns. Files that match
`include` will be measured, files that match `omit` will not. Each
will also accept a single string argument.
@@ -235,6 +240,8 @@ def __init__( # pylint: disable=too-many-arguments
.. versionadded:: 7.7
The `plugins` parameter.
+ .. versionadded:: ???
+ The `source_dirs` parameter.
"""
# Start self.config as a usable default configuration. It will soon be
# replaced with the real configuration.
@@ -302,6 +309,7 @@ def __init__( # pylint: disable=too-many-arguments
parallel=bool_or_none(data_suffix),
source=source,
source_pkgs=source_pkgs,
+ source_dirs=source_dirs,
run_omit=omit,
run_include=include,
debug=debug,
diff --git a/coverage/inorout.py b/coverage/inorout.py
index 8814fb08d..2a10e0d37 100644
--- a/coverage/inorout.py
+++ b/coverage/inorout.py
@@ -24,7 +24,7 @@
from coverage import env
from coverage.disposition import FileDisposition, disposition_init
-from coverage.exceptions import CoverageException, PluginError
+from coverage.exceptions import ConfigError, CoverageException, PluginError
from coverage.files import TreeMatcher, GlobMatcher, ModuleMatcher
from coverage.files import prep_patterns, find_python_files, canonical_filename
from coverage.misc import isolate_module, sys_modules_saved
@@ -183,14 +183,25 @@ def __init__(
self.debug = debug
self.include_namespace_packages = include_namespace_packages
- self.source: list[str] = []
self.source_pkgs: list[str] = []
self.source_pkgs.extend(config.source_pkgs)
+ self.source_dirs: list[str] = []
+ self.source_dirs.extend(config.source_dirs)
for src in config.source or []:
if os.path.isdir(src):
- self.source.append(canonical_filename(src))
+ self.source_dirs.append(src)
else:
self.source_pkgs.append(src)
+
+ # Canonicalize everything in `source_dirs`.
+ # Also confirm that they actually are directories.
+ for i, src in enumerate(self.source_dirs):
+ self.source_dirs[i] = canonical_filename(src)
+
+ if not os.path.isdir(src):
+ raise ConfigError(f"Source dir doesn't exist, or is not a directory: {src}")
+
+
self.source_pkgs_unmatched = self.source_pkgs[:]
self.include = prep_patterns(config.run_include)
@@ -225,10 +236,10 @@ def _debug(msg: str) -> None:
self.pylib_match = None
self.include_match = self.omit_match = None
- if self.source or self.source_pkgs:
+ if self.source_dirs or self.source_pkgs:
against = []
- if self.source:
- self.source_match = TreeMatcher(self.source, "source")
+ if self.source_dirs:
+ self.source_match = TreeMatcher(self.source_dirs, "source")
against.append(f"trees {self.source_match!r}")
if self.source_pkgs:
self.source_pkgs_match = ModuleMatcher(self.source_pkgs, "source_pkgs")
@@ -277,7 +288,7 @@ def _debug(msg: str) -> None:
)
self.source_in_third_paths.add(pathdir)
- for src in self.source:
+ for src in self.source_dirs:
if self.third_match.match(src):
_debug(f"Source in third-party: source directory {src!r}")
self.source_in_third_paths.add(src)
@@ -449,12 +460,12 @@ def check_include_omit_etc(self, filename: str, frame: FrameType | None) -> str
def warn_conflicting_settings(self) -> None:
"""Warn if there are settings that conflict."""
if self.include:
- if self.source or self.source_pkgs:
+ if self.source_dirs or self.source_pkgs:
self.warn("--include is ignored because --source is set", slug="include-ignored")
def warn_already_imported_files(self) -> None:
"""Warn if files have already been imported that we will be measuring."""
- if self.include or self.source or self.source_pkgs:
+ if self.include or self.source_dirs or self.source_pkgs:
warned = set()
for mod in list(sys.modules.values()):
filename = getattr(mod, "__file__", None)
@@ -527,7 +538,7 @@ def find_possibly_unexecuted_files(self) -> Iterable[tuple[str, str | None]]:
pkg_file = source_for_file(cast(str, sys.modules[pkg].__file__))
yield from self._find_executable_files(canonical_path(pkg_file))
- for src in self.source:
+ for src in self.source_dirs:
yield from self._find_executable_files(src)
def _find_plugin_files(self, src_dir: str) -> Iterable[tuple[str, str]]:
diff --git a/doc/config.rst b/doc/config.rst
index 87cbdd108..f62305dd1 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -476,6 +476,18 @@ ambiguities between packages and directories.
.. versionadded:: 5.3
+.. _config_run_source_dirs:
+
+[run] source_dirs
+.................
+
+(multi-string) A list of directories, the source to measure during execution.
+Operates the same as ``source``, but only names directories, for resolving
+ambiguities between packages and directories.
+
+.. versionadded:: ???
+
+
.. _config_run_timid:
[run] timid
diff --git a/tests/test_api.py b/tests/test_api.py
index d85b89764..e4a042c13 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -23,7 +23,7 @@
import coverage
from coverage import Coverage, env
from coverage.data import line_counts, sorted_lines
-from coverage.exceptions import CoverageException, DataError, NoDataError, NoSource
+from coverage.exceptions import ConfigError, CoverageException, DataError, NoDataError, NoSource
from coverage.files import abs_file, relative_filename
from coverage.misc import import_local_file
from coverage.types import FilePathClasses, FilePathType, TCovKwargs
@@ -963,6 +963,22 @@ def test_ambiguous_source_package_as_package(self) -> None:
# Because source= was specified, we do search for un-executed files.
assert lines['p1c'] == 0
+ def test_source_dirs(self) -> None:
+ os.chdir("tests_dir_modules")
+ assert os.path.isdir("pkg1")
+ lines = self.coverage_usepkgs_counts(source_dirs=["pkg1"])
+ self.filenames_in(list(lines), "p1a p1b")
+ self.filenames_not_in(list(lines), "p2a p2b othera otherb osa osb")
+ # Because source_dirs= was specified, we do search for un-executed files.
+ assert lines['p1c'] == 0
+
+ def test_non_existent_source_dir(self) -> None:
+ with pytest.raises(
+ ConfigError,
+ match=re.escape("Source dir doesn't exist, or is not a directory: i-do-not-exist"),
+ ):
+ self.coverage_usepkgs_counts(source_dirs=["i-do-not-exist"])
+
class ReportIncludeOmitTest(IncludeOmitTestsMixin, CoverageTest):
"""Tests of the report include/omit functionality."""
diff --git a/tests/test_config.py b/tests/test_config.py
index e0a975652..190a27b1c 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -510,6 +510,7 @@ class ConfigFileTest(UsingModulesMixin, CoverageTest):
omit = twenty
source = myapp
source_pkgs = ned
+ source_dirs = cooldir
plugins =
plugins.a_plugin
plugins.another
@@ -604,6 +605,7 @@ def assert_config_settings_are_correct(self, cov: Coverage) -> None:
assert cov.config.concurrency == ["thread"]
assert cov.config.source == ["myapp"]
assert cov.config.source_pkgs == ["ned"]
+ assert cov.config.source_dirs == ["cooldir"]
assert cov.config.disable_warnings == ["abcd", "efgh"]
assert cov.get_exclude_list() == ["if 0:", r"pragma:?\s+no cover", "another_tab"]
From 38782cb5e481e24e139bd6cf08ec06e0438be4cd Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Fri, 28 Mar 2025 15:03:27 -0400
Subject: [PATCH 12/14] docs: finish up source_dirs. bump to 7.8.0
---
CHANGES.rst | 13 ++++++-------
CONTRIBUTORS.txt | 1 +
coverage/control.py | 2 +-
coverage/inorout.py | 6 ++----
coverage/version.py | 2 +-
doc/config.rst | 2 +-
tests/test_api.py | 2 +-
7 files changed, 13 insertions(+), 15 deletions(-)
diff --git a/CHANGES.rst b/CHANGES.rst
index 01b6b1434..2ab3d59e5 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -23,6 +23,11 @@ upgrading your version of coverage.py.
Unreleased
----------
+- Added a new ``source_dirs`` setting for symmetry with the existing
+ ``source_pkgs`` setting. It's preferable to the existing ``source`` setting,
+ because you'll get a clear error when directories don't exist. Fixes `issue
+ 1942`_. Thanks, `Jeremy Fleischman `_.
+
- Fix: the PYTHONSAFEPATH environment variable new in Python 3.11 is properly
supported, closing `issue 1696`_. Thanks, `Philipp A. `_. This
works properly except for a detail when using the ``coverage`` command on
@@ -31,14 +36,8 @@ Unreleased
.. _issue 1696: https://github.com/nedbat/coveragepy/issues/1696
.. _pull 1700: https://github.com/nedbat/coveragepy/pull/1700
-
-- Added a new ``source_dirs`` setting for symmetry with the existing
- ``source_pkgs`` setting. It's preferable to the existing ``source`` setting,
- because you'll get a clear error when directories don't exist. Fixes `issue
- 1942`_.
-
.. _issue 1942: https://github.com/nedbat/coveragepy/issues/1942
-
+.. _pull 1943: https://github.com/nedbat/coveragepy/pull/1943
.. start-releases
diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt
index 186608d1b..12fc1dab5 100644
--- a/CONTRIBUTORS.txt
+++ b/CONTRIBUTORS.txt
@@ -110,6 +110,7 @@ James Valleroy
Jan Kühle
Jan Rusak
Janakarajan Natarajan
+Jeremy Fleischman
Jerin Peter George
Jessamyn Smith
Joanna Ejzel
diff --git a/coverage/control.py b/coverage/control.py
index 3547996ab..16c99f7f0 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -240,7 +240,7 @@ def __init__( # pylint: disable=too-many-arguments
.. versionadded:: 7.7
The `plugins` parameter.
- .. versionadded:: ???
+ .. versionadded:: 7.8
The `source_dirs` parameter.
"""
# Start self.config as a usable default configuration. It will soon be
diff --git a/coverage/inorout.py b/coverage/inorout.py
index 2a10e0d37..8a5a1e27d 100644
--- a/coverage/inorout.py
+++ b/coverage/inorout.py
@@ -196,11 +196,9 @@ def __init__(
# Canonicalize everything in `source_dirs`.
# Also confirm that they actually are directories.
for i, src in enumerate(self.source_dirs):
- self.source_dirs[i] = canonical_filename(src)
-
if not os.path.isdir(src):
- raise ConfigError(f"Source dir doesn't exist, or is not a directory: {src}")
-
+ raise ConfigError(f"Source dir is not a directory: {src!r}")
+ self.source_dirs[i] = canonical_filename(src)
self.source_pkgs_unmatched = self.source_pkgs[:]
diff --git a/coverage/version.py b/coverage/version.py
index 2e944bc97..80369a9e0 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, 7, 2, "alpha", 0)
+version_info = (7, 8, 0, "alpha", 0)
_dev = 1
diff --git a/doc/config.rst b/doc/config.rst
index f62305dd1..7a02d6a04 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -485,7 +485,7 @@ ambiguities between packages and directories.
Operates the same as ``source``, but only names directories, for resolving
ambiguities between packages and directories.
-.. versionadded:: ???
+.. versionadded:: 7.8
.. _config_run_timid:
diff --git a/tests/test_api.py b/tests/test_api.py
index e4a042c13..1485a859b 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -975,7 +975,7 @@ def test_source_dirs(self) -> None:
def test_non_existent_source_dir(self) -> None:
with pytest.raises(
ConfigError,
- match=re.escape("Source dir doesn't exist, or is not a directory: i-do-not-exist"),
+ match=re.escape("Source dir is not a directory: 'i-do-not-exist'"),
):
self.coverage_usepkgs_counts(source_dirs=["i-do-not-exist"])
From 49c194fbb225039f3c2c029faecbc187aba37a9c Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Sun, 30 Mar 2025 15:44:24 -0400
Subject: [PATCH 13/14] docs: prep for 7.8.0
---
CHANGES.rst | 10 ++++++----
README.rst | 1 +
coverage/version.py | 4 ++--
doc/conf.py | 6 +++---
4 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/CHANGES.rst b/CHANGES.rst
index 2ab3d59e5..a2b172dc6 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -20,8 +20,12 @@ upgrading your version of coverage.py.
.. Version 9.8.1 — 2027-07-27
.. --------------------------
-Unreleased
-----------
+.. start-releases
+
+.. _changes_7-8-0:
+
+Version 7.8.0 — 2025-03-30
+--------------------------
- Added a new ``source_dirs`` setting for symmetry with the existing
``source_pkgs`` setting. It's preferable to the existing ``source`` setting,
@@ -39,8 +43,6 @@ Unreleased
.. _issue 1942: https://github.com/nedbat/coveragepy/issues/1942
.. _pull 1943: https://github.com/nedbat/coveragepy/pull/1943
-.. start-releases
-
.. _changes_7-7-1:
Version 7.7.1 — 2025-03-21
diff --git a/README.rst b/README.rst
index cb5f41b2d..cf1e856f5 100644
--- a/README.rst
+++ b/README.rst
@@ -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:**
+``[run] source_dirs`` setting;
``Coverage.branch_stats()``;
multi-line exclusion patterns;
function/class reporting;
diff --git a/coverage/version.py b/coverage/version.py
index 80369a9e0..fe08b5f98 100644
--- a/coverage/version.py
+++ b/coverage/version.py
@@ -8,8 +8,8 @@
# version_info: same semantics as sys.version_info.
# _dev: the .devN suffix if any.
-version_info = (7, 8, 0, "alpha", 0)
-_dev = 1
+version_info = (7, 8, 0, "final", 0)
+_dev = 0
def _make_version(
diff --git a/doc/conf.py b/doc/conf.py
index 80fc2cca8..57a1ffd00 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.7.1"
+version = "7.8.0"
# The full version, including alpha/beta/rc tags.
-release = "7.7.1"
+release = "7.8.0"
# The date of release, in "monthname day, year" format.
-release_date = "March 21, 2025"
+release_date = "March 30, 2025"
# @@@ end
rst_epilog = f"""
From 6d5ced933f116d6ced5497ffbe7616db05b63e12 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Sun, 30 Mar 2025 15:44:45 -0400
Subject: [PATCH 14/14] docs: sample HTML for 7.8.0
---
doc/sample_html/class_index.html | 8 ++++----
doc/sample_html/function_index.html | 8 ++++----
doc/sample_html/index.html | 8 ++++----
doc/sample_html/status.json | 2 +-
doc/sample_html/z_7b071bdc2a35fa80___init___py.html | 8 ++++----
doc/sample_html/z_7b071bdc2a35fa80___main___py.html | 8 ++++----
doc/sample_html/z_7b071bdc2a35fa80_cogapp_py.html | 8 ++++----
doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html | 8 ++++----
doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html | 8 ++++----
doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html | 8 ++++----
.../z_7b071bdc2a35fa80_test_whiteutils_py.html | 8 ++++----
doc/sample_html/z_7b071bdc2a35fa80_utils_py.html | 8 ++++----
doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html | 8 ++++----
13 files changed, 49 insertions(+), 49 deletions(-)
diff --git a/doc/sample_html/class_index.html b/doc/sample_html/class_index.html
index 796683503..c4f4afb5e 100644
--- a/doc/sample_html/class_index.html
+++ b/doc/sample_html/class_index.html
@@ -56,8 +56,8 @@
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -537,8 +537,8 @@
@@ -97,8 +97,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80___main___py.html b/doc/sample_html/z_7b071bdc2a35fa80___main___py.html
index db4997ab1..156a8b29c 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.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -97,8 +97,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_cogapp_py.html b/doc/sample_html/z_7b071bdc2a35fa80_cogapp_py.html
index 7b247890d..4e1ebbd83 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.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -928,8 +928,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html b/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html
index 40f9b68ae..43a29b1e3 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.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -127,8 +127,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html
index f305b47b0..45d3d3ccb 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.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -2737,8 +2737,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html
index 2a76d60e6..43d9b519d 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.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -205,8 +205,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html
index f87c79fd4..55500d4a5 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.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -186,8 +186,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html
index 43a69f5f3..ef5f617af 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.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
@@ -159,8 +159,8 @@
^ index
» next
- coverage.py v7.7.1,
- created at 2025-03-21 12:53 -0400
+ coverage.py v7.8.0,
+ created at 2025-03-30 15:44 -0400
diff --git a/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html
index dfba79b99..c8b77d090 100644
--- a/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html
+++ b/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html
@@ -66,8 +66,8 @@