Skip to content

Commit 697d4bb

Browse files
committed
fix: subprocesses inherit the entire configuration. #2021
1 parent b6db3b7 commit 697d4bb

File tree

6 files changed

+104
-46
lines changed

6 files changed

+104
-46
lines changed

CHANGES.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,20 @@ Unreleased
3737
``ModuleNotFound`` error trying to import coverage. This is now fixed,
3838
closing `issue 2022`_.
3939

40+
- Originally only options set in the coverage configuration file would apply
41+
to subprocesses. Options set on the ``coverage run`` command line (such as
42+
``--branch``) wouldn't be communicated to the subprocesses. This could
43+
lead to combining failures, as described in `issue 2021`_. Now the entire
44+
configuration is used in subprocesses, regardless of its origin.
45+
4046
- Added ``debug=patch`` to help diagnose problems.
4147

4248
- Fix: really close all SQLite databases, even in-memory ones. Closes `issue
4349
2017`_.
4450

4551
.. _issue 2007: https://github.com/nedbat/coveragepy/issues/2007
4652
.. _issue 2017: https://github.com/nedbat/coveragepy/issues/2017
53+
.. _issue 2021: https://github.com/nedbat/coveragepy/issues/2021
4754
.. _issue 2022: https://github.com/nedbat/coveragepy/issues/2022
4855
.. _issue 2024: https://github.com/nedbat/coveragepy/issues/2024
4956
.. _issue 2025: https://github.com/nedbat/coveragepy/issues/2025

coverage/config.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55

66
from __future__ import annotations
77

8+
import base64
89
import collections
910
import configparser
1011
import copy
12+
import json
1113
import os
1214
import os.path
1315
import re
@@ -273,6 +275,10 @@ def __init__(self) -> None:
273275
"patch",
274276
}
275277

278+
SERIALIZE_ABSPATH = {
279+
"data_file", "debug_file", "source_dirs",
280+
}
281+
276282
def from_args(self, **kwargs: TConfigValueIn) -> None:
277283
"""Read config values from `kwargs`."""
278284
for k, v in kwargs.items():
@@ -554,6 +560,22 @@ def debug_info(self) -> list[tuple[str, Any]]:
554560
(k, v) for k, v in self.__dict__.items() if not k.startswith("_")
555561
)
556562

563+
def serialize(self) -> str:
564+
"""Convert to a string that can be ingested with `deserialize_config`.
565+
566+
File paths used by `coverage run` are made absolute to ensure the
567+
deserialized config will refer to the same files.
568+
"""
569+
data = {k:v for k, v in self.__dict__.items() if not k.startswith("_")}
570+
for k in self.SERIALIZE_ABSPATH:
571+
v = data[k]
572+
if isinstance(v, list):
573+
v = list(map(os.path.abspath, v))
574+
elif isinstance(v, str):
575+
v = os.path.abspath(v)
576+
data[k] = v
577+
return base64.b64encode(json.dumps(data).encode()).decode()
578+
557579

558580
def process_file_value(path: str) -> str:
559581
"""Make adjustments to a file path to make it usable."""
@@ -669,3 +691,11 @@ def read_coverage_config(
669691
config.post_process()
670692

671693
return config
694+
695+
696+
def deserialize_config(config_str: str) -> CoverageConfig:
697+
"""Take a string from CoverageConfig.serialize, and make a CoverageConfig."""
698+
data = json.loads(base64.b64decode(config_str.encode()).decode())
699+
config = CoverageConfig()
700+
config.__dict__.update(data)
701+
return config

coverage/control.py

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from coverage import env
2525
from coverage.annotate import AnnotateReporter
2626
from coverage.collector import Collector
27-
from coverage.config import CoverageConfig, read_coverage_config
27+
from coverage.config import CoverageConfig, deserialize_config, read_coverage_config
2828
from coverage.context import combine_context_switchers, should_start_context_test_function
2929
from coverage.core import CTRACER_FILE, Core
3030
from coverage.data import CoverageData, combine_parallel_data
@@ -89,6 +89,7 @@ def override_config(cov: Coverage, **kwargs: TConfigValueIn) -> Iterator[None]:
8989

9090
DEFAULT_DATAFILE = DefaultValue("MISSING")
9191
_DEFAULT_DATAFILE = DEFAULT_DATAFILE # Just in case, for backwards compatibility
92+
CONFIG_FROM_ENVIRONMENT = ":envvar:"
9293

9394
class Coverage(TConfigurable):
9495
"""Programmatic access to coverage.py.
@@ -316,27 +317,30 @@ def __init__( # pylint: disable=too-many-arguments
316317
self._should_write_debug = True
317318

318319
# Build our configuration from a number of sources.
319-
if not isinstance(config_file, bool):
320-
config_file = os.fspath(config_file)
321-
self.config = read_coverage_config(
322-
config_file=config_file,
323-
warn=self._warn,
324-
data_file=data_file,
325-
cover_pylib=cover_pylib,
326-
timid=timid,
327-
branch=branch,
328-
parallel=bool_or_none(data_suffix),
329-
source=source,
330-
source_pkgs=source_pkgs,
331-
source_dirs=source_dirs,
332-
run_omit=omit,
333-
run_include=include,
334-
debug=debug,
335-
report_omit=omit,
336-
report_include=include,
337-
concurrency=concurrency,
338-
context=context,
339-
)
320+
if config_file == CONFIG_FROM_ENVIRONMENT:
321+
self.config = deserialize_config(cast(str, os.getenv("COVERAGE_PROCESS_CONFIG")))
322+
else:
323+
if not isinstance(config_file, bool):
324+
config_file = os.fspath(config_file)
325+
self.config = read_coverage_config(
326+
config_file=config_file,
327+
warn=self._warn,
328+
data_file=data_file,
329+
cover_pylib=cover_pylib,
330+
timid=timid,
331+
branch=branch,
332+
parallel=bool_or_none(data_suffix),
333+
source=source,
334+
source_pkgs=source_pkgs,
335+
source_dirs=source_dirs,
336+
run_omit=omit,
337+
run_include=include,
338+
debug=debug,
339+
report_omit=omit,
340+
report_include=include,
341+
concurrency=concurrency,
342+
context=context,
343+
)
340344

341345
# If we have subprocess measurement happening automatically, then we
342346
# want any explicit creation of a Coverage object to mean, this process
@@ -1413,24 +1417,19 @@ def process_startup() -> Coverage | None:
14131417
measurement is started. The value of the variable is the config file
14141418
to use.
14151419
1416-
There are two ways to configure your Python installation to invoke this
1417-
function when Python starts:
1418-
1419-
#. Create or append to sitecustomize.py to add these lines::
1420-
1421-
import coverage
1422-
coverage.process_startup()
1423-
1424-
#. Create a .pth file in your Python installation containing::
1425-
1426-
import coverage; coverage.process_startup()
1420+
For details, see https://coverage.readthedocs.io/en/latest/subprocess.html.
14271421
14281422
Returns the :class:`Coverage` instance that was started, or None if it was
14291423
not started by this call.
14301424
14311425
"""
14321426
cps = os.getenv("COVERAGE_PROCESS_START")
1433-
if not cps:
1427+
config_data = os.getenv("COVERAGE_PROCESS_CONFIG")
1428+
if cps is not None:
1429+
config_file = cps
1430+
elif config_data is not None:
1431+
config_file = CONFIG_FROM_ENVIRONMENT
1432+
else:
14341433
# No request for coverage, nothing to do.
14351434
return None
14361435

@@ -1445,13 +1444,10 @@ def process_startup() -> Coverage | None:
14451444

14461445
if hasattr(process_startup, "coverage"):
14471446
# We've annotated this function before, so we must have already
1448-
# started coverage.py in this process. Nothing to do.
1447+
# auto-started coverage.py in this process. Nothing to do.
14491448
return None
14501449

1451-
cov = Coverage(
1452-
config_file=cps,
1453-
data_file=os.getenv("COVERAGE_PROCESS_DATAFILE") or DEFAULT_DATAFILE,
1454-
)
1450+
cov = Coverage(config_file=config_file)
14551451
process_startup.coverage = cov # type: ignore[attr-defined]
14561452
cov._warn_no_data = False
14571453
cov._warn_unimported_source = False

coverage/patch.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,7 @@ def delete_pth_files() -> None:
113113
p.unlink(missing_ok=True)
114114
atexit.register(delete_pth_files)
115115
assert config.config_file is not None
116-
os.environ["COVERAGE_PROCESS_START"] = config.config_file
117-
os.environ["COVERAGE_PROCESS_DATAFILE"] = os.path.abspath(config.data_file)
116+
os.environ["COVERAGE_PROCESS_CONFIG"] = config.serialize()
118117

119118

120119
# Writing .pth files is not obvious. On Windows, getsitepackages() returns two

doc/config.rst

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

462-
.. note:: When using ``patch = subprocess``, all configuration options must
463-
be specified in a configuration file. Options on the ``coverage``
464-
command line will not be available to subprocesses.
465-
466462
- ``execv``: The :func:`execv <python:os.execl>` family of functions end the
467463
current program without giving coverage a chance to write collected data.
468464
This patch adjusts those functions to save the data before starting the next

tests/test_process.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1436,10 +1436,40 @@ def test_subprocess_in_directories(self) -> None:
14361436
self.run_command("coverage combine")
14371437
data = coverage.CoverageData(".covdata")
14381438
data.read()
1439-
print(line_counts(data))
14401439
assert line_counts(data)["main.py"] == 6
14411440
assert line_counts(data)["subproc.py"] == 2
14421441

1442+
def test_subprocess_gets_nonfile_config(self) -> None:
1443+
# https://github.com/nedbat/coveragepy/issues/2021
1444+
self.make_file("subfunctions.py", """\
1445+
import subprocess, sys
1446+
1447+
def f1():
1448+
print("function 1")
1449+
def f2():
1450+
print("function 2")
1451+
1452+
functions = [f1, f2]
1453+
1454+
cases = sys.argv[1:]
1455+
if len(cases) > 1:
1456+
for c in cases:
1457+
subprocess.call([sys.executable, __file__, c])
1458+
else:
1459+
functions[int(cases[0])]()
1460+
""")
1461+
self.make_file(".coveragerc", """\
1462+
[run]
1463+
disable_warnings = no-sysmon
1464+
patch = subprocess
1465+
""")
1466+
out = self.run_command("coverage run --branch subfunctions.py 0 1")
1467+
assert out.endswith("function 1\nfunction 2\n")
1468+
self.run_command("coverage combine")
1469+
data = coverage.CoverageData()
1470+
data.read()
1471+
assert line_counts(data)["subfunctions.py"] == 11
1472+
14431473

14441474
@pytest.mark.skipif(env.WINDOWS, reason="patch=execv isn't supported on Windows")
14451475
@pytest.mark.xdist_group(name="needs_pth")

0 commit comments

Comments
 (0)