diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 764f927e2..f256e499a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,9 @@ updates: # Check for updates to GitHub Actions once a week interval: "weekly" day: "sunday" + cooldown: + # https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns + default-days: 10 groups: action-dependencies: patterns: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9fcae10fc..cca43efe8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -55,7 +55,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -66,7 +66,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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -80,4 +80,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 720f86324..b2fc8e5c7 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -63,7 +63,7 @@ jobs: coverage: name: "${{ matrix.python-version }} on ${{ matrix.os }}" runs-on: "${{ matrix.os }}-${{ matrix.os-version || 'latest' }}" - timeout-minutes: 30 + timeout-minutes: 10 # Only run coverage if Python files or this workflow changed. needs: changed @@ -94,10 +94,13 @@ jobs: - "3.15" - "3.15t" - "pypy-3.10" + - "pypy-3.11" exclude: # Mac PyPy always takes the longest, and doesn't add anything. - os: macos python-version: "pypy-3.10" + - os: macos + python-version: "pypy-3.11" # Windows pypy 3.10 gets stuck with PyPy 7.3.15. I hope to # unstick them, but I don't want that to block all other progress, so # skip them for now. @@ -120,7 +123,7 @@ jobs: persist-credentials: false - name: "Set up Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "${{ matrix.python-version }}" allow-prereleases: true @@ -181,7 +184,7 @@ jobs: persist-credentials: false - name: "Set up Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.10" # Minimum of PYVERSIONS # At a certain point, installing dependencies failed on pypy 3.9 and @@ -259,7 +262,7 @@ jobs: echo "sha10=$SHA10" >> $GITHUB_ENV echo "slug=$SLUG" >> $GITHUB_ENV echo "report_dir=reports/$SLUG/htmlcov" >> $GITHUB_ENV - echo "url=https://htmlpreview.github.io/?https://github.com/coveragepy/metacov-reports/blob/main/reports/$SLUG/htmlcov/index.html" >> $GITHUB_ENV + echo "url=https://coveragepy.github.io/metacov-reports/reports/$SLUG/htmlcov" >> $GITHUB_ENV echo "branch=${REF#refs/heads/}" >> $GITHUB_ENV - name: "Checkout reports repo" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6bf969a74..8d6f8bcee 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: 'Dependency Review' - uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 with: base-ref: ${{ github.event.pull_request.base.sha || 'main' }} head-ref: ${{ github.event.pull_request.head.sha || github.ref }} diff --git a/.github/workflows/kit.yml b/.github/workflows/kit.yml index 03075d282..8a6ff5a17 100644 --- a/.github/workflows/kit.yml +++ b/.github/workflows/kit.yml @@ -171,7 +171,7 @@ jobs: persist-credentials: false - name: "Install Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "${{ matrix.minpy || '3.11' }}" # PYVERSIONS needed by cibuildwheel cache: pip @@ -232,7 +232,7 @@ jobs: persist-credentials: false - name: "Install Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" # PYVERSIONS: the kit-building version cache: pip diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 4b0231eff..4ee344be5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -76,7 +76,7 @@ jobs: persist-credentials: false - name: "Install Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.10" # Minimum of PYVERSIONS cache: pip @@ -104,7 +104,7 @@ jobs: persist-credentials: false - name: "Install Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.10" # Minimum of PYVERSIONS cache: pip @@ -137,7 +137,7 @@ jobs: git fetch origin main --depth=1 - name: "Install Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" # Doc version from PYVERSIONS cache: pip @@ -175,7 +175,7 @@ jobs: persist-credentials: false - name: Install the latest version of uv - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 #v7.1.2 + uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 #v7.1.4 with: enable-cache: false diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml index 9132058b7..7f9d63483 100644 --- a/.github/workflows/testsuite.yml +++ b/.github/workflows/testsuite.yml @@ -60,7 +60,7 @@ jobs: tests: name: "${{ matrix.python-version }} on ${{ matrix.os }}" runs-on: "${{ matrix.os }}-${{ matrix.os-version || 'latest' }}" - timeout-minutes: 30 + timeout-minutes: 10 # Don't run tests if the branch name includes "-notests". # Only run tests if files that affect tests have changed. @@ -116,7 +116,7 @@ jobs: persist-credentials: false - name: "Set up Python" - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "${{ matrix.python-version }}" allow-prereleases: true diff --git a/.gitignore b/.gitignore index e38a5b0a4..4afbc6efc 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ doc/sample_html_beta # Build intermediaries. tmp +a1_coverage.pth # OS junk .DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a5843aab..0be9cb9f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: exclude: "stress_phystoken|\\.py,cover$" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.4 + rev: v0.14.5 hooks: - id: ruff-format diff --git a/CHANGES.rst b/CHANGES.rst index d4ed013f7..8ddc09e81 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,6 +22,80 @@ upgrading your version of coverage.py. .. start-releases +.. _changes_7-13-0: + +Version 7.13.0 — 2025-12-08 +--------------------------- + +- Feature: coverage.py now supports :file:`.coveragerc.toml` configuration + files. These files use TOML syntax and take priority over + :file:`pyproject.toml` but lower priority than :file:`.coveragerc` files. + Closes `issue 1643`_ thanks to `Olena Yefymenko `_. + +- Fix: we now include a permanent .pth file which is installed with the code, + fixing `issue 2084`_. In 7.12.1b1 this was done incorrectly: it didn't work + when using the source wheel (``py3-none-any``). This is now fixed. Thanks, + `Henry Schreiner `_. + +- Deprecated: when coverage.py is installed, it creates three command entry + points: ``coverage``, ``coverage3``, and ``coverage-3.10`` (if installed for + Python 3.10). The second and third of these are not needed and will + eventually be removed. They still work for now, but print a message about + their deprecation. + +.. _issue 1643: https://github.com/coveragepy/coveragepy/issues/1643 +.. _pull 1952: https://github.com/coveragepy/coveragepy/pull/1952 +.. _pull 2100: https://github.com/coveragepy/coveragepy/pull/2100 + + +.. _changes_7-12-1b1: + +Version 7.12.1b1 — 2025-11-30 +----------------------------- + +- Fix: coverage.py now includes a permanent .pth file in the distribution which + is installed with the code. This fixes `issue 2084`_: failure to patch for + subprocess measurement when site-packages is not writable. + +.. _issue 2084: https://github.com/coveragepy/coveragepy/issues/2084 + + +.. _changes_7-12-0: + +Version 7.12.0 — 2025-11-18 +--------------------------- + +- The HTML report now shows separate coverage totals for statements and + branches, as well as the usual combined coverage percentage. Thanks to Ryuta + Otsuka for the `discussion `_ and the `implementation + `_. + +- The JSON report now includes separate coverage totals for statements and + branches, thanks to `Ryuta Otsuka `_. + +- Fix: ``except*`` clauses were not handled properly under the "sysmon" + measurement core, causing KeyError exceptions as described in `issue 2086`_. + This is now fixed. + +- Fix: we now defend against aggressive mocking of ``open()`` that could cause + errors inside coverage.py. An example of a failure is in `issue 2083`_. + +- Fix: in unusual cases where a test suite intentionally exhausts the system's + file descriptors to test handling errors in ``open()``, coverage.py would + fail when trying to open source files, as described in `issue 2091`_. This + is now fixed. + +- A small tweak to the HTML report: file paths now use thin spaces around + slashes to make them easier to read. + +.. _issue 2081: https://github.com/coveragepy/coveragepy/issues/2081 +.. _issue 2083: https://github.com/coveragepy/coveragepy/issues/2083 +.. _pull 2085: https://github.com/coveragepy/coveragepy/pull/2085 +.. _issue 2086: https://github.com/coveragepy/coveragepy/issues/2086 +.. _pull 2090: https://github.com/coveragepy/coveragepy/pull/2090 +.. _issue 2091: https://github.com/coveragepy/coveragepy/issues/2091 + + .. _changes_7-11-3: Version 7.11.3 — 2025-11-09 diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 2587df5f5..5506aea2c 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -188,6 +188,7 @@ Nils Kattenbeck Noel O'Boyle Oleg Höfling Oleh Krehel +Olena Yefymenko Olivier Grisel Ori Avtalion Pablo Carballo @@ -208,6 +209,7 @@ Roland Illig Ross Lawley Roy Williams Russell Keith-Magee +Ryuta Otsuka S. Y. Lee Salvatore Zagaria Sandra Martocchia diff --git a/Makefile b/Makefile index ff049db60..6c3327973 100644 --- a/Makefile +++ b/Makefile @@ -37,11 +37,12 @@ clean: debug_clean _clean_platform ## Remove artifacts of test execution, instal @rm -f MANIFEST @rm -f .coverage .coverage.* .metacov* @rm -f coverage.xml coverage.json - @rm -f .tox/*/lib/*/site-packages/zzz_metacov.pth + @rm -f .tox/*/lib/*/site-packages/a0_metacov.pth @rm -f */.coverage */*/.coverage */*/*/.coverage */*/*/*/.coverage */*/*/*/*/.coverage */*/*/*/*/*/.coverage @rm -f tests/covmain.zip tests/zipmods.zip tests/zip1.zip @rm -rf doc/_build doc/_spell doc/sample_html_beta @rm -rf tmp + @rm -rf a1_coverage.pth @rm -rf .*cache */.*cache */*/.*cache */*/*/.*cache .hypothesis @rm -rf tests/actual @-make -C tests/gold/html clean @@ -114,7 +115,9 @@ KITBIN = .tox/$(KITVER)/bin $(KITBIN): tox -q -e $(KITVER) --notest -PIP_COMPILE = uv pip compile -q --universal ${COMPILE_OPTS} +# Limit to packages that were released more than 10 days ago. +# https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns +PIP_COMPILE = uv pip compile -q --universal --exclude-newer=$$(date -v-10d +%Y-%m-%d) ${COMPILE_OPTS} upgrade: ## Update the *.pip files with the latest packages satisfying *.in files. $(MAKE) _upgrade COMPILE_OPTS="--upgrade" @@ -189,6 +192,7 @@ sample_html_beta: _sample_cog_html ## Generate sample HTML report for a beta rel .PHONY: release_version edit_for_release cheats relbranch relcommit1 relcommit2 .PHONY: kit pypi_upload test_upload kit_local build_kits update_rtd +.PHONY: _check_github_auth download_kits .PHONY: tag bump_version REPO_OWNER = coveragepy/coveragepy @@ -213,8 +217,11 @@ relcommit2: #: Commit the latest sample HTML report (see howto.txt). git add doc/sample_html git commit -am "docs: sample HTML for $$(python setup.py --version)" -kit: ## Make the source distribution. +kit: ## Make a source distribution and some wheels. + @# Makes sdist and binary wheel for current Python version and platform: python -m build + @# Makes py3-none-any wheel: + COVERAGE_DISABLE_EXTENSION=1 python -m build --wheel pypi_upload: ## Upload the built distributions to PyPI. python ci/trigger_action.py $(REPO_OWNER) publish-pypi @@ -233,14 +240,25 @@ kit_local: # don't go crazy trying to figure out why our new code isn't installing. find ~/Library/Caches/pip/wheels -name 'coverage-*' -delete -build_kits: ## Trigger GitHub to build kits. +_check_github_auth: #: Check that we have GITHUB_TOKEN for other commands that need it. + @if [[ -z "$$GITHUB_TOKEN" ]]; then \ + echo 'Missing GITHUB_TOKEN: opvars github'; \ + exit 1; \ + fi + +build_kits: _check_github_auth ## Trigger GitHub to build all the distributions. python ci/trigger_action.py $(REPO_OWNER) build-kits +download_kits: _check_github_auth ## Download the kits built on GitHub. + @# This is only if we need to examine them for debugging. + mkdir -p dist + gh run download --dir=dist $$(gh run list --workflow=Kits --json=databaseId --jq='.[0].databaseId') + tag: #: Make a git tag with the version number (see howto.txt). git tag -s -m "Version $$(python setup.py --version)" $$(python setup.py --version) git push --follow-tags -update_rtd: #: Update ReadTheDocs with the versions to show +update_rtd: #: Update ReadTheDocs with the versions to show. python ci/update_rtfd.py $(RTD_PROJECT) bump_version: #: Edit sources to bump the version after a release (see howto.txt). diff --git a/README.rst b/README.rst index a3ebe1046..03a3207c0 100644 --- a/README.rst +++ b/README.rst @@ -35,27 +35,6 @@ Documentation is on `Read the Docs`_. Code repository and issue tracker are on .. _Read the Docs: https://coverage.readthedocs.io/ .. _GitHub: https://github.com/coveragepy/coveragepy -**New in 7.x:** -``[run] patch`` setting; -``--save-signal`` option; -``[run] core`` setting; -``[run] source_dirs`` setting; -``Coverage.branch_stats()``; -multi-line exclusion patterns; -function/class reporting; -experimental support for sys.monitoring; -dropped support for Python up to 3.9; -added ``Coverage.collect()`` context manager; -improved data combining; -``[run] exclude_also`` setting; -``report --format=``; -type annotations. - -**New in 6.x:** -dropped support for Python 2.7, 3.5, and 3.6; -write data on SIGTERM; -added support for 3.10 match/case statements. - For Enterprise -------------- diff --git a/coverage/__init__.py b/coverage/__init__.py index 1f7086a33..5441133c3 100644 --- a/coverage/__init__.py +++ b/coverage/__init__.py @@ -11,8 +11,6 @@ from __future__ import annotations -# isort: skip_file - # mypy's convention is that "import as" names are public from the module. # We import names as themselves to indicate that. Pylint sees it as pointless, # so disable its warning. diff --git a/coverage/cmdline.py b/coverage/cmdline.py index 5287327e7..c8c4e49cd 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -1161,6 +1161,20 @@ def main(argv: list[str] | None = None) -> int | None: return status +def main_deprecated(argv: list[str] | None = None) -> int | None: + """For entry points we'll be getting rid of.""" + print( + textwrap.dedent("""\ + ** + ** This entry point is deprecated and will be removed. + ** Send me an email if you want to keep this command name working: + ** ned@nedbatchelder.com + ** + """) + ) + return main(argv) + + # Profiling using ox_profile. Install it from GitHub: # pip install git+https://github.com/emin63/ox_profile.git # diff --git a/coverage/config.py b/coverage/config.py index 274d71d66..7787ab838 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -658,6 +658,7 @@ def config_files_to_try(config_file: bool | str) -> list[tuple[str, bool, bool]] assert isinstance(config_file, str) files_to_try = [ (config_file, True, specified_file), + (".coveragerc.toml", True, False), ("setup.cfg", False, False), ("tox.ini", False, False), ("pyproject.toml", False, False), diff --git a/coverage/control.py b/coverage/control.py index 81308d330..7aeccd9e5 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -12,7 +12,6 @@ import functools import os import os.path -import platform import signal import sys import threading @@ -288,9 +287,6 @@ def __init__( # pylint: disable=too-many-arguments self._no_warn_slugs: set[str] = set() self._messages = messages - # If we're invoked from a .pth file, we shouldn't try to make another one. - self._make_pth_file = True - # A record of all the warnings that have been issued. self._warnings: list[str] = [] @@ -354,6 +350,17 @@ def __init__( # pylint: disable=too-many-arguments if not env.METACOV: _prevent_sub_process_measurement() + def __repr__(self) -> str: + core_name = self._core.tracer_class.__name__ if self._core is not None else "-none-" + data_file = repr(self._data._filename) if self._data is not None else "-none-" + return ( + "" + ) + def _init(self) -> None: """Set all the initial state. @@ -708,7 +715,7 @@ def start(self) -> None: if self._auto_load: self.load() - apply_patches(self, self.config, self._debug, make_pth_file=self._make_pth_file) + apply_patches(self, self.config, self._debug) self._collector.start() self._started = True @@ -1354,6 +1361,9 @@ def lcov_report( def sys_info(self) -> Iterable[tuple[str, Any]]: """Return a list of (key, value) pairs showing internal information.""" + import glob + import platform + import site import coverage as covmod self._init() @@ -1369,6 +1379,10 @@ def plugin_info(plugins: list[Any]) -> list[str]: entries.append(entry) return entries + pth_files = [] + for spdir in site.getsitepackages(): + pth_files.extend(glob.glob(f"{spdir}/*cov*.pth")) + info = [ ("coverage_version", covmod.__version__), ("coverage_module", covmod.__file__), @@ -1391,6 +1405,7 @@ def plugin_info(plugins: list[Any]) -> list[str]: ("build", platform.python_build()), ("gil_enabled", getattr(sys, "_is_gil_enabled", lambda: True)()), ("executable", sys.executable), + ("pth_files", pth_files), ("def_encoding", sys.getdefaultencoding()), ("fs_encoding", sys.getfilesystemencoding()), ("pid", os.getpid()), @@ -1418,12 +1433,21 @@ def plugin_info(plugins: list[Any]) -> list[str]: )(Coverage) -def process_startup(*, force: bool = False) -> Coverage | None: +def process_startup( + *, + force: bool = False, + slug: str = "default", # pylint: disable=unused-argument +) -> Coverage | None: """Call this at Python start-up to perhaps measure coverage. - If the environment variable COVERAGE_PROCESS_START is defined, coverage - measurement is started. The value of the variable is the config file - to use. + Coverage is started if one of these environment variables is defined: + + - COVERAGE_PROCESS_START: the config file to use. + - COVERAGE_PROCESS_CONFIG: the config data to use, a string produced by + CoverageConfig.serialize, prefixed by ":data:". + + If one of these is defined, it's used to get the coverage configuration, + and coverage is started. For details, see https://coverage.readthedocs.io/en/latest/subprocess.html. @@ -1431,6 +1455,29 @@ def process_startup(*, force: bool = False) -> Coverage | None: not started by this call. """ + # This function can be called more than once in a process, for a few + # reasons. + # + # 1) We install a .pth file in multiple places reported by the site module, + # so this function can be called more than once even in simple + # situations. + # + # 2) In some virtualenv configurations the same directory is visible twice + # in sys.path. This means that the .pth file will be found twice and + # executed twice, executing this function twice. + # https://github.com/coveragepy/coveragepy/issues/340 has more details. + # + # We set a global flag (an attribute on this function) to indicate that + # coverage.py has already been started, so we can avoid starting it twice. + + if not force and hasattr(process_startup, "coverage"): + # We've annotated this function before, so we must have already + # auto-started coverage.py in this process. Nothing to do. + return None + + # Now check for the environment variables that request coverage. If they + # aren't set, do nothing. + config_data = os.getenv("COVERAGE_PROCESS_CONFIG") cps = os.getenv("COVERAGE_PROCESS_START") if config_data is not None: @@ -1441,27 +1488,12 @@ def process_startup(*, force: bool = False) -> Coverage | None: # No request for coverage, nothing to do. return None - # This function can be called more than once in a process. This happens - # because some virtualenv configurations make the same directory visible - # twice in sys.path. This means that the .pth file will be found twice, - # and executed twice, executing this function twice. We set a global - # flag (an attribute on this function) to indicate that coverage.py has - # already been started, so we can avoid doing it twice. - # - # https://github.com/coveragepy/coveragepy/issues/340 has more details. - - if not force and hasattr(process_startup, "coverage"): - # We've annotated this function before, so we must have already - # auto-started coverage.py in this process. Nothing to do. - return None - cov = Coverage(config_file=config_file) process_startup.coverage = cov # type: ignore[attr-defined] cov._warn_no_data = False cov._warn_unimported_source = False cov._warn_preimported_source = False cov._auto_save = True - cov._make_pth_file = False cov.start() return cov @@ -1471,7 +1503,7 @@ def _after_fork_in_child() -> None: """Used by patch=fork in the child process to restart coverage.""" if cov := Coverage.current(): cov.stop() - process_startup(force=True) + process_startup(force=True, slug="fork") def _prevent_sub_process_measurement() -> None: diff --git a/coverage/ctracer/tracer.c b/coverage/ctracer/tracer.c index 1096bef7a..cee4b723c 100644 --- a/coverage/ctracer/tracer.c +++ b/coverage/ctracer/tracer.c @@ -137,23 +137,25 @@ static void CTracer_showlog(CTracer * self, int lineno, PyObject * filename, const char * msg) { if (logging) { + FILE *flog = fopen("/tmp/debug_trace.txt", "a"); int depth = self->pdata_stack->depth; - printf("%x: %s%3d ", (int)self, indent(depth), depth); + fprintf(flog, "%p: %s%3d ", self, indent(depth), depth); if (lineno) { - printf("%4d", lineno); + fprintf(flog, "%4d", lineno); } else { - printf(" "); + fprintf(flog, " "); } if (filename) { PyObject *ascii = PyUnicode_AsASCIIString(filename); - printf(" %s", PyBytes_AS_STRING(ascii)); + fprintf(flog, " %s", PyBytes_AS_STRING(ascii)); Py_DECREF(ascii); } if (msg) { - printf(" %s", msg); + fprintf(flog, " %s", msg); } - printf("\n"); + fprintf(flog, "\n"); + fclose(flog); } } diff --git a/coverage/html.py b/coverage/html.py index 1fff59569..807661c4b 100644 --- a/coverage/html.py +++ b/coverage/html.py @@ -313,7 +313,7 @@ def __init__(self, cov: Coverage) -> None: # Functions available in the templates. "escape": escape, "pair": pair, - "len": len, + "pretty_file": pretty_file, # Constants for this report. "__url__": __url__, "__version__": coverage.__version__, @@ -321,7 +321,6 @@ def __init__(self, cov: Coverage) -> None: "time_stamp": format_local_datetime(datetime.datetime.now()), "extra_css": self.extra_css, "has_arcs": self.has_arcs, - "show_contexts": self.config.show_contexts, "statics": {}, # Constants for all reports. # These css classes determine which lines are highlighted by default. @@ -854,3 +853,8 @@ def escape(t: str) -> str: def pair(ratio: tuple[int, int]) -> str: """Format a pair of numbers so JavaScript can read them in an attribute.""" return "{} {}".format(*ratio) + + +def pretty_file(filename: str) -> str: + """Return a prettier version of `filename` for display.""" + return re.sub(r"[/\\]", "\N{THIN SPACE}\\g<0>\N{THIN SPACE}", filename) diff --git a/coverage/htmlfiles/coverage_html.js b/coverage/htmlfiles/coverage_html.js index 851d4b41f..6f871742c 100644 --- a/coverage/htmlfiles/coverage_html.js +++ b/coverage/htmlfiles/coverage_html.js @@ -140,12 +140,15 @@ coverage.wire_up_filter = function () { const table_body_rows = table.querySelectorAll("tbody tr"); const no_rows = document.getElementById("no_rows"); + const footer = table.tFoot.rows[0]; + const ratio_columns = Array.from(footer.cells).map(cell => Boolean(cell.dataset.ratio)); + // Observe filter keyevents. const filter_handler = (event => { // Keep running total of each metric, first index contains number of shown rows - const totals = new Array(table.rows[0].cells.length).fill(0); - // Accumulate the percentage as fraction - totals[totals.length - 1] = { "numer": 0, "denom": 0 }; // nosemgrep: eslint.detect-object-injection + const totals = ratio_columns.map( + is_ratio => is_ratio ? {"numer": 0, "denom": 0} : 0 + ); var text = document.getElementById("filter").value; // Store filter value @@ -191,11 +194,11 @@ coverage.wire_up_filter = function () { for (let column = 0; column < totals.length; column++) { // Accumulate dynamic totals cell = row.cells[column] // nosemgrep: eslint.detect-object-injection - if (cell.classList.contains("name")) { + if (cell.matches(".name, .spacer")) { continue; } - if (column === totals.length - 1) { - // Last column contains percentage + if (ratio_columns[column] && cell.dataset.ratio) { + // Column stores a ratio const [numer, denom] = cell.dataset.ratio.split(" "); totals[column]["numer"] += parseInt(numer, 10); // nosemgrep: eslint.detect-object-injection totals[column]["denom"] += parseInt(denom, 10); // nosemgrep: eslint.detect-object-injection @@ -218,17 +221,16 @@ coverage.wire_up_filter = function () { no_rows.style.display = null; table.style.display = null; - const footer = table.tFoot.rows[0]; // Calculate new dynamic sum values based on visible rows. for (let column = 0; column < totals.length; column++) { // Get footer cell element. const cell = footer.cells[column]; // nosemgrep: eslint.detect-object-injection - if (cell.classList.contains("name")) { + if (cell.matches(".name, .spacer")) { continue; } // Set value into dynamic footer cell element. - if (column === totals.length - 1) { + if (ratio_columns[column]) { // Percentage column uses the numerator and denominator, // and adapts to the number of decimal places. const match = /\.([0-9]+)/.exec(cell.textContent); diff --git a/coverage/htmlfiles/index.html b/coverage/htmlfiles/index.html index bb84b4403..7c5803978 100644 --- a/coverage/htmlfiles/index.html +++ b/coverage/htmlfiles/index.html @@ -81,53 +81,88 @@

{# The title="" attr doesn't work in Safari. #} + {% if has_arcs %} + + + {% if region_noun %} + + {% endif %} + + + + + + + + {% endif %} - + {% if region_noun %} - + + {% endif %} + + {% if has_arcs %} + {% endif %} {% if has_arcs %} + + {% endif %} - + + {% for region in regions %} - + {% if region_noun %} - + + {% endif %} + + {% if has_arcs %} + {% endif %} {% if has_arcs %} + + {% endif %} - + + {% endfor %} - + {% if region_noun %} - + + {% endif %} + + {% if has_arcs %} + {% endif %} {% if has_arcs %} + + {% endif %} - + +
   Statements Branches Total
FileFile{{ region_noun }}{{ region_noun }} coveragestatements missing excluded coverage branches partialcoverage coverage
{{region.file}}{{region.file|escape|pretty_file}}{{region.description}}{{region.description}} {{region.nums.pc_statements_str}}%{{region.nums.n_statements}} {{region.nums.n_missing}} {{region.nums.n_excluded}} {{region.nums.pc_branches_str}}% {{region.nums.n_branches}} {{region.nums.n_partial_branches}}{{region.nums.pc_covered_str}}% {{region.nums.pc_covered_str}}%
TotalTotal   {{totals.pc_statements_str}}%{{totals.n_statements}} {{totals.n_missing}} {{totals.n_excluded}} {{totals.pc_branches_str}}% {{totals.n_branches}} {{totals.n_partial_branches}}{{totals.pc_covered_str}}% {{totals.pc_covered_str}}%
diff --git a/coverage/htmlfiles/pyfile.html b/coverage/htmlfiles/pyfile.html index f4cf66a93..ce9f26d2c 100644 --- a/coverage/htmlfiles/pyfile.html +++ b/coverage/htmlfiles/pyfile.html @@ -25,7 +25,7 @@

- Coverage for {{relative_filename|escape}}: + Coverage for {{relative_filename|escape|pretty_file}}: {{nums.pc_covered_str}}%

diff --git a/coverage/htmlfiles/style.css b/coverage/htmlfiles/style.css index cb0cf4c46..5e304ce5f 100644 --- a/coverage/htmlfiles/style.css +++ b/coverage/htmlfiles/style.css @@ -328,16 +328,24 @@ kbd { border: 1px solid black; border-color: #888 #333 #333 #888; padding: .1em #index table.index { margin-left: -.5em; } -#index td, #index th { text-align: right; padding: .25em .5em; border-bottom: 1px solid #eee; } +#index td, #index th { text-align: right; vertical-align: baseline; padding: .25em .5em; border-bottom: 1px solid #eee; } @media (prefers-color-scheme: dark) { #index td, #index th { border-color: #333; } } #index td.name, #index th.name { text-align: left; width: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; min-width: 15em; } -#index th { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-style: italic; color: #333; cursor: pointer; } +#index td.left, #index th.left { text-align: left; } + +#index td.spacer, #index th.spacer { border: none; padding: 0; } + +#index td.spacer:hover, #index th.spacer:hover { background: inherit; } + +#index th { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-style: italic; color: #333; border-color: #ccc; cursor: pointer; } @media (prefers-color-scheme: dark) { #index th { color: #ddd; } } +@media (prefers-color-scheme: dark) { #index th { border-color: #444; } } + #index th:hover { background: #eee; } @media (prefers-color-scheme: dark) { #index th:hover { background: #333; } } @@ -352,13 +360,17 @@ kbd { border: 1px solid black; border-color: #888 #333 #333 #888; padding: .1em #index th[aria-sort="descending"] .arrows::after { content: " ▼"; } +#index tr.grouphead th { cursor: default; font-style: normal; border-color: #999; } + +@media (prefers-color-scheme: dark) { #index tr.grouphead th { border-color: #777; } } + #index td.name { font-size: 1.15em; } #index td.name a { text-decoration: none; color: inherit; } #index td.name .no-noun { font-style: italic; } -#index tr.total td, #index tr.total_dynamic td { font-weight: bold; border-top: 1px solid #ccc; border-bottom: none; } +#index tr.total td, #index tr.total_dynamic td { font-weight: bold; border-bottom: none; } #index tr.region:hover { background: #eee; } diff --git a/coverage/htmlfiles/style.scss b/coverage/htmlfiles/style.scss index 7feae6871..5e33c21e7 100644 --- a/coverage/htmlfiles/style.scss +++ b/coverage/htmlfiles/style.scss @@ -733,6 +733,7 @@ $border-indicator-width: .2em; } td, th { text-align: right; + vertical-align: baseline; padding: .25em .5em; border-bottom: 1px solid $light-gray2; @include border-color-dark($dark-gray2); @@ -742,12 +743,24 @@ $border-indicator-width: .2em; font-family: $font-normal; min-width: 15em; } + &.left { + text-align: left; + } + &.spacer { + border: none; + padding: 0; + &:hover { + background: inherit; + } + } } th { font-family: $font-normal; font-style: italic; color: $light-gray6; @include color-dark($dark-gray6); + border-color: $light-gray3; + @include border-color-dark($dark-gray3); cursor: pointer; &:hover { background: $light-gray2; @@ -773,6 +786,14 @@ $border-indicator-width: .2em; content: " ▼"; } } + tr.grouphead { + th { + cursor: default; + font-style: normal; + border-color: $light-gray4; + @include border-color-dark($dark-gray4); + } + } td.name { font-size: 1.15em; a { @@ -787,7 +808,6 @@ $border-indicator-width: .2em; tr.total td, tr.total_dynamic td { font-weight: bold; - border-top: 1px solid #ccc; border-bottom: none; } tr.region:hover { diff --git a/coverage/jsonreport.py b/coverage/jsonreport.py index 7b51cb7b1..1aad2e15e 100644 --- a/coverage/jsonreport.py +++ b/coverage/jsonreport.py @@ -51,6 +51,8 @@ def make_summary(self, nums: Numbers) -> JsonObj: "percent_covered_display": nums.pc_covered_str, "missing_lines": nums.n_missing, "excluded_lines": nums.n_excluded, + "percent_statements_covered": nums.pc_statements, + "percent_statements_covered_display": nums.pc_statements_str, } def make_branch_summary(self, nums: Numbers) -> JsonObj: @@ -60,6 +62,8 @@ def make_branch_summary(self, nums: Numbers) -> JsonObj: "num_partial_branches": nums.n_partial_branches, "covered_branches": nums.n_executed_branches, "missing_branches": nums.n_missing_branches, + "percent_branches_covered": nums.pc_branches, + "percent_branches_covered_display": nums.pc_branches_str, } def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str]) -> float: diff --git a/coverage/parser.py b/coverage/parser.py index 94c102b2f..9b13f19be 100644 --- a/coverage/parser.py +++ b/coverage/parser.py @@ -1166,6 +1166,8 @@ def _handle__Try(self, node: ast.Try) -> set[ArcStart]: return exits + _handle__TryStar = _handle__Try + def _handle__While(self, node: ast.While) -> set[ArcStart]: start = to_top = self.line_for_node(node.test) constant_test, _ = is_constant_test_expr(node.test) diff --git a/coverage/patch.py b/coverage/patch.py index 42ce0364e..8d54c5c60 100644 --- a/coverage/patch.py +++ b/coverage/patch.py @@ -5,15 +5,12 @@ from __future__ import annotations -import atexit import contextlib import os -import site -from pathlib import Path from typing import TYPE_CHECKING, Any, NoReturn from coverage import env -from coverage.debug import NoDebugging, DevNullDebug +from coverage.debug import DevNullDebug from coverage.exceptions import ConfigError, CoverageException if TYPE_CHECKING: @@ -26,8 +23,6 @@ def apply_patches( cov: Coverage, config: CoverageConfig, debug: TDebugCtl, - *, - make_pth_file: bool = True, ) -> None: """Apply invasive patches requested by `[run] patch=`.""" debug = debug if debug.should("patch") else DevNullDebug() @@ -43,7 +38,7 @@ def apply_patches( _patch_fork(debug) case "subprocess": - _patch_subprocess(config, debug, make_pth_file) + _patch_subprocess(config, debug) case _: raise ConfigError(f"Unknown patch {patch!r}") @@ -116,51 +111,8 @@ def _patch_fork(debug: TDebugCtl) -> None: os.register_at_fork(after_in_child=_after_fork_in_child) -def _patch_subprocess(config: CoverageConfig, debug: TDebugCtl, make_pth_file: bool) -> None: +def _patch_subprocess(config: CoverageConfig, debug: TDebugCtl) -> None: """Write .pth files and set environment vars to measure subprocesses.""" debug.write("Patching subprocess") - - if make_pth_file: - pth_files = create_pth_files(debug) - - def delete_pth_files() -> None: - for p in pth_files: - debug.write(f"Deleting subprocess .pth file: {str(p)!r}") - p.unlink(missing_ok=True) - - atexit.register(delete_pth_files) assert config.config_file is not None os.environ["COVERAGE_PROCESS_CONFIG"] = config.serialize() - - -# Writing .pth files is not obvious. On Windows, getsitepackages() returns two -# directories. A .pth file in the first will be run, but coverage isn't -# importable yet. We write into all the places we can, but with defensive -# import code. - -PTH_CODE = """\ -try: - import coverage -except: - pass -else: - coverage.process_startup() -""" - -PTH_TEXT = f"import sys; exec({PTH_CODE!r})\n" - - -def create_pth_files(debug: TDebugCtl = NoDebugging()) -> list[Path]: - """Create .pth files for measuring subprocesses.""" - pth_files = [] - for pth_dir in site.getsitepackages(): - pth_file = Path(pth_dir) / f"subcover_{os.getpid()}.pth" - try: - if debug.should("patch"): - debug.write(f"Writing subprocess .pth file: {str(pth_file)!r}") - pth_file.write_text(PTH_TEXT, encoding="utf-8") - except OSError: # pragma: cant happen - continue - else: - pth_files.append(pth_file) - return pth_files diff --git a/coverage/pth_file.py b/coverage/pth_file.py new file mode 100644 index 000000000..ee6ca4557 --- /dev/null +++ b/coverage/pth_file.py @@ -0,0 +1,16 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/coveragepy/coveragepy/blob/main/NOTICE.txt + +# pylint: disable=missing-module-docstring +# pragma: exclude file from coverage +# This will become the .pth file for subprocesses. + +import os + +if os.getenv("COVERAGE_PROCESS_START") or os.getenv("COVERAGE_PROCESS_CONFIG"): + try: + import coverage + except: # pylint: disable=bare-except + pass + else: + coverage.process_startup(slug="pth") diff --git a/coverage/python.py b/coverage/python.py index 52002dacb..8fa734e46 100644 --- a/coverage/python.py +++ b/coverage/python.py @@ -24,7 +24,10 @@ if TYPE_CHECKING: from coverage import Coverage +# Protect ourselves against aggressive mocking. os = isolate_module(os) +# Save the original `open` function so later mocks don't break us. +open = open # pylint: disable=redefined-builtin def read_python_source(filename: str) -> bytes: diff --git a/coverage/report.py b/coverage/report.py index 30fa40655..efba545d4 100644 --- a/coverage/report.py +++ b/coverage/report.py @@ -169,7 +169,7 @@ def report_markdown( # Write the TOTAL line formats.update( dict( - Name="|{:>{name_len}} |", + Name="|{:{name_len}} |", Cover="{:>{n}} |", ), ) diff --git a/coverage/results.py b/coverage/results.py index 57f9006f4..4487baeaa 100644 --- a/coverage/results.py +++ b/coverage/results.py @@ -323,15 +323,36 @@ def n_executed_branches(self) -> int: """Returns the number of executed branches.""" return self.n_branches - self.n_missing_branches + @property + def ratio_statements(self) -> tuple[int, int]: + """Return numerator/denominator for statement coverage.""" + return self.n_executed, self.n_statements + + @property + def ratio_branches(self) -> tuple[int, int]: + """Return numerator/denominator for branch coverage.""" + return self.n_executed_branches, self.n_branches + + def _percent(self, numerator: int, denominator: int) -> float: + """Helper for pc_* properties.""" + if denominator > 0: + return (100.0 * numerator) / denominator + return 100.0 + @property def pc_covered(self) -> float: """Returns a single percentage value for coverage.""" - if self.n_statements > 0: - numerator, denominator = self.ratio_covered - pc_cov = (100.0 * numerator) / denominator - else: - pc_cov = 100.0 - return pc_cov + return self._percent(*self.ratio_covered) + + @property + def pc_statements(self) -> float: + """Returns the percentage covered for statements.""" + return self._percent(*self.ratio_statements) + + @property + def pc_branches(self) -> float: + """Returns the percentage covered for branches.""" + return self._percent(*self.ratio_branches) @property def pc_covered_str(self) -> str: @@ -344,6 +365,16 @@ def pc_covered_str(self) -> str: """ return display_covered(self.pc_covered, self.precision) + @property + def pc_statements_str(self) -> str: + """Returns the statement percent covered without a percent sign.""" + return display_covered(self.pc_statements, self.precision) + + @property + def pc_branches_str(self) -> str: + """Returns the branch percent covered without a percent sign.""" + return display_covered(self.pc_branches, self.precision) + @property def ratio_covered(self) -> tuple[int, int]: """Return a numerator and denominator for the coverage ratio.""" diff --git a/coverage/sysmon.py b/coverage/sysmon.py index 3696500f8..33e7a6f35 100644 --- a/coverage/sysmon.py +++ b/coverage/sysmon.py @@ -5,6 +5,7 @@ from __future__ import annotations +import collections import functools import inspect import os @@ -19,7 +20,7 @@ from coverage import env from coverage.bytecode import TBranchTrails, always_jumps, branch_trails from coverage.debug import short_filename, short_stack -from coverage.exceptions import NotPython +from coverage.exceptions import NoSource, NotPython from coverage.misc import isolate_module from coverage.parser import PythonParser from coverage.types import ( @@ -223,6 +224,10 @@ def __init__(self, tool_id: int) -> None: # A list of code_objects, just to keep them alive so that id's are # useful as identity. self.code_objects: list[CodeType] = [] + + # Map filename:__name__ -> set(id(code_object)) + self.filename_code_ids: dict[str, set[int]] = collections.defaultdict(set) + self.sysmon_on = False self.lock = threading.Lock() @@ -260,7 +265,7 @@ def start(self) -> None: self.sysmon_branch_either, ) register( - events.BRANCH_LEFT, # type:ignore[attr-defined] + events.BRANCH_LEFT, self.sysmon_branch_either, ) else: @@ -281,6 +286,22 @@ def stop(self) -> None: self.sysmon_on = False sys_monitoring.free_tool_id(self.myid) + if LOG: # pragma: debugging + items = sorted( + self.filename_code_ids.items(), + key=lambda item: len(item[1]), + reverse=True, + ) + code_objs = sum(len(code_ids) for _, code_ids in items) + dupes = code_objs - len(items) + if dupes: + log(f"==== Duplicate code objects: {dupes} duplicates, {code_objs} total") + for filename, code_ids in items: + if len(code_ids) > 1: + log(f"{len(code_ids):>5} objects: {filename}") + else: + log("==== Duplicate code objects: none") + @panopticon() def post_fork(self) -> None: """The process has forked, clean up as needed.""" @@ -301,11 +322,15 @@ def get_stats(self) -> dict[str, int] | None: @panopticon("code", "@") def sysmon_py_start(self, code: CodeType, instruction_offset: TOffset) -> MonitorReturn: """Handle sys.monitoring.events.PY_START events.""" - # Entering a new frame. Decide if we should trace in this file. self._activity = True if self.stats is not None: self.stats["starts"] += 1 + if code.co_name == "__annotate__": + # Type annotation code objects don't execute, ignore them. + return DISABLE + + # Entering a new frame. Decide if we should trace in this file. code_info = self.code_infos.get(id(code)) tracing_code: bool | None = None file_data: TTraceFileData | None = None @@ -320,7 +345,7 @@ def sysmon_py_start(self, code: CodeType, instruction_offset: TOffset) -> Monito frame = inspect.currentframe() if frame is not None: frame = inspect.currentframe().f_back # type: ignore[union-attr] - if LOG: + if LOG: # pragma: debugging # @panopticon adds a frame. frame = frame.f_back # type: ignore[union-attr] disp = self.should_trace(filename, frame) # type: ignore[arg-type] @@ -364,10 +389,16 @@ def sysmon_py_start(self, code: CodeType, instruction_offset: TOffset) -> Monito assert env.PYBEHAVIOR.branch_right_left local_events |= ( events.BRANCH_RIGHT # type:ignore[attr-defined] - | events.BRANCH_LEFT # type:ignore[attr-defined] + | events.BRANCH_LEFT ) sys_monitoring.set_local_events(self.myid, code, local_events) + if LOG: # pragma: debugging + if code.co_filename not in {""}: + self.filename_code_ids[f"{code.co_filename}:{code.co_name}"].add( + id(code) + ) + return DISABLE @panopticon("code", "@", None) @@ -383,7 +414,7 @@ def sysmon_py_return( code_info = self.code_infos.get(id(code)) # code_info is not None and code_info.file_data is not None, since we # wouldn't have enabled this event if they were. - last_line = code_info.byte_to_line[instruction_offset] # type: ignore + last_line = code_info.byte_to_line.get(instruction_offset) # type: ignore if last_line is not None: arc = (last_line, -code.co_firstlineno) code_info.file_data.add(arc) # type: ignore @@ -457,21 +488,22 @@ def sysmon_branch_either( if not added_arc: # This could be an exception jumping from line to line. assert code_info.byte_to_line is not None - l1 = code_info.byte_to_line[instruction_offset] - l2 = code_info.byte_to_line.get(destination_offset) - if l2 is not None and l1 != l2: - arc = (l1, l2) - code_info.file_data.add(arc) # type: ignore - # log(f"adding unforeseen {arc=}") + l1 = code_info.byte_to_line.get(instruction_offset) + if l1 is not None: + l2 = code_info.byte_to_line.get(destination_offset) + if l2 is not None and l1 != l2: + arc = (l1, l2) + code_info.file_data.add(arc) # type: ignore + # log(f"adding unforeseen {arc=}") return DISABLE -@functools.lru_cache(maxsize=5) +@functools.lru_cache(maxsize=20) def get_multiline_map(filename: str) -> dict[TLineNo, TLineNo]: """Get a PythonParser for the given filename, cached.""" - parser = PythonParser(filename=filename) try: + parser = PythonParser(filename=filename) parser.parse_source() except NotPython: # The file was not Python. This can happen when the code object refers @@ -479,4 +511,7 @@ def get_multiline_map(filename: str) -> dict[TLineNo, TLineNo]: # In that case, just return an empty map, which might lead to slightly # wrong branch coverage, but we don't have any better option. return {} + except NoSource: + # This can happen if open() in python.py fails. + return {} return parser.multiline_map diff --git a/coverage/templite.py b/coverage/templite.py index e4abd00df..7c0d08307 100644 --- a/coverage/templite.py +++ b/coverage/templite.py @@ -89,6 +89,10 @@ class Templite: {% if var %}...{% endif %} + if-else:: + + {% if var %}...{% else %}...{% endif %} + Comments are within curly-hash markers:: {# This will be ignored #} @@ -187,6 +191,14 @@ def flush_output() -> None: ops_stack.append("if") code.add_line("if %s:" % self._expr_code(words[1])) code.indent() + elif words[0] == "else": + if len(words) != 1: + self._syntax_error("Don't understand else", token) + if not ops_stack or ops_stack[-1] != "if": + self._syntax_error("Mismatched else", token) + code.dedent() + code.add_line("else:") + code.indent() elif words[0] == "for": # A loop: iterate over expression result. if len(words) != 4 or words[2] != "in": diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py index 2a1f7dffe..1dbc0a568 100644 --- a/coverage/tomlconfig.py +++ b/coverage/tomlconfig.py @@ -84,6 +84,8 @@ def _get_section(self, section: str) -> tuple[str | None, TConfigSectionOut | No """ prefixes = ["tool.coverage."] + if self.our_file: + prefixes.append("") for prefix in prefixes: real_section = prefix + section parts = real_section.split(".") diff --git a/coverage/version.py b/coverage/version.py index 0205a3673..dbe2ef535 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, 11, 3, "final", 0) +version_info = (7, 13, 0, "final", 0) _dev = 0 diff --git a/doc/cog_helpers.py b/doc/cog_helpers.py index 91e01da66..6f66b64ba 100644 --- a/doc/cog_helpers.py +++ b/doc/cog_helpers.py @@ -76,7 +76,7 @@ def show_configs(ini, toml): The equivalence is checked for accuracy, and the process fails if there's a mismatch. - A three-tabbed box will be produced. + A four-tabbed box will be produced. """ ini, ini_vals = _read_config(ini, "covrc") toml, toml_vals = _read_config(toml, "covrc.toml") @@ -89,11 +89,15 @@ def show_configs(ini, toml): ) ini2 = re.sub(r"(?m)^\[", "[coverage:", ini) + toml2 = "# You can also use sections like [tool.coverage.run]\n" + toml2 += toml.replace("[tool.coverage.", "[") + print() print(".. tabs::\n") for name, syntax, text in [ - (".coveragerc", "ini", ini), ("pyproject.toml", "toml", toml), + (".coveragerc", "ini", ini), + (".coveragerc.toml", "toml", toml2), ("setup.cfg or tox.ini", "ini", ini2), ]: print(f" .. code-tab:: {syntax}") diff --git a/doc/commands/index.rst b/doc/commands/index.rst index 6eb34e21d..299380894 100644 --- a/doc/commands/index.rst +++ b/doc/commands/index.rst @@ -9,14 +9,8 @@ Commands .. highlight:: console -When you install coverage.py, a command-line script called ``coverage`` is -placed on your path. To help with multi-version installs, it will also create -a ``coverage3`` alias, and a ``coverage-X.Y`` alias, depending on the version -of Python you're using. For example, when installing on Python 3.10, you will -be able to use ``coverage``, ``coverage3``, or ``coverage-3.10`` on the command -line. - -Coverage.py has a number of commands: +When you install coverage.py, a command called ``coverage`` is installed for +command-line use. It has a number of commands: * **run** -- :ref:`Run a Python program and collect execution data `. diff --git a/doc/conf.py b/doc/conf.py index 2cdc9752b..e02ae191b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -68,11 +68,11 @@ # @@@ editable copyright = "2009–2025, Ned Batchelder" # pylint: disable=redefined-builtin # The short X.Y.Z version. -version = "7.11.3" +version = "7.13.0" # The full version, including alpha/beta/rc tags. -release = "7.11.3" +release = "7.13.0" # The date of release, in "monthname day, year" format. -release_date = "November 9, 2025" +release_date = "December 8, 2025" # @@@ end rst_epilog = f""" diff --git a/doc/config.rst b/doc/config.rst index 130c25465..a9c0c1d3d 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -39,8 +39,9 @@ environment variable. If ``.coveragerc`` doesn't exist and another file hasn't been specified, then coverage.py will look for settings in other common configuration files, in this -order: setup.cfg, tox.ini, or pyproject.toml. The first file found with -coverage.py settings will be used and other files won't be consulted. +order: :file:`.coveragerc.toml`, :file:`setup.cfg`, :file:`tox.ini`, or +:file:`pyproject.toml`. The first file found with coverage.py settings will be +used and other files won't be consulted. Coverage.py will read from "pyproject.toml" if TOML support is available, either because you are running on Python 3.11 or later, or because you @@ -169,6 +170,36 @@ Here's a sample configuration file, in each syntax: .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.run] + branch = true + + [tool.coverage.report] + # Regexes for lines to exclude from consideration + exclude_also = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", + ] + + ignore_errors = true + + [tool.coverage.html] + directory = "coverage_html_report" + .. code-tab:: ini :caption: .coveragerc @@ -199,12 +230,13 @@ Here's a sample configuration file, in each syntax: directory = coverage_html_report .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.run] + # You can also use sections like [tool.coverage.run] + [run] branch = true - [tool.coverage.report] + [report] # Regexes for lines to exclude from consideration exclude_also = [ # Don't complain about missing debug-only code: @@ -225,7 +257,7 @@ Here's a sample configuration file, in each syntax: ignore_errors = true - [tool.coverage.html] + [html] directory = "coverage_html_report" .. code-tab:: ini @@ -257,7 +289,7 @@ Here's a sample configuration file, in each syntax: [coverage:html] directory = coverage_html_report -.. [[[end]]] (sum: HU1Z62mvRK) +.. [[[end]]] (sum: qYCtIxMe+3) The specific configuration settings are described below. Many sections and @@ -384,7 +416,7 @@ include a short string at the end, the name of the warning. See [run] debug ........... -(multi-string) A list of debug options. See :ref:`the run --debug option +(multi-string) A list of debug options. See :ref:`the run -‍-debug option ` for details. @@ -393,7 +425,7 @@ include a short string at the end, the name of the warning. See [run] debug_file ................ -(string) A file name to write debug output to. See :ref:`the run --debug +(string) A file name to write debug output to. See :ref:`the run -‍-debug option ` for details. @@ -596,6 +628,16 @@ equivalent when combining data from different machines: .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.paths] + source = [ + "src/", + "/jenkins/build/*/src", + "c:\\myproj\\src", + ] + .. code-tab:: ini :caption: .coveragerc @@ -606,9 +648,10 @@ equivalent when combining data from different machines: c:\myproj\src .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.paths] + # You can also use sections like [tool.coverage.run] + [paths] source = [ "src/", "/jenkins/build/*/src", @@ -624,7 +667,7 @@ equivalent when combining data from different machines: /jenkins/build/*/src c:\myproj\src -.. [[[end]]] (sum: oHSl8SGiMT) +.. [[[end]]] (sum: Aq4877/XV0) The names of the entries ("source" in this example) are ignored, you may choose @@ -651,7 +694,7 @@ file being reported. Combining multiple files requires the ``combine`` command. The ``--debug=pathmap`` option can be used to log details of the re-mapping of -paths. See :ref:`the --debug option `. +paths. See :ref:`the -‍-debug option `. See :ref:`cmd_combine_remapping` and :ref:`source_glob` for more information. diff --git a/doc/contexts.rst b/doc/contexts.rst index 80b57df0d..bbb9f60dd 100644 --- a/doc/contexts.rst +++ b/doc/contexts.rst @@ -90,6 +90,12 @@ The ``[run] dynamic_context`` setting has only one option now. Set it to .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.run] + dynamic_context = "test_function" + .. code-tab:: ini :caption: .coveragerc @@ -97,9 +103,10 @@ The ``[run] dynamic_context`` setting has only one option now. Set it to dynamic_context = test_function .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.run] + # You can also use sections like [tool.coverage.run] + [run] dynamic_context = "test_function" .. code-tab:: ini @@ -108,7 +115,7 @@ The ``[run] dynamic_context`` setting has only one option now. Set it to [coverage:run] dynamic_context = test_function -.. [[[end]]] (sum: dZTDYjHw71) +.. [[[end]]] (sum: G1Fc1tVhgd) Each test function you run will be considered a separate dynamic context, and coverage data will be segregated for each. A test function is any function diff --git a/doc/excluding.rst b/doc/excluding.rst index 46b458a7a..94651798d 100644 --- a/doc/excluding.rst +++ b/doc/excluding.rst @@ -137,6 +137,14 @@ all of them by adding a regex to the exclusion list: .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.report] + exclude_also = [ + "def __repr__", + ] + .. code-tab:: ini :caption: .coveragerc @@ -145,9 +153,10 @@ all of them by adding a regex to the exclusion list: def __repr__ .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.report] + # You can also use sections like [tool.coverage.run] + [report] exclude_also = [ "def __repr__", ] @@ -159,7 +168,7 @@ all of them by adding a regex to the exclusion list: exclude_also = def __repr__ -.. [[[end]]] (sum: 8+cOvxKPvv) +.. [[[end]]] (sum: pma7Vgh8a0) For example, here's a list of exclusions I've used: @@ -199,6 +208,23 @@ For example, here's a list of exclusions I've used: .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.report] + exclude_also = [ + 'def __repr__', + 'if self.debug:', + 'if settings.DEBUG', + 'raise AssertionError', + 'raise NotImplementedError', + 'if 0:', + 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', + 'class .*\bProtocol\):', + '@(abc\.)?abstractmethod', + ] + .. code-tab:: ini :caption: .coveragerc @@ -216,9 +242,10 @@ For example, here's a list of exclusions I've used: @(abc\.)?abstractmethod .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.report] + # You can also use sections like [tool.coverage.run] + [report] exclude_also = [ 'def __repr__', 'if self.debug:', @@ -248,7 +275,7 @@ For example, here's a list of exclusions I've used: class .*\bProtocol\): @(abc\.)?abstractmethod -.. [[[end]]] (sum: ZQsgnt0nES) +.. [[[end]]] (sum: QV2NECVQ1X) The :ref:`config_report_exclude_also` option adds regexes to the built-in default list so that you can add your own exclusions. The older @@ -306,6 +333,19 @@ Here are some examples: .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.report] + exclude_also = [ + # 1. Exclude an except clause of a specific form: + 'except ValueError:\n\s*assume\(False\)', + # 2. Comments to turn coverage on and off: + 'no cover: start(?s:.)*?no cover: stop', + # 3. A pragma comment that excludes an entire file: + '\A(?s:.*# pragma: exclude file.*)\Z', + ] + .. code-tab:: ini :caption: .coveragerc @@ -319,9 +359,10 @@ Here are some examples: \A(?s:.*# pragma: exclude file.*)\Z .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.report] + # You can also use sections like [tool.coverage.run] + [report] exclude_also = [ # 1. Exclude an except clause of a specific form: 'except ValueError:\n\s*assume\(False\)', @@ -343,7 +384,7 @@ Here are some examples: ; 3. A pragma comment that excludes an entire file: \A(?s:.*# pragma: exclude file.*)\Z -.. [[[end]]] (sum: xG6Bmtmh06) +.. [[[end]]] (sum: LAn0Y4yP/X) The first regex matches a specific except line followed by a specific function call. Both lines must be present for the exclusion to take effect. Note that diff --git a/doc/faq.rst b/doc/faq.rst index f2bb22247..65fd5562a 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -116,7 +116,9 @@ did not execute all of their exits. The :ref:`JSON report ` includes more data that can be used to re-calculate the total percentage. Individual files have a ``summary`` key, -and the report as a whole has a ``totals`` key that include items like these: +and the report as a whole has a ``totals`` key that include items like these. +The ``percent_statements_covered`` value is always included, and when branch +coverage is measured there are matching branch values: .. code-block:: json @@ -130,7 +132,11 @@ and the report as a whole has a ``totals`` key that include items like these: "num_partial_branches": 5, "num_statements": 114, "percent_covered": 10.76923076923077, - "percent_covered_display": "11" + "percent_covered_display": "11", + "percent_statements_covered": 7.894736842105263, + "percent_statements_covered_display": "8", + "percent_branches_covered": 31.25, + "percent_branches_covered_display": "31" } The total percentage is calculated as:: diff --git a/doc/index.rst b/doc/index.rst index 32434c393..08c5c67a5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -24,7 +24,7 @@ supported on: .. ifconfig:: prerelease **This is a pre-release build. The usual warnings about possible bugs - apply.** The latest stable version is coverage.py 6.5.0, `described here`_. + apply.** The latest stable version is coverage.py 7.12.0, `described here`_. .. _described here: http://coverage.readthedocs.io/ diff --git a/doc/messages.rst b/doc/messages.rst index bdf473cb6..7d11101a6 100644 --- a/doc/messages.rst +++ b/doc/messages.rst @@ -184,6 +184,12 @@ file: .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.run] + disable_warnings = ["no-data-collected"] + .. code-tab:: ini :caption: .coveragerc @@ -191,9 +197,10 @@ file: disable_warnings = no-data-collected .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.run] + # You can also use sections like [tool.coverage.run] + [run] disable_warnings = ["no-data-collected"] .. code-tab:: ini @@ -202,4 +209,4 @@ file: [coverage:run] disable_warnings = no-data-collected -.. [[[end]]] (sum: SJKFvPoXO2) +.. [[[end]]] (sum: 29eQyqSCXt) diff --git a/doc/plugins.rst b/doc/plugins.rst index 1061c478b..dd47ad5d9 100644 --- a/doc/plugins.rst +++ b/doc/plugins.rst @@ -64,6 +64,12 @@ a coverage.py plug-in called ``something.plugin``. .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.run] + plugins = [ "something.plugin" ] + .. code-tab:: ini :caption: .coveragerc @@ -72,9 +78,10 @@ a coverage.py plug-in called ``something.plugin``. something.plugin .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.run] + # You can also use sections like [tool.coverage.run] + [run] plugins = [ "something.plugin" ] .. code-tab:: ini @@ -84,7 +91,7 @@ a coverage.py plug-in called ``something.plugin``. plugins = something.plugin - .. [[[end]]] (sum: boZjI9S8MZ) + .. [[[end]]] (sum: 7exEgyBxea) #. If the plug-in needs its own configuration, you can add those settings in the .coveragerc file in a section named for the plug-in: @@ -106,6 +113,13 @@ a coverage.py plug-in called ``something.plugin``. .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.something.plugin] + option1 = true + option2 = "abc.foo" + .. code-tab:: ini :caption: .coveragerc @@ -114,9 +128,10 @@ a coverage.py plug-in called ``something.plugin``. option2 = abc.foo .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.something.plugin] + # You can also use sections like [tool.coverage.run] + [something.plugin] option1 = true option2 = "abc.foo" @@ -127,7 +142,7 @@ a coverage.py plug-in called ``something.plugin``. option1 = True option2 = abc.foo - .. [[[end]]] (sum: tpARXb5/bH) + .. [[[end]]] (sum: xPc0F1izoA) Check the documentation for the plug-in for details on the options it takes. diff --git a/doc/requirements.pip b/doc/requirements.pip index be0f11c97..b36ea5801 100644 --- a/doc/requirements.pip +++ b/doc/requirements.pip @@ -10,11 +10,11 @@ attrs==25.4.0 # via scriv babel==2.17.0 # via sphinx -certifi==2025.10.5 +certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests -click==8.3.0 +click==8.3.1 # via # click-log # scriv diff --git a/doc/sample_html/class_index.html b/doc/sample_html/class_index.html index a8ba27051..6cad72d09 100644 --- a/doc/sample_html/class_index.html +++ b/doc/sample_html/class_index.html @@ -4,8 +4,8 @@ Cog coverage - - + +
@@ -56,507 +56,762 @@

Classes

- coverage.py v7.11.3, - created at 2025-11-09 18:19 -0500 + coverage.py v7.13.0, + created at 2025-12-08 07:30 -0500

+ + + + + + + + + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + + - - + + + + + + - + +
   Statements Branches Total
FileclassFileclass coverage statements missing excluded coverage branches partialcoverage coverage
cogapp/__init__.py(no class)cogapp / __init__.py(no class) 100.00% 1 0 0 100.00% 0 0100.00% 100.00%
cogapp/__main__.py(no class)cogapp / __main__.py(no class) 0.00% 3 3 0 100.00% 0 00.00% 0.00%
cogapp/cogapp.pyCogErrorcogapp / cogapp.pyCogError 100.00% 3 0 0 100.00% 2 0100.00% 100.00%
cogapp/cogapp.pyCogUsageErrorcogapp / cogapp.pyCogUsageError 100.00% 0 0 0 100.00% 0 0100.00% 100.00%
cogapp/cogapp.pyCogInternalErrorcogapp / cogapp.pyCogInternalError 100.00% 0 0 0 100.00% 0 0100.00% 100.00%
cogapp/cogapp.pyCogGeneratedErrorcogapp / cogapp.pyCogGeneratedError 100.00% 0 0 0 100.00% 0 0100.00% 100.00%
cogapp/cogapp.pyCogUserExceptioncogapp / cogapp.pyCogUserException 100.00% 0 0 0 100.00% 0 0100.00% 100.00%
cogapp/cogapp.pyCogCheckFailedcogapp / cogapp.pyCogCheckFailed 100.00% 0 0 0 100.00% 0 0100.00% 100.00%
cogapp/cogapp.pyCogGeneratorcogapp / cogapp.pyCogGenerator 89.66% 58 6 0 85.00% 20 388.46% 88.46%
cogapp/cogapp.pyCogOptionscogapp / cogapp.pyCogOptions 27.27% 88 64 1 0.00% 50 017.39% 17.39%
cogapp/cogapp.pyCogcogapp / cogapp.pyCog 37.45% 259 162 0 30.17% 116 2135.20% 35.20%
cogapp/cogapp.py(no class)cogapp / cogapp.py(no class) 89.77% 88 9 0 50.00% 8 286.46% 86.46%
cogapp/hashhandler.pyHashHandlercogapp / hashhandler.pyHashHandler 31.37% 51 35 0 16.67% 24 226.67% 26.67%
cogapp/hashhandler.py(no class)cogapp / hashhandler.py(no class) 100.00% 12 0 0 100.00% 0 0100.00% 100.00%
cogapp/makefiles.py(no class)cogapp / makefiles.py(no class) 18.18% 22 18 0 0.00% 14 011.11% 11.11%
cogapp/test_cogapp.pyCogTestsInMemorycogapp / test_cogapp.pyCogTestsInMemory 100.00% 73 0 0 100.00% 2 0100.00% 100.00%
cogapp/test_cogapp.pyCogOptionsTestscogapp / test_cogapp.pyCogOptionsTests 0.00% 31 31 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyFileStructureTestscogapp / test_cogapp.pyFileStructureTests 0.00% 29 29 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyCogErrorTestscogapp / test_cogapp.pyCogErrorTests 0.00% 11 11 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyCogGeneratorGetCodeTestscogapp / test_cogapp.pyCogGeneratorGetCodeTests 0.00% 37 37 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyTestCaseWithTempDircogapp / test_cogapp.pyTestCaseWithTempDir 0.00% 19 19 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyArgumentHandlingTestscogapp / test_cogapp.pyArgumentHandlingTests 0.00% 47 47 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyTestMaincogapp / test_cogapp.pyTestMain 0.00% 27 27 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyTestFileHandlingcogapp / test_cogapp.pyTestFileHandling 0.00% 79 79 0 0.00% 2 00.00% 0.00%
cogapp/test_cogapp.pyCogTestLineEndingscogapp / test_cogapp.pyCogTestLineEndings 0.00% 12 12 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyCogTestCharacterEncodingcogapp / test_cogapp.pyCogTestCharacterEncoding 0.00% 12 12 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyTestCaseWithImportscogapp / test_cogapp.pyTestCaseWithImports 0.00% 6 6 0 0.00% 2 00.00% 0.00%
cogapp/test_cogapp.pyCogIncludeTestscogapp / test_cogapp.pyCogIncludeTests 0.00% 46 46 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyCogTestsInFilescogapp / test_cogapp.pyCogTestsInFiles 0.00% 122 122 2 0.00% 6 00.00% 0.00%
cogapp/test_cogapp.pyCheckTestscogapp / test_cogapp.pyCheckTests 0.00% 61 61 0 0.00% 6 00.00% 0.00%
cogapp/test_cogapp.pyWritabilityTestscogapp / test_cogapp.pyWritabilityTests 0.00% 19 19 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyChecksumTestscogapp / test_cogapp.pyChecksumTests 0.00% 34 34 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyCustomMarkerTestscogapp / test_cogapp.pyCustomMarkerTests 0.00% 12 12 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyBlakeTestscogapp / test_cogapp.pyBlakeTests 0.00% 15 15 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyErrorCallTestscogapp / test_cogapp.pyErrorCallTests 0.00% 12 12 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.pyHashHandlerTestscogapp / test_cogapp.pyHashHandlerTests 0.00% 10 10 0 100.00% 0 00.00% 0.00%
cogapp/test_cogapp.py(no class)cogapp / test_cogapp.py(no class) 98.98% 197 2 0 50.00% 2 198.49% 98.49%
cogapp/test_makefiles.pySimpleTestscogapp / test_makefiles.pySimpleTests 0.00% 51 51 0 0.00% 6 00.00% 0.00%
cogapp/test_makefiles.py(no class)cogapp / test_makefiles.py(no class) 100.00% 17 0 0 100.00% 0 0100.00% 100.00%
cogapp/test_whiteutils.pyWhitePrefixTestscogapp / test_whiteutils.pyWhitePrefixTests 0.00% 17 17 0 100.00% 0 00.00% 0.00%
cogapp/test_whiteutils.pyReindentBlockTestscogapp / test_whiteutils.pyReindentBlockTests 0.00% 21 21 0 100.00% 0 00.00% 0.00%
cogapp/test_whiteutils.pyCommonPrefixTestscogapp / test_whiteutils.pyCommonPrefixTests 0.00% 12 12 0 100.00% 0 00.00% 0.00%
cogapp/test_whiteutils.py(no class)cogapp / test_whiteutils.py(no class) 100.00% 18 0 0 100.00% 0 0100.00% 100.00%
cogapp/utils.pyRedirectablecogapp / utils.pyRedirectable 62.50% 8 3 0 50.00% 4 258.33% 58.33%
cogapp/utils.pyNumberedFileReadercogapp / utils.pyNumberedFileReader 100.00% 7 0 0 100.00% 2 0100.00% 100.00%
cogapp/utils.py(no class)cogapp / utils.py(no class) 77.27% 22 5 0 100.00% 0 077.27% 77.27%
cogapp/whiteutils.py(no class)cogapp / whiteutils.py(no class) 88.64% 44 5 0 87.50% 32 488.16% 88.16%
Total Total  38.47% 1713 1054 3 32.55% 298 3537.59% 37.59%
@@ -567,8 +822,8 @@

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_hashhandler_py.html b/doc/sample_html/z_7b071bdc2a35fa80_hashhandler_py.html index 74142d3ad..946f37e3d 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_hashhandler_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_hashhandler_py.html @@ -4,14 +4,14 @@ Coverage for cogapp/hashhandler.py: 36.78% - - + +

- Coverage for cogapp/hashhandler.py: + Coverage for cogapp / hashhandler.py: 36.78%

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html b/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html index 53c263a06..1bc7f2aa3 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html @@ -4,14 +4,14 @@ Coverage for cogapp/makefiles.py: 11.11% - - + +

- Coverage for cogapp/makefiles.py: + Coverage for cogapp / makefiles.py: 11.11%

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html index e6374dbe9..b86f2c474 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_test_cogapp_py.html @@ -4,14 +4,14 @@ Coverage for cogapp/test_cogapp.py: 29.11% - - + +

- Coverage for cogapp/test_cogapp.py: + Coverage for cogapp / test_cogapp.py: 29.11%

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html index f84b31fc0..46d16ace9 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_test_makefiles_py.html @@ -4,14 +4,14 @@ Coverage for cogapp/test_makefiles.py: 22.97% - - + +

- Coverage for cogapp/test_makefiles.py: + Coverage for cogapp / test_makefiles.py: 22.97%

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html index 9889cbfb1..46b321560 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_test_whiteutils_py.html @@ -4,14 +4,14 @@ Coverage for cogapp/test_whiteutils.py: 26.47% - - + +

- Coverage for cogapp/test_whiteutils.py: + Coverage for cogapp / test_whiteutils.py: 26.47%

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html index 55e5974fa..217af53c5 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_utils_py.html @@ -4,14 +4,14 @@ Coverage for cogapp/utils.py: 76.74% - - + +

- Coverage for cogapp/utils.py: + Coverage for cogapp / utils.py: 76.74%

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html b/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html index 17e3732cf..ea0d6a78f 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_whiteutils_py.html @@ -4,14 +4,14 @@ Coverage for cogapp/whiteutils.py: 88.16% - - + +

- Coverage for cogapp/whiteutils.py: + Coverage for cogapp / whiteutils.py: 88.16%

diff --git a/doc/source.rst b/doc/source.rst index 515b2748a..e1c2b78d4 100644 --- a/doc/source.rst +++ b/doc/source.rst @@ -102,6 +102,19 @@ current directory: .. tabs:: + .. code-tab:: toml + :caption: pyproject.toml + + [tool.coverage.run] + omit = [ + # omit anything in a .local directory anywhere + "*/.local/*", + # omit everything in /usr + "/usr/*", + # omit this single file + "utils/tirefire.py", + ] + .. code-tab:: ini :caption: .coveragerc @@ -115,9 +128,10 @@ current directory: utils/tirefire.py .. code-tab:: toml - :caption: pyproject.toml + :caption: .coveragerc.toml - [tool.coverage.run] + # You can also use sections like [tool.coverage.run] + [run] omit = [ # omit anything in a .local directory anywhere "*/.local/*", @@ -139,7 +153,7 @@ current directory: # omit this single file utils/tirefire.py -.. [[[end]]] (sum: hK0nQ8wMeg) +.. [[[end]]] (sum: r3vqCJZ28r) The ``source``, ``include``, and ``omit`` values all work together to determine the source that will be measured. diff --git a/howto.txt b/howto.txt index 91be8f4d2..449522e0d 100644 --- a/howto.txt +++ b/howto.txt @@ -6,12 +6,15 @@ - start branch for release work $ make relbranch - check version number in coverage/version.py + - Update to a major/minor version bump if needed. - IF PRE-RELEASE: - edit to look like one of these: - version_info = (4, 0, 2, "alpha", 1) - version_info = (4, 0, 2, "beta", 1) - version_info = (4, 0, 2, "candidate", 1) - version_info = (4, 0, 2, "final", 0) + version_info = (4, 0, 2, "alpha", 1) + version_info = (4, 0, 2, "beta", 1) + version_info = (4, 0, 2, "candidate", 1) + version_info = (4, 0, 2, "final", 0) + - also: + _dev = 0 - IF NOT PRE-RELEASE: $ make release_version - Update source files with release facts, and get useful snippets: @@ -19,8 +22,6 @@ - Edit supported Python version numbers. Search for "PYVERSIONS". - Especially README.rst and doc/index.rst - Look over CHANGES.rst -- Update README.rst - - "New in x.y:" - Update docs - IF PRE-RELEASE: - Version of latest stable release in doc/index.rst @@ -70,8 +71,7 @@ - keep just the latest version of each x.y release, make the rest active but hidden. - pre-releases should be hidden - IF NOT PRE-RELEASE: - $ opvars - $ make update_rtd + $ opvars; make update_rtd $ deopvars - Once CI passes, merge the bump-version branch to main and push it $ gshipit diff --git a/igor.py b/igor.py index d25e75d9c..aa895737f 100644 --- a/igor.py +++ b/igor.py @@ -52,7 +52,7 @@ def do_show_env(): print(f" {env} = {os.environ[env]!r}") -def remove_extension(core): +def do_clean_for_core(core): """Remove the compiled C extension, no matter what its name.""" if core == "ctrace": @@ -159,7 +159,6 @@ def make_env_id(core): def run_tests(core, *runner_args): """The actual running of tests.""" - remove_extension(core) if "COVERAGE_TESTING" not in os.environ: os.environ["COVERAGE_TESTING"] = "True" print_banner(label_for_core(core)) @@ -183,9 +182,9 @@ def run_tests_with_coverage(core, *runner_args): # or the sys.path entries aren't created right? # There's an entry in "make clean" to get rid of this file. pth_dir = sysconfig.get_path("purelib") - pth_path = os.path.join(pth_dir, "zzz_metacov.pth") + pth_path = os.path.join(pth_dir, "a0_metacov.pth") with open(pth_path, "w", encoding="utf-8") as pth_file: - pth_file.write("import coverage; coverage.process_startup()\n") + pth_file.write("import coverage; coverage.process_startup(slug='meta')\n") suffix = f"{make_env_id(core)}_{platform.platform()}" os.environ["COVERAGE_METAFILE"] = os.path.abspath(".metacov." + suffix) @@ -211,7 +210,6 @@ def run_tests_with_coverage(core, *runner_args): if getattr(mod, "__file__", "??").startswith(covdir): covmods[name] = mod del sys.modules[name] - remove_extension(core) import coverage # pylint: disable=reimported @@ -452,7 +450,6 @@ def do_cheats(): repo = "coveragepy/coveragepy" github = f"https://github.com/{repo}" - egg = "egg=coverage==0.0" # to force a re-install print( f"https://coverage.readthedocs.io/en/{facts.ver}/changes.html#changes-{facts.anchor}", ) @@ -465,10 +462,10 @@ def do_cheats(): print("\n## To install this code:") if facts.branch == "main": - print(f"python3 -m pip install git+{github}#{egg}") + print(f"python3 -m pip install git+{github}") else: - print(f"python3 -m pip install git+{github}@{facts.branch}#{egg}") - print(f"python3 -m pip install git+{github}@{facts.sha[:20]}#{egg}") + print(f"python3 -m pip install git+{github}@{facts.branch}") + print(f"python3 -m pip install git+{github}@{facts.sha[:20]}") print("\n## To read this code on GitHub:") print(f"https://github.com/coveragepy/coveragepy/commit/{facts.sha}") diff --git a/metacov.ini b/metacov.ini index 668c7622f..330924735 100644 --- a/metacov.ini +++ b/metacov.ini @@ -75,6 +75,9 @@ exclude_lines = # Lines that will never be called, but satisfy the type checker pragma: never called + # Exclude an entire file. + \A(?s:.*# pragma: exclude file from coverage.*)\Z + partial_branches = pragma: part covered # A for-loop that always hits its break statement diff --git a/pyproject.toml b/pyproject.toml index 24993ee0f..776ed125e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,6 @@ addopts = [ "-n", "auto", "--dist", "loadgroup", "-p", "no:legacypath", - "-p", "no:terminalprogress", # https://github.com/pytest-dev/pytest/issues/13896 "--no-flaky-report", "-rfEX", "--failed-first", @@ -129,7 +128,6 @@ strict = true # "virtualenv_test": because of an expensive session-scoped fixture # "compare_test": Because of shared-file manipulations (~/tests/actual/testing) # "get_zip_bytes_test": no idea why this one fails if run on separate workers -# "needs_pth": tests that create .pth files in shared locations # How come these warnings are suppressed successfully here, but not in conftest.py?? filterwarnings = [ diff --git a/requirements/dev.pip b/requirements/dev.pip index b7e607a26..89da13969 100644 --- a/requirements/dev.pip +++ b/requirements/dev.pip @@ -1,6 +1,6 @@ # This file was autogenerated by uv via the following command: # make upgrade -astroid==4.0.1 +astroid==4.0.2 # via pylint attrs==25.4.0 # via scriv @@ -8,9 +8,9 @@ backports-tarfile==1.2.0 ; python_full_version < '3.12' and platform_machine != # via jaraco-context build==1.3.0 # via check-manifest -cachetools==6.2.1 +cachetools==6.2.2 # via tox -certifi==2025.10.5 +certifi==2025.11.12 # via requests cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' # via cryptography @@ -20,7 +20,7 @@ charset-normalizer==3.4.4 # via requests check-manifest==0.51 # via -r requirements/dev.in -click==8.3.0 +click==8.3.1 # via # click-log # scriv @@ -49,7 +49,7 @@ exceptiongroup==1.3.0 ; python_full_version < '3.11' # via # hypothesis # pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist filelock==3.20.0 # via @@ -59,7 +59,7 @@ flaky==3.8.1 # via -r requirements/pytest.in greenlet==3.2.4 # via -r requirements/dev.in -hypothesis==6.147.0 +hypothesis==6.148.1 # via -r requirements/pytest.in id==1.5.0 # via twine @@ -139,13 +139,13 @@ pygments==2.19.2 # pytest # readme-renderer # rich -pylint==4.0.2 +pylint==4.0.3 # via -r requirements/dev.in pyproject-api==1.10.0 # via tox pyproject-hooks==1.2.0 # via build -pytest==9.0.0 +pytest==9.0.1 # via # -r requirements/pytest.in # pytest-xdist @@ -170,11 +170,11 @@ rfc3986==2.0.0 # via twine rich==14.2.0 # via twine -ruff==0.14.4 +ruff==0.14.5 # via -r requirements/dev.in scriv==1.7.0 # via -r requirements/dev.in -secretstorage==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' +secretstorage==3.4.1 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' # via keyring setuptools==80.9.0 # via diff --git a/requirements/kit.pip b/requirements/kit.pip index a7350448c..722603000 100644 --- a/requirements/kit.pip +++ b/requirements/kit.pip @@ -12,7 +12,7 @@ build==1.3.0 # via # -r requirements/kit.in # cibuildwheel -certifi==2025.10.5 +certifi==2025.11.12 # via # cibuildwheel # requests @@ -20,7 +20,7 @@ cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and # via cryptography charset-normalizer==3.4.4 # via requests -cibuildwheel==3.2.1 +cibuildwheel==3.3.0 # via -r requirements/kit.in colorama==0.4.6 # via @@ -102,7 +102,7 @@ rfc3986==2.0.0 # via twine rich==14.2.0 # via twine -secretstorage==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' +secretstorage==3.4.1 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' # via keyring setuptools==80.9.0 # via -r requirements/kit.in diff --git a/requirements/light-threads.pip b/requirements/light-threads.pip index 054aeb7d4..d8af3747c 100644 --- a/requirements/light-threads.pip +++ b/requirements/light-threads.pip @@ -19,5 +19,5 @@ pycparser==2.23 ; implementation_name != 'PyPy' # via cffi zope-event==6.1 # via gevent -zope-interface==8.0.1 +zope-interface==8.1.1 # via gevent diff --git a/requirements/mypy.pip b/requirements/mypy.pip index a47695587..f7a61f05d 100644 --- a/requirements/mypy.pip +++ b/requirements/mypy.pip @@ -8,11 +8,11 @@ exceptiongroup==1.3.0 ; python_full_version < '3.11' # via # hypothesis # pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r requirements/pytest.in -hypothesis==6.147.0 +hypothesis==6.148.1 # via -r requirements/pytest.in iniconfig==2.3.0 # via pytest @@ -30,7 +30,7 @@ pygments==2.19.2 # via # -r requirements/pytest.in # pytest -pytest==9.0.0 +pytest==9.0.1 # via # -r requirements/pytest.in # pytest-xdist diff --git a/requirements/pytest.pip b/requirements/pytest.pip index b758c30e5..3962f6cc2 100644 --- a/requirements/pytest.pip +++ b/requirements/pytest.pip @@ -8,11 +8,11 @@ exceptiongroup==1.3.0 ; python_full_version < '3.11' # via # hypothesis # pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r requirements/pytest.in -hypothesis==6.147.0 +hypothesis==6.148.1 # via -r requirements/pytest.in iniconfig==2.3.0 # via pytest @@ -24,7 +24,7 @@ pygments==2.19.2 # via # -r requirements/pytest.in # pytest -pytest==9.0.0 +pytest==9.0.1 # via # -r requirements/pytest.in # pytest-xdist diff --git a/requirements/tox.pip b/requirements/tox.pip index 5be014b08..88e470322 100644 --- a/requirements/tox.pip +++ b/requirements/tox.pip @@ -1,6 +1,6 @@ # This file was autogenerated by uv via the following command: # make upgrade -cachetools==6.2.1 +cachetools==6.2.2 # via tox chardet==5.2.0 # via tox diff --git a/setup.py b/setup.py index 0183d0979..cd882fe55 100644 --- a/setup.py +++ b/setup.py @@ -6,122 +6,111 @@ # Setuptools setup for coverage.py # This file is used unchanged under all versions of Python. +import re import os +import os.path +import platform import sys +import textwrap +import zipfile -from setuptools import Extension, errors, setup -from setuptools.command.build_ext import build_ext # pylint: disable=wrong-import-order - -# Get or massage our metadata. We exec coverage/version.py so we can avoid -# importing the product code into setup.py. +from pathlib import Path +from typing import Any -# PYVERSIONS -classifiers = """\ -Environment :: Console -Intended Audience :: Developers -Operating System :: OS Independent -Programming Language :: Python -Programming Language :: Python :: 3 -Programming Language :: Python :: 3.10 -Programming Language :: Python :: 3.11 -Programming Language :: Python :: 3.12 -Programming Language :: Python :: 3.13 -Programming Language :: Python :: 3.14 -Programming Language :: Python :: 3.15 -Programming Language :: Python :: Free Threading :: 3 - Stable -Programming Language :: Python :: Implementation :: CPython -Programming Language :: Python :: Implementation :: PyPy -Topic :: Software Development :: Quality Assurance -Topic :: Software Development :: Testing -""" - -cov_ver_py = os.path.join(os.path.split(__file__)[0], "coverage/version.py") -with open(cov_ver_py, encoding="utf-8") as version_file: - # __doc__ will be overwritten by version.py. - doc = __doc__ - # Keep pylint happy. - __version__ = __url__ = version_info = "" - # Execute the code in version.py. - exec(compile(version_file.read(), cov_ver_py, "exec", dont_inherit=True)) - -with open("README.rst", encoding="utf-8") as readme: - readme_text = readme.read() - -temp_url = __url__.replace("readthedocs", "@@") -assert "@@" not in readme_text -long_description = ( - readme_text.replace("https://coverage.readthedocs.io/en/latest", temp_url) - .replace("https://coverage.readthedocs.io", temp_url) - .replace("@@", "readthedocs") -) +from setuptools import Extension, errors, setup +from setuptools.command.build_ext import build_ext +from setuptools.command.editable_wheel import editable_wheel + + +def get_version_data() -> dict[str, Any]: + """Get the globals from coverage/version.py.""" + # We exec coverage/version.py so we can avoid importing the product code into setup.py. + module_globals: dict[str, Any] = {} + cov_ver_py = os.path.join(os.path.split(__file__)[0], "coverage/version.py") + with open(cov_ver_py, encoding="utf-8") as version_file: + # Execute the code in version.py. + exec(compile(version_file.read(), cov_ver_py, "exec", dont_inherit=True), module_globals) + return module_globals + + +def get_long_description(url: str) -> str: + """Massage README.rst to get the long description.""" + with open("README.rst", encoding="utf-8") as readme: + readme_text = readme.read() + + url = url.replace("readthedocs", "@@") + assert "@@" not in readme_text + long_description = ( + readme_text.replace("https://coverage.readthedocs.io/en/latest", url) + .replace("https://coverage.readthedocs.io", url) + .replace("@@", "readthedocs") + ) + return long_description -with open("CONTRIBUTORS.txt", "rb") as contributors: - paras = contributors.read().split(b"\n\n") - num_others = len(paras[-1].splitlines()) - num_others += 1 # Count Gareth Rees, who is mentioned in the top paragraph. -classifier_list = classifiers.splitlines() +def count_contributors() -> int: + """Read CONTRIBUTORS.txt to count how many people have helped.""" + with open("CONTRIBUTORS.txt", "rb") as contributors: + paras = contributors.read().split(b"\n\n") + num_others = len(paras[-1].splitlines()) + num_others += 1 # Count Gareth Rees, who is mentioned in the top paragraph. + return num_others -if version_info[3] == "alpha": - devstat = "3 - Alpha" -elif version_info[3] in ["beta", "candidate"]: - devstat = "4 - Beta" -else: - assert version_info[3] == "final" - devstat = "5 - Production/Stable" -classifier_list.append(f"Development Status :: {devstat}") -# Create the keyword arguments for setup() +# PYVERSIONS +CLASSIFIERS = textwrap.dedent("""\ + Development Status :: 5 - Production/Stable + Environment :: Console + Intended Audience :: Developers + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 + Programming Language :: Python :: 3.15 + Programming Language :: Python :: Free Threading :: 3 - Stable + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: PyPy + Topic :: Software Development :: Quality Assurance + Topic :: Software Development :: Testing +""").splitlines() + + +# The names of .pth files matter because they are read in lexicographic order. +# Editable installs work because of `__editable__*.pth` files, so we need our +# .pth files to come after those. But we want ours to be earlyish in the +# sequence, so we start with `a`. The metacov .pth file should come before the +# coverage .pth file, so we use `a0_metacov.pth` and `a1_coverage.pth`. +PTH_NAME = "a1_coverage.pth" + + +def make_pth_file() -> None: + """Make the packaged .pth file used for measuring subprocess coverage.""" + + with open("coverage/pth_file.py", encoding="utf-8") as f: + code = f.read() + + code = re.sub(r"\s*#.*\n", "\n", code) + code = code.replace(" ", " ") + + # `import sys` is needed because .pth files are executed only if they start + # with `import `. + with open(PTH_NAME, "w", encoding="utf-8") as pth_file: + pth_file.write(f"import sys; exec({code!r})\n") + + +class EditableWheelWithPth(editable_wheel): # type: ignore[misc] + """Override the editable_wheel command to insert our .pth file into the wheel.""" + + def run(self) -> None: + super().run() + for whl in Path(self.dist_dir).glob("*editable*.whl"): + with zipfile.ZipFile(whl, "a") as zf: + zf.write(PTH_NAME, PTH_NAME) -setup_args = dict( - name="coverage", - version=__version__, - packages=[ - "coverage", - ], - package_data={ - "coverage": [ - "htmlfiles/*.*", - "py.typed", - ], - }, - entry_points={ - # Install a script as "coverage", and as "coverage3", and as - # "coverage-3.7" (or whatever). - "console_scripts": [ - "coverage = coverage.cmdline:main", - "coverage%d = coverage.cmdline:main" % sys.version_info[:1], - "coverage-%d.%d = coverage.cmdline:main" % sys.version_info[:2], - ], - }, - extras_require={ - # Enable pyproject.toml support. - "toml": ['tomli; python_full_version<="3.11.0a6"'], - }, - # We need to get HTML assets from our htmlfiles directory. - zip_safe=False, - author=f"Ned Batchelder and {num_others} others", - author_email="ned@nedbatchelder.com", - description=doc, - long_description=long_description, - long_description_content_type="text/x-rst", - keywords="code coverage testing", - license="Apache-2.0", - license_files=["LICENSE.txt"], - classifiers=classifier_list, - url="https://github.com/coveragepy/coveragepy", - project_urls={ - "Documentation": __url__, - "Funding": ( - "https://tidelift.com/subscription/pkg/pypi-coverage" - + "?utm_source=pypi-coverage&utm_medium=referral&utm_campaign=pypi" - ), - "Issues": "https://github.com/coveragepy/coveragepy/issues", - "Mastodon": "https://hachyderm.io/@coveragepy", - "Mastodon (nedbat)": "https://hachyderm.io/@nedbat", - }, - python_requires=">=3.10", # minimum of PYVERSIONS -) # A replacement for the build_ext command which raises a single exception # if the build fails, so we can fallback nicely. @@ -139,22 +128,22 @@ class BuildFailed(Exception): """Raise this to indicate the C extension wouldn't build.""" - def __init__(self): + def __init__(self) -> None: Exception.__init__(self) self.cause = sys.exc_info()[1] # work around py 2/3 different syntax -class ve_build_ext(build_ext): +class ve_build_ext(build_ext): # type: ignore[misc] """Build C extensions, but fail with a straightforward exception.""" - def run(self): + def run(self) -> None: """Wrap `run` with `BuildFailed`.""" try: build_ext.run(self) except errors.PlatformError as exc: raise BuildFailed() from exc - def build_extension(self, ext): + def build_extension(self, ext: Any) -> None: """Wrap `build_extension` with `BuildFailed`.""" if self.compiler.compiler_type == "msvc": ext.extra_compile_args = (ext.extra_compile_args or []) + [ @@ -174,6 +163,67 @@ def build_extension(self, ext): raise +version_data = get_version_data() +make_pth_file() + + +# Create the keyword arguments for setup() + +setup_args = dict( + name="coverage", + version=version_data["__version__"], + packages=[ + "coverage", + ], + package_data={ + "coverage": [ + "htmlfiles/*.*", + "py.typed", + f"../{PTH_NAME}", + ], + }, + entry_points={ + "console_scripts": [ + # Install a script as "coverage". + "coverage = coverage.cmdline:main", + # And as "coverage3", and as "coverage-3.7" (or whatever), but deprecated. + "coverage%d = coverage.cmdline:main_deprecated" % sys.version_info[:1], + "coverage-%d.%d = coverage.cmdline:main_deprecated" % sys.version_info[:2], + ], + }, + extras_require={ + # Enable pyproject.toml support. + "toml": ['tomli; python_full_version<="3.11.0a6"'], + }, + cmdclass={ + "build_ext": ve_build_ext, + "editable_wheel": EditableWheelWithPth, + }, + # We need to get HTML assets from our htmlfiles directory. + zip_safe=False, + author=f"Ned Batchelder and {count_contributors()} others", + author_email="ned@nedbatchelder.com", + description=__doc__, + long_description=get_long_description(url=version_data["__url__"]), + long_description_content_type="text/x-rst", + keywords="code coverage testing", + license="Apache-2.0", + license_files=["LICENSE.txt"], + classifiers=CLASSIFIERS, + url="https://github.com/coveragepy/coveragepy", + project_urls={ + "Documentation": version_data["__url__"], + "Funding": ( + "https://tidelift.com/subscription/pkg/pypi-coverage" + + "?utm_source=pypi-coverage&utm_medium=referral&utm_campaign=pypi" + ), + "Issues": "https://github.com/coveragepy/coveragepy/issues", + "Mastodon": "https://hachyderm.io/@coveragepy", + "Mastodon (nedbat)": "https://hachyderm.io/@nedbat", + }, + python_requires=">=3.10", # minimum of PYVERSIONS +) + # There are a few reasons we might not be able to compile the C extension. # Figure out if we should attempt the C extension or not. Define # COVERAGE_DISABLE_EXTENSION in the build environment to explicitly disable the @@ -181,7 +231,7 @@ def build_extension(self, ext): compile_extension = os.getenv("COVERAGE_DISABLE_EXTENSION", None) is None -if "__pypy__" in sys.builtin_module_names: +if platform.python_implementation() == "PyPy": # Pypy can't compile C extensions compile_extension = False @@ -199,14 +249,11 @@ def build_extension(self, ext): ], ), ], - cmdclass={ - "build_ext": ve_build_ext, - }, ), ) -def main(): +def main() -> None: """Actually invoke setup() with the arguments we built above.""" # For a variety of reasons, it might not be possible to install the C # extension. Try it with, and if it fails, try it without. diff --git a/tests/conftest.py b/tests/conftest.py index da6803049..6c64c291c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,6 @@ import pytest from coverage.files import set_relative_directory -from coverage.patch import create_pth_files from tests import testenv @@ -97,15 +96,3 @@ def force_local_pyc_files() -> None: # For some tests, we need .pyc files written in the current directory, # so override any local setting. sys.pycache_prefix = None - - -# Give this an underscored name so pylint won't complain when we use the fixture. -@pytest.fixture(name="_create_pth_file") -def create_pth_file_fixture() -> Iterable[None]: - """Create and clean up a .pth file for tests that need it for subprocesses.""" - pth_files = create_pth_files() - try: - yield - finally: - for p in pth_files: - p.unlink() diff --git a/tests/gold/html/a/a_py.html b/tests/gold/html/a/a_py.html index 1b294759e..4f865f070 100644 --- a/tests/gold/html/a/a_py.html +++ b/tests/gold/html/a/a_py.html @@ -4,8 +4,8 @@ Coverage for a.py: 67% - - + +
@@ -64,8 +64,8 @@

^ index     » next       - coverage.py v7.6.0a0.dev1, - created at 2024-07-10 12:20 -0400 + coverage.py v7.11.4a0.dev1, + created at 2025-11-15 23:12 +0900