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

Classes

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

- 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