Skip to content

Commit 49a1992

Browse files
committed
fix: include a .pth file in the distribution
- metacov now has to run with -n0, I'm not sure why - The extension has to be cleaned in a separate step, since it can be in use on Windows during the test step
1 parent b48eca9 commit 49a1992

File tree

12 files changed

+115
-133
lines changed

12 files changed

+115
-133
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ doc/sample_html_beta
4949

5050
# Build intermediaries.
5151
tmp
52+
zzz_coverage.pth
5253

5354
# OS junk
5455
.DS_Store

CHANGES.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ upgrading your version of coverage.py.
2323
Unreleased
2424
----------
2525

26-
Nothing yet.
26+
- Fix: coverage.py now includes a permanent .pth file in the distribution which
27+
is installed with the code. This fixes `issue 2084`_: failure to patch for
28+
subprocess measurement when site-packages is not writable.
29+
30+
.. _issue 2084: https://github.com/coveragepy/coveragepy/issues/2084
2731

2832

2933
.. start-releases

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ clean: debug_clean _clean_platform ## Remove artifacts of test execution, instal
4242
@rm -f tests/covmain.zip tests/zipmods.zip tests/zip1.zip
4343
@rm -rf doc/_build doc/_spell doc/sample_html_beta
4444
@rm -rf tmp
45+
@rm -rf zzz_coverage.pth
4546
@rm -rf .*cache */.*cache */*/.*cache */*/*/.*cache .hypothesis
4647
@rm -rf tests/actual
4748
@-make -C tests/gold/html clean
@@ -215,7 +216,7 @@ relcommit2: #: Commit the latest sample HTML report (see howto.txt).
215216
git add doc/sample_html
216217
git commit -am "docs: sample HTML for $$(python setup.py --version)"
217218

218-
kit: ## Make the source distribution.
219+
kit: ## Make the source distribution and one wheel
219220
python -m build
220221

221222
pypi_upload: ## Upload the built distributions to PyPI.
@@ -235,7 +236,7 @@ kit_local:
235236
# don't go crazy trying to figure out why our new code isn't installing.
236237
find ~/Library/Caches/pip/wheels -name 'coverage-*' -delete
237238

238-
build_kits: ## Trigger GitHub to build kits.
239+
build_kits: ## Trigger GitHub to build all the distributions.
239240
python ci/trigger_action.py $(REPO_OWNER) build-kits
240241

241242
tag: #: Make a git tag with the version number (see howto.txt).

coverage/control.py

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,6 @@ def __init__( # pylint: disable=too-many-arguments
288288
self._no_warn_slugs: set[str] = set()
289289
self._messages = messages
290290

291-
# If we're invoked from a .pth file, we shouldn't try to make another one.
292-
self._make_pth_file = True
293-
294291
# A record of all the warnings that have been issued.
295292
self._warnings: list[str] = []
296293

@@ -719,7 +716,7 @@ def start(self) -> None:
719716
if self._auto_load:
720717
self.load()
721718

722-
apply_patches(self, self.config, self._debug, make_pth_file=self._make_pth_file)
719+
apply_patches(self, self.config, self._debug)
723720

724721
self._collector.start()
725722
self._started = True
@@ -1429,19 +1426,51 @@ def plugin_info(plugins: list[Any]) -> list[str]:
14291426
)(Coverage)
14301427

14311428

1432-
def process_startup(*, force: bool = False) -> Coverage | None:
1429+
def process_startup(
1430+
*,
1431+
force: bool = False,
1432+
slug: str = "default", # pylint: disable=unused-argument
1433+
) -> Coverage | None:
14331434
"""Call this at Python start-up to perhaps measure coverage.
14341435
1435-
If the environment variable COVERAGE_PROCESS_START is defined, coverage
1436-
measurement is started. The value of the variable is the config file
1437-
to use.
1436+
Coverage is started if one of these environment variables is defined:
1437+
1438+
- COVERAGE_PROCESS_START: the config file to use.
1439+
- COVERAGE_PROCESS_CONFIG: the config data to use, a string produced by
1440+
CoverageConfig.serialize, prefixed by ":data:".
1441+
1442+
If one of these is defined, it's used to get the coverage configuration,
1443+
and coverage is started.
14381444
14391445
For details, see https://coverage.readthedocs.io/en/latest/subprocess.html.
14401446
14411447
Returns the :class:`Coverage` instance that was started, or None if it was
14421448
not started by this call.
14431449
14441450
"""
1451+
# This function can be called more than once in a process, for a few
1452+
# reasons.
1453+
#
1454+
# 1) We install a .pth file in multiple places reported by the site module,
1455+
# so this function can be called more than once even in simple
1456+
# situations.
1457+
#
1458+
# 2) In some virtualenv configurations the same directory is visible twice
1459+
# in sys.path. This means that the .pth file will be found twice and
1460+
# executed twice, executing this function twice.
1461+
# https://github.com/coveragepy/coveragepy/issues/340 has more details.
1462+
#
1463+
# We set a global flag (an attribute on this function) to indicate that
1464+
# coverage.py has already been started, so we can avoid starting it twice.
1465+
1466+
if not force and hasattr(process_startup, "coverage"):
1467+
# We've annotated this function before, so we must have already
1468+
# auto-started coverage.py in this process. Nothing to do.
1469+
return None
1470+
1471+
# Now check for the environment variables that request coverage. If they
1472+
# aren't set, do nothing.
1473+
14451474
config_data = os.getenv("COVERAGE_PROCESS_CONFIG")
14461475
cps = os.getenv("COVERAGE_PROCESS_START")
14471476
if config_data is not None:
@@ -1452,27 +1481,12 @@ def process_startup(*, force: bool = False) -> Coverage | None:
14521481
# No request for coverage, nothing to do.
14531482
return None
14541483

1455-
# This function can be called more than once in a process. This happens
1456-
# because some virtualenv configurations make the same directory visible
1457-
# twice in sys.path. This means that the .pth file will be found twice,
1458-
# and executed twice, executing this function twice. We set a global
1459-
# flag (an attribute on this function) to indicate that coverage.py has
1460-
# already been started, so we can avoid doing it twice.
1461-
#
1462-
# https://github.com/coveragepy/coveragepy/issues/340 has more details.
1463-
1464-
if not force and hasattr(process_startup, "coverage"):
1465-
# We've annotated this function before, so we must have already
1466-
# auto-started coverage.py in this process. Nothing to do.
1467-
return None
1468-
14691484
cov = Coverage(config_file=config_file)
14701485
process_startup.coverage = cov # type: ignore[attr-defined]
14711486
cov._warn_no_data = False
14721487
cov._warn_unimported_source = False
14731488
cov._warn_preimported_source = False
14741489
cov._auto_save = True
1475-
cov._make_pth_file = False
14761490
cov.start()
14771491

14781492
return cov
@@ -1482,7 +1496,7 @@ def _after_fork_in_child() -> None:
14821496
"""Used by patch=fork in the child process to restart coverage."""
14831497
if cov := Coverage.current():
14841498
cov.stop()
1485-
process_startup(force=True)
1499+
process_startup(force=True, slug="fork")
14861500

14871501

14881502
def _prevent_sub_process_measurement() -> None:

coverage/patch.py

Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,12 @@
55

66
from __future__ import annotations
77

8-
import atexit
98
import contextlib
109
import os
11-
import site
12-
from pathlib import Path
1310
from typing import TYPE_CHECKING, Any, NoReturn
1411

1512
from coverage import env
16-
from coverage.debug import NoDebugging, DevNullDebug
13+
from coverage.debug import DevNullDebug
1714
from coverage.exceptions import ConfigError, CoverageException
1815

1916
if TYPE_CHECKING:
@@ -26,8 +23,6 @@ def apply_patches(
2623
cov: Coverage,
2724
config: CoverageConfig,
2825
debug: TDebugCtl,
29-
*,
30-
make_pth_file: bool = True,
3126
) -> None:
3227
"""Apply invasive patches requested by `[run] patch=`."""
3328
debug = debug if debug.should("patch") else DevNullDebug()
@@ -43,7 +38,7 @@ def apply_patches(
4338
_patch_fork(debug)
4439

4540
case "subprocess":
46-
_patch_subprocess(config, debug, make_pth_file)
41+
_patch_subprocess(config, debug)
4742

4843
case _:
4944
raise ConfigError(f"Unknown patch {patch!r}")
@@ -116,55 +111,8 @@ def _patch_fork(debug: TDebugCtl) -> None:
116111
os.register_at_fork(after_in_child=_after_fork_in_child)
117112

118113

119-
def _patch_subprocess(config: CoverageConfig, debug: TDebugCtl, make_pth_file: bool) -> None:
114+
def _patch_subprocess(config: CoverageConfig, debug: TDebugCtl) -> None:
120115
"""Write .pth files and set environment vars to measure subprocesses."""
121116
debug.write("Patching subprocess")
122-
123-
if make_pth_file:
124-
pth_files = create_pth_files(debug)
125-
126-
def delete_pth_files() -> None:
127-
for p in pth_files:
128-
debug.write(f"Deleting subprocess .pth file: {str(p)!r}")
129-
p.unlink(missing_ok=True)
130-
131-
atexit.register(delete_pth_files)
132117
assert config.config_file is not None
133118
os.environ["COVERAGE_PROCESS_CONFIG"] = config.serialize()
134-
135-
136-
# Writing .pth files is not obvious. On Windows, getsitepackages() returns two
137-
# directories. A .pth file in the first will be run, but coverage isn't
138-
# importable yet. We write into all the places we can, but with defensive
139-
# import code.
140-
141-
PTH_CODE = """\
142-
try:
143-
import coverage
144-
except:
145-
pass
146-
else:
147-
coverage.process_startup()
148-
"""
149-
150-
PTH_TEXT = f"import sys; exec({PTH_CODE!r})\n"
151-
152-
153-
def create_pth_files(debug: TDebugCtl = NoDebugging()) -> list[Path]:
154-
"""Create .pth files for measuring subprocesses."""
155-
pth_files = []
156-
for pth_dir in site.getsitepackages():
157-
pth_file = Path(pth_dir) / f"subcover_{os.getpid()}.pth"
158-
try:
159-
if debug.should("patch"):
160-
debug.write(f"Writing subprocess .pth file: {str(pth_file)!r}")
161-
pth_file.write_text(PTH_TEXT, encoding="utf-8")
162-
except OSError as oserr: # pragma: cant happen
163-
if debug.should("patch"):
164-
debug.write(f"Couldn't write subprocess .pth file: {oserr}")
165-
continue
166-
else:
167-
pth_files.append(pth_file)
168-
if debug.should("patch"):
169-
debug.write(f"Subprocess .pth files created: {', '.join(map(str, pth_files)) or '-none-'}")
170-
return pth_files

coverage/pth_file.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
2+
# For details: https://github.com/coveragepy/coveragepy/blob/main/NOTICE.txt
3+
4+
# pylint: disable=missing-module-docstring
5+
# pragma: exclude file from coverage
6+
# This will become the .pth file for subprocesses.
7+
8+
import os
9+
10+
if os.getenv("COVERAGE_PROCESS_START") or os.getenv("COVERAGE_PROCESS_CONFIG"):
11+
try:
12+
import coverage
13+
except: # pylint: disable=bare-except
14+
pass
15+
else:
16+
coverage.process_startup(slug="pth")

igor.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def do_show_env():
5252
print(f" {env} = {os.environ[env]!r}")
5353

5454

55-
def remove_extension(core):
55+
def do_clean_for_core(core):
5656
"""Remove the compiled C extension, no matter what its name."""
5757

5858
if core == "ctrace":
@@ -159,7 +159,6 @@ def make_env_id(core):
159159

160160
def run_tests(core, *runner_args):
161161
"""The actual running of tests."""
162-
remove_extension(core)
163162
if "COVERAGE_TESTING" not in os.environ:
164163
os.environ["COVERAGE_TESTING"] = "True"
165164
print_banner(label_for_core(core))
@@ -183,9 +182,9 @@ def run_tests_with_coverage(core, *runner_args):
183182
# or the sys.path entries aren't created right?
184183
# There's an entry in "make clean" to get rid of this file.
185184
pth_dir = sysconfig.get_path("purelib")
186-
pth_path = os.path.join(pth_dir, "zzz_metacov.pth")
185+
pth_path = os.path.join(pth_dir, "zzy_metacov.pth")
187186
with open(pth_path, "w", encoding="utf-8") as pth_file:
188-
pth_file.write("import coverage; coverage.process_startup()\n")
187+
pth_file.write("import coverage; coverage.process_startup(slug='meta')\n")
189188

190189
suffix = f"{make_env_id(core)}_{platform.platform()}"
191190
os.environ["COVERAGE_METAFILE"] = os.path.abspath(".metacov." + suffix)
@@ -211,7 +210,6 @@ def run_tests_with_coverage(core, *runner_args):
211210
if getattr(mod, "__file__", "??").startswith(covdir):
212211
covmods[name] = mod
213212
del sys.modules[name]
214-
remove_extension(core)
215213

216214
import coverage # pylint: disable=reimported
217215

metacov.ini

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ branch = true
1111
data_file = ${COVERAGE_METAFILE-.metacov}
1212
parallel = true
1313
relative_files = true
14-
source =
15-
${COVERAGE_HOME-.}/coverage
16-
${COVERAGE_HOME-.}/tests
14+
include =
15+
${COVERAGE_HOME-.}/coverage/*
16+
${COVERAGE_HOME-.}/tests/*
17+
**/coverage/*
1718
# $set_env.py: COVERAGE_DYNCTX - Set to 'test_function' for who-tests-what
1819
dynamic_context = ${COVERAGE_DYNCTX-none}
1920
# $set_env.py: COVERAGE_CONTEXT - Static context for this run (or $ENV_VAR like $TOX_ENV_NAME)
@@ -75,6 +76,9 @@ exclude_lines =
7576
# Lines that will never be called, but satisfy the type checker
7677
pragma: never called
7778

79+
# Exclude an entire file.
80+
\A(?s:.*# pragma: exclude file from coverage.*)\Z
81+
7882
partial_branches =
7983
pragma: part covered
8084
# A for-loop that always hits its break statement

setup.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
# Setuptools setup for coverage.py
77
# This file is used unchanged under all versions of Python.
88

9+
import re
910
import os
11+
import os.path
12+
import site
1013
import sys
1114

1215
from setuptools import Extension, errors, setup
@@ -71,6 +74,24 @@
7174
devstat = "5 - Production/Stable"
7275
classifier_list.append(f"Development Status :: {devstat}")
7376

77+
78+
def do_make_pth():
79+
"""Make the packaged .pth file used for measuring subprocess coverage."""
80+
81+
with open("coverage/pth_file.py", encoding="utf-8") as f:
82+
code = f.read()
83+
84+
code = re.sub(r"\s*#.*\n", "\n", code)
85+
code = code.replace(" ", " ")
86+
87+
# `import sys` is needed because .pth files are executed only if they start
88+
# with `import `.
89+
with open("zzz_coverage.pth", "w", encoding="utf-8") as pth_file:
90+
pth_file.write(f"import sys; exec({code!r})\n")
91+
92+
93+
do_make_pth()
94+
7495
# Create the keyword arguments for setup()
7596

7697
setup_args = dict(
@@ -85,6 +106,15 @@
85106
"py.typed",
86107
],
87108
},
109+
data_files=[
110+
# Write the .pth file into all site-packages directories. Different
111+
# platforms read different directories for .pth files, so put it
112+
# everywhere. The process_startup function called in the .pth file
113+
# does nothing the second time it's called, so it's fine to have
114+
# multiple .pth files.
115+
(os.path.relpath(sp, sys.prefix), ["zzz_coverage.pth"])
116+
for sp in site.getsitepackages()
117+
],
88118
entry_points={
89119
# Install a script as "coverage", and as "coverage3", and as
90120
# "coverage-3.7" (or whatever).

0 commit comments

Comments
 (0)