Skip to content

Commit af3cd6a

Browse files
committed
BUG: futures with dir switching
* Fixes gh-2065. * Add a regression test for the failure to collect `.coverage` files when parent and child (`concurrent.futures`) processes have different directory contexts. This test fails for the expected reason it seems. * Add a patch that fixes the problem described in the matching issue, but for some reason does not manage to restore the desired behavior in the regression test (something I'm not understanding about the "meta" testing?)
1 parent 9c2960e commit af3cd6a

File tree

4 files changed

+60
-0
lines changed

4 files changed

+60
-0
lines changed

coverage/control.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,12 +559,17 @@ def load(self) -> None:
559559

560560
def _init_for_start(self) -> None:
561561
"""Initialization for start()"""
562+
self.orig_dir = None
562563
# Construct the collector.
563564
concurrency: list[str] = self.config.concurrency or []
565+
coverpath_file = os.path.join(os.getcwd(), ".coverpath")
564566
if "multiprocessing" in concurrency:
565567
if self.config.config_file is None:
566568
raise ConfigError("multiprocessing requires a configuration file")
567569
patch_multiprocessing(rcfile=self.config.config_file)
570+
if os.path.exists(coverpath_file):
571+
with open(coverpath_file, encoding="utf-8") as cf:
572+
self.orig_dir = cf.read().strip()
568573

569574
dycon = self.config.dynamic_context
570575
if not dycon or dycon == "none":
@@ -656,6 +661,8 @@ def _init_for_start(self) -> None:
656661

657662
def _init_data(self, suffix: str | bool | None) -> None:
658663
"""Create a data file if we don't have one yet."""
664+
if not hasattr(self, "orig_dir"):
665+
self.orig_dir = None
659666
if self._data is None:
660667
# Create the data file. We do this at construction time so that the
661668
# data file will be written into the directory where the process
@@ -667,6 +674,7 @@ def _init_data(self, suffix: str | bool | None) -> None:
667674
warn=self._warn,
668675
debug=self._debug,
669676
no_disk=self._no_disk,
677+
orig_dir=self.orig_dir,
670678
)
671679
self._data_to_close.append(self._data)
672680

coverage/multiproc.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ def get_preparation_data_with_stowaway(name: str) -> dict[str, Any]:
113113
"""Get the original preparation data, and also insert our stowaway."""
114114
d = original_get_preparation_data(name)
115115
d["stowaway"] = Stowaway(rcfile)
116+
with open(".coverpath", "w", encoding="utf-8") as cpath:
117+
cpath.write(d["orig_dir"])
116118
return d
117119

118120
spawn.get_preparation_data = get_preparation_data_with_stowaway

coverage/sqldata.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import glob
1212
import itertools
1313
import os
14+
import shutil
1415
import random
1516
import socket
1617
import sqlite3
@@ -232,6 +233,8 @@ def __init__(
232233
no_disk: bool = False,
233234
warn: TWarnFn | None = None,
234235
debug: TDebugCtl | None = None,
236+
*,
237+
orig_dir = None,
235238
) -> None:
236239
"""Create a :class:`CoverageData` object to hold coverage-measured data.
237240
@@ -271,6 +274,7 @@ def __init__(
271274
self._current_context: str | None = None
272275
self._current_context_id: int | None = None
273276
self._query_context_ids: list[int] | None = None
277+
self.orig_dir = orig_dir
274278

275279
__repr__ = auto_repr
276280

@@ -898,6 +902,11 @@ def read(self) -> None:
898902
def write(self) -> None:
899903
"""Ensure the data is written to the data file."""
900904
self._debug_dataio("Writing (no-op) data file", self._filename)
905+
if self.orig_dir is not None:
906+
for fname in os.listdir():
907+
if fname.startswith(".coverage."):
908+
if not os.path.exists(os.path.join(self.orig_dir, fname)):
909+
shutil.copy(fname, self.orig_dir)
901910

902911
def _start_using(self) -> None:
903912
"""Call this before using the database at all."""

tests/test_concurrency.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import re
1414
import sys
1515
import threading
16+
import concurrent.futures
1617
import time
1718

1819
from types import ModuleType
@@ -412,6 +413,32 @@ def work(x):
412413
return sum_range((x+1)*100)
413414
"""
414415

416+
MULTI_CODE_DIR_CHANGE = """
417+
import os
418+
import tempfile
419+
from concurrent.futures import ProcessPoolExecutor
420+
421+
def add(a, b):
422+
return a + b
423+
424+
def probe_dispatcher():
425+
orig_dir = os.getcwd()
426+
with tempfile.TemporaryDirectory() as temp_dir:
427+
os.chdir(temp_dir)
428+
dispatcher()
429+
os.chdir(orig_dir)
430+
431+
def dispatcher():
432+
futures = []
433+
with ProcessPoolExecutor({NPROCS}) as executor:
434+
futures.append(executor.submit(add, 2, 2))
435+
for future in futures:
436+
future.result()
437+
438+
if __name__ == "__main__":
439+
probe_dispatcher()
440+
"""
441+
415442
MULTI_CODE = """
416443
# Above this will be a definition of work().
417444
import multiprocessing
@@ -522,6 +549,20 @@ def test_multiprocessing_simple(self, start_method: str) -> None:
522549
start_method=start_method,
523550
)
524551

552+
def test_gh_2065(self, start_method: str) -> None:
553+
nprocs = 1
554+
upto = 30
555+
code = (MULTI_CODE_DIR_CHANGE).format(NPROCS=nprocs, UPTO=upto)
556+
total = 0
557+
expected_out = f""
558+
self.try_multiprocessing_code(
559+
code,
560+
expected_out,
561+
concurrent.futures,
562+
nprocs,
563+
start_method=start_method,
564+
)
565+
525566
def test_multiprocessing_append(self, start_method: str) -> None:
526567
nprocs = 3
527568
upto = 30

0 commit comments

Comments
 (0)