Skip to content

Commit 6154fda

Browse files
committed
feat: patch=execv
wip: we need this to make it work? wip: this has to make it work, right?
1 parent 787e5c4 commit 6154fda

File tree

5 files changed

+152
-20
lines changed

5 files changed

+152
-20
lines changed

CHANGES.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ Unreleased
3737
<python:os.execl>` or :func:`spawnv <python:os.spawnl>` family of
3838
functions. Closes old `issue 367`_ and duplicate `issue 378`_.
3939

40+
- ``patch = execv`` adjusts the :func:`execv <python:os.execl>` family of
41+
functions to save coverage data before ending the current program and
42+
starting the next. Not available on Windows. Closes `issue 43`_ after 15
43+
years!
44+
4045
- The HTML report now dimly colors subsequent lines in multi-line statements.
4146
They used to have no color. This gives a better indication of the amount of
4247
code missing in the report. Closes `issue 1308`_.
@@ -65,6 +70,7 @@ Unreleased
6570
- In the very unusual situation of not having a current frame, coverage no
6671
longer crashes when using the sysmon core, fixing `issue 2005`_.
6772

73+
.. _issue 43: https://github.com/nedbat/coveragepy/issues/43
6874
.. _issue 310: https://github.com/nedbat/coveragepy/issues/310
6975
.. _issue 312: https://github.com/nedbat/coveragepy/issues/312
7076
.. _issue 367: https://github.com/nedbat/coveragepy/issues/367

coverage/patch.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
import atexit
99
import os
1010

11-
from typing import Callable, NoReturn, TYPE_CHECKING
11+
from typing import Any, Callable, NoReturn, TYPE_CHECKING
1212

13-
from coverage.exceptions import ConfigError
13+
from coverage import env
14+
from coverage.exceptions import ConfigError, CoverageException
1415
from coverage.files import create_pth_file
1516

1617
if TYPE_CHECKING:
@@ -21,17 +22,60 @@
2122
def apply_patches(cov: Coverage, config: CoverageConfig) -> None:
2223
"""Apply invasive patches requested by `[run] patch=`."""
2324

24-
for patch in set(config.patch):
25+
for patch in sorted(set(config.patch)):
2526
if patch == "_exit":
26-
def make_patch(old_os_exit: Callable[[int], NoReturn]) -> Callable[[int], NoReturn]:
27-
def _coverage_os_exit_patch(status: int) -> NoReturn:
27+
28+
def make_exit_patch(
29+
old_exit: Callable[[int], NoReturn],
30+
) -> Callable[[int], NoReturn]:
31+
def coverage_os_exit_patch(status: int) -> NoReturn:
2832
try:
2933
cov.save()
30-
except: # pylint: disable=bare-except
34+
except: # pylint: disable=bare-except
3135
pass
32-
old_os_exit(status)
33-
return _coverage_os_exit_patch
34-
os._exit = make_patch(os._exit) # type: ignore[assignment]
36+
old_exit(status)
37+
38+
return coverage_os_exit_patch
39+
40+
os._exit = make_exit_patch(os._exit) # type: ignore[assignment]
41+
42+
elif patch == "execv":
43+
if env.WINDOWS:
44+
raise CoverageException("patch=execv isn't supported yet on Windows.")
45+
46+
def make_execv_patch(fname: str, old_execv: Any) -> Any:
47+
def coverage_execv_patch(*args: Any, **kwargs: Any) -> Any:
48+
try:
49+
cov.save()
50+
except: # pylint: disable=bare-except
51+
pass
52+
53+
if fname.endswith("e"):
54+
# Assume the `env` argument is passed positionally.
55+
new_env = args[-1]
56+
# Pass our environment variable in the new environment.
57+
new_env["COVERAGE_PROCESS_START"] = config.config_file
58+
if env.TESTING:
59+
# The subprocesses need to use the same core as the main process.
60+
new_env["COVERAGE_CORE"] = os.getenv("COVERAGE_CORE")
61+
62+
# When testing locally, we need to honor the pyc file location
63+
# or they get written to the .tox directories and pollute the
64+
# next run with a different core.
65+
if (
66+
cache_prefix := os.getenv("PYTHONPYCACHEPREFIX")
67+
) is not None:
68+
new_env["PYTHONPYCACHEPREFIX"] = cache_prefix
69+
70+
# Without this, it fails on PyPy and Ubuntu.
71+
new_env["PATH"] = os.getenv("PATH")
72+
old_execv(*args, **kwargs)
73+
74+
return coverage_execv_patch
75+
76+
# All the exec* and spawn* functions eventually call execv or execve.
77+
os.execv = make_execv_patch("execv", os.execv)
78+
os.execve = make_execv_patch("execve", os.execve)
3579

3680
elif patch == "subprocess":
3781
pth_file = create_pth_file()

doc/config.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,11 @@ Available patches:
459459
and will require combining data files before reporting. See
460460
:ref:`cmd_combine` for more details.
461461

462+
- ``execv``: The :func:`execv <python:os.execl>` family of functions end the
463+
current program without giving coverage a chance to write collected data.
464+
This patch adjusts those functions to save the data before starting the next
465+
executable. Not available on Windows.
466+
462467
.. versionadded:: 7.10
463468

464469

doc/subprocess.rst

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
.. _subprocess:
55
.. _processes:
66

7-
======================
8-
Coordinating processes
9-
======================
7+
==================
8+
Managing processes
9+
==================
1010

1111
For coverage measurement to work properly, coverage has to be involved at the
1212
very beginning and very end of the Python process. There a number of ways to
@@ -71,7 +71,8 @@ families of functions start new execution, either replacing the current process
7171
or starting a new subprocess. The ``exec*e`` and ``spawn*e`` variants take a
7272
new set of environment variables to use for the new program. To start coverage
7373
measurement, the ``COVERAGE_PROCESS_START`` value must be copied from the
74-
current environment into the new environment.
74+
current environment into the new environment or set. It should be the absolute
75+
path to the coverage configuration file to use.
7576

7677

7778
Ending processes
@@ -95,6 +96,14 @@ If your program ends by calling :func:`python:os._exit` (or a library does),
9596
you can patch that function with :ref:`patch = _exit <config_run_patch>` to
9697
give coverage a chance to write data before the process exits.
9798

99+
execv
100+
.....
101+
102+
If your program ends by calling one of the :func:`execv <python:os.execl>`
103+
functions, using :ref:`patch = execv <config_run_patch>` will let coverage
104+
write its data before the execution begins.
105+
106+
98107
Long-running processes
99108
......................
100109

tests/test_process.py

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import csv
99
import glob
10+
import itertools
1011
import os
1112
import os.path
1213
import platform
@@ -693,9 +694,8 @@ def test_module_name(self) -> None:
693694

694695
@pytest.mark.skipif(env.WINDOWS, reason="This test is not for Windows")
695696
def test_save_signal_usr1(self) -> None:
696-
test_file = "dummy_hello.py"
697697
self.assert_doesnt_exist(".coverage")
698-
self.make_file(test_file, """\
698+
self.make_file("dummy_hello.py", """\
699699
import os
700700
import signal
701701
@@ -706,7 +706,7 @@ def test_save_signal_usr1(self) -> None:
706706
print("Done and goodbye")
707707
""")
708708
out = self.run_command(
709-
f"coverage run --save-signal=USR1 {test_file}",
709+
"coverage run --save-signal=USR1 dummy_hello.py",
710710
status=-signal.SIGKILL,
711711
)
712712
# `startswith` because on Linux it also prints "Killed"
@@ -1321,6 +1321,7 @@ def test_removing_directory_with_error(self) -> None:
13211321

13221322

13231323
@pytest.mark.skipif(env.METACOV, reason="Can't test subprocess pth file during metacoverage")
1324+
@pytest.mark.xdist_group(name="needs_pth")
13241325
class ProcessStartupTest(CoverageTest):
13251326
"""Test that we can measure coverage in subprocesses."""
13261327

@@ -1340,7 +1341,6 @@ def setUp(self) -> None:
13401341
f.close()
13411342
""")
13421343

1343-
@pytest.mark.xdist_group(name="needs_pth")
13441344
def test_patch_subprocess(self) -> None:
13451345
self.make_file(".coveragerc", """\
13461346
[run]
@@ -1349,12 +1349,11 @@ def test_patch_subprocess(self) -> None:
13491349
self.run_command("coverage run main.py")
13501350
self.run_command("coverage combine")
13511351
self.assert_exists(".coverage")
1352-
data = coverage.CoverageData(".coverage")
1352+
data = coverage.CoverageData()
13531353
data.read()
13541354
assert line_counts(data)["main.py"] == 3
13551355
assert line_counts(data)["sub.py"] == 3
13561356

1357-
@pytest.mark.xdist_group(name="needs_pth")
13581357
def test_subprocess_with_pth_files(self, _create_pth_file: None) -> None:
13591358
# An existing data file should not be read when a subprocess gets
13601359
# measured automatically. Create the data file here with bogus data in
@@ -1379,7 +1378,6 @@ def test_subprocess_with_pth_files(self, _create_pth_file: None) -> None:
13791378
data.read()
13801379
assert line_counts(data)['sub.py'] == 3
13811380

1382-
@pytest.mark.xdist_group(name="needs_pth")
13831381
def test_subprocess_with_pth_files_and_parallel(self, _create_pth_file: None) -> None:
13841382
# https://github.com/nedbat/coveragepy/issues/492
13851383
self.make_file("coverage.ini", """\
@@ -1410,6 +1408,76 @@ def test_subprocess_with_pth_files_and_parallel(self, _create_pth_file: None) ->
14101408
assert len(data_files) == 1, msg
14111409

14121410

1411+
@pytest.mark.skipif(env.METACOV, reason="Can't test subprocess pth file during metacoverage")
1412+
@pytest.mark.skipif(env.WINDOWS, reason="patch=execv isn't supported on Windows")
1413+
@pytest.mark.xdist_group(name="needs_pth")
1414+
class ExecvTest(CoverageTest):
1415+
"""Test that we can measure coverage in subprocesses."""
1416+
1417+
@pytest.mark.parametrize("fname",
1418+
[base + suffix for base, suffix in itertools.product(
1419+
["exec", "spawn"],
1420+
["l", "le", "lp", "lpe", "v", "ve", "vp", "vpe"],
1421+
)]
1422+
)
1423+
def test_execv_patch(self, fname: str) -> None:
1424+
if not hasattr(os, fname):
1425+
pytest.skip(f"This OS doesn't have os.{fname}")
1426+
1427+
self.make_file(".coveragerc", """\
1428+
[run]
1429+
patch = subprocess, execv
1430+
""")
1431+
self.make_file("main.py", f"""\
1432+
import os, sys
1433+
print("In main")
1434+
args = []
1435+
if "spawn" in {fname!r}:
1436+
args.append(os.P_WAIT)
1437+
args.append(sys.executable)
1438+
prog_args = ["python", {os.path.abspath("other.py")!r}, "cat", "dog"]
1439+
if "l" in {fname!r}:
1440+
args.extend(prog_args)
1441+
else:
1442+
args.append(prog_args)
1443+
if {fname!r}.endswith("e"):
1444+
args.append({{"SUBVAR": "the-sub-var"}})
1445+
os.environ["MAINVAR"] = "the-main-var"
1446+
sys.stdout.flush()
1447+
os.{fname}(*args)
1448+
""")
1449+
self.make_file("other.py", """\
1450+
import os, sys
1451+
print(f"MAINVAR = {os.getenv('MAINVAR', 'none')}")
1452+
print(f"SUBVAR = {os.getenv('SUBVAR', 'none')}")
1453+
print(f"{sys.argv[1:] = }")
1454+
""")
1455+
1456+
out = self.run_command("coverage run main.py")
1457+
expected = "In main\n"
1458+
if fname.endswith("e"):
1459+
expected += "MAINVAR = none\n"
1460+
expected += "SUBVAR = the-sub-var\n"
1461+
else:
1462+
expected += "MAINVAR = the-main-var\n"
1463+
expected += "SUBVAR = none\n"
1464+
expected += "sys.argv[1:] = ['cat', 'dog']\n"
1465+
assert out == expected
1466+
1467+
self.run_command("coverage combine")
1468+
data = coverage.CoverageData()
1469+
data.read()
1470+
1471+
main_lines = 12
1472+
if "spawn" in fname:
1473+
main_lines += 1
1474+
if fname.endswith("e"):
1475+
main_lines += 1
1476+
1477+
assert line_counts(data)["main.py"] == main_lines
1478+
assert line_counts(data)["other.py"] == 4
1479+
1480+
14131481
class ProcessStartupWithSourceTest(CoverageTest):
14141482
"""Show that we can configure {[run]source} during process-level coverage.
14151483

0 commit comments

Comments
 (0)