Skip to content

Commit 253a815

Browse files
committed
Defend against parallel package builds via a file lock
1 parent f57d7e2 commit 253a815

File tree

8 files changed

+111
-64
lines changed

8 files changed

+111
-64
lines changed

changelog/1026.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
intermittent failures with ``--parallel--safe-build``, instead of mangling with the file paths now uses a lock to make the package build operation thread safe and is now on by default (``--parallel--safe-build`` is now deprecated) - by :user:`gaborbernat`

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def main():
8080
"six >= 1.0.0, <2",
8181
"virtualenv >= 1.11.2",
8282
"toml >=0.9.4",
83+
"filelock >= 3.0.0, <4",
8384
],
8485
extras_require={
8586
"testing": [

src/tox/_pytestplugin.py

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import textwrap
66
import time
7+
from collections import OrderedDict
78
from fnmatch import fnmatch
89

910
import py
@@ -408,12 +409,30 @@ def mock_venv(monkeypatch):
408409

409410
# object to collect some data during the execution
410411
class Result(object):
411-
def __init__(self):
412+
def __init__(self, session):
412413
self.popens = []
413-
self.cwd = None
414-
self.session = None
415-
416-
res = Result()
414+
self._popen = session.popen
415+
416+
# collect all popen calls
417+
def popen(cmd, **kwargs):
418+
# we don't want to perform installation of new packages,
419+
# just replace with an always ok cmd
420+
if "pip" in cmd and "install" in cmd:
421+
cmd = ["python", "-c", "print({!r})".format(cmd)]
422+
activity_id = session._actions[-1].id
423+
activity_name = session._actions[-1].activity
424+
try:
425+
ret = self._popen(cmd, **kwargs)
426+
except tox.exception.InvocationError as exception: # pragma: no cover
427+
ret = exception # pragma: no cover
428+
finally:
429+
self.popens.append((activity_id, activity_name, kwargs.get("env"), ret, cmd))
430+
return ret
431+
432+
monkeypatch.setattr(session, "popen", popen)
433+
self.session = session
434+
435+
res = OrderedDict()
417436

418437
# convince tox that the current running virtual environment is already the env we would create
419438
class ProxyCurrentPython:
@@ -468,28 +487,11 @@ def tox_runenvreport(venv, action):
468487
prev_build = tox.session.build_session
469488

470489
def build_session(config):
471-
res.session = prev_build(config)
472-
res._popen = res.session.popen
473-
monkeypatch.setattr(res.session, "popen", popen)
474-
return res.session
490+
session = prev_build(config)
491+
res[id(session)] = Result(session)
492+
return session
475493

476494
monkeypatch.setattr(tox.session, "build_session", build_session)
477-
478-
# collect all popen calls
479-
def popen(cmd, **kwargs):
480-
# we don't want to perform installation of new packages, just replace with an always ok cmd
481-
if "pip" in cmd and "install" in cmd:
482-
cmd = ["python", "-c", "print({!r})".format(cmd)]
483-
activity_id = res.session._actions[-1].id
484-
activity_name = res.session._actions[-1].activity
485-
try:
486-
ret = res._popen(cmd, **kwargs)
487-
except tox.exception.InvocationError as exception: # pragma: no cover
488-
ret = exception # pragma: no cover
489-
finally:
490-
res.popens.append((activity_id, activity_name, kwargs.get("env"), ret, cmd))
491-
return ret
492-
493495
return res
494496

495497

src/tox/config.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import shlex
99
import string
1010
import sys
11-
import uuid
1211
import warnings
1312
from collections import OrderedDict
1413
from fnmatch import fnmatchcase
@@ -419,7 +418,8 @@ def tox_addoption(parser):
419418
"--parallel--safe-build",
420419
action="store_true",
421420
dest="parallel_safe_build",
422-
help="ensure two tox builds can run in parallel",
421+
help="(deprecated) ensure two tox builds can run in parallel "
422+
"(uses a lock file in the tox workdir with .lock extension)",
423423
)
424424
parser.add_argument(
425425
"--installpkg",
@@ -1018,20 +1018,16 @@ def __init__(self, config, ini_path, ini_data): # noqa
10181018
if override:
10191019
for name in config.indexserver:
10201020
config.indexserver[name] = IndexServerConfig(name, override)
1021-
unique_id = str(uuid.uuid4())
10221021

10231022
reader.addsubstitutions(toxworkdir=config.toxworkdir)
10241023
config.distdir = reader.getpath("distdir", "{toxworkdir}/dist")
1025-
self._make_thread_safe_path(config, "distdir", unique_id)
10261024

10271025
reader.addsubstitutions(distdir=config.distdir)
10281026
config.distshare = reader.getpath("distshare", dist_share_default)
1029-
self._make_thread_safe_path(config, "distshare", unique_id)
10301027
reader.addsubstitutions(distshare=config.distshare)
10311028
config.sdistsrc = reader.getpath("sdistsrc", None)
10321029
config.setupdir = reader.getpath("setupdir", "{toxinidir}")
10331030
config.logdir = config.toxworkdir.join("log")
1034-
self._make_thread_safe_path(config, "logdir", unique_id)
10351031

10361032
self.parse_build_isolation(config, reader)
10371033
config.envlist, all_envs = self._getenvdata(reader, config)
@@ -1076,12 +1072,6 @@ def parse_build_isolation(self, config, reader):
10761072
name, "{}{}".format(testenvprefix, name), reader._subs, config
10771073
)
10781074

1079-
def _make_thread_safe_path(self, config, attr, unique_id):
1080-
if config.option.parallel_safe_build:
1081-
path = getattr(config, attr)
1082-
value = py.path.local(path.dirname).join("{}-{}".format(path.basename, unique_id))
1083-
setattr(config, attr, value)
1084-
10851075
@staticmethod
10861076
def ensure_requires_satisfied(specified):
10871077
missing_requirements = []

src/tox/package.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pkg_resources
77
import py
88
import six
9+
from filelock import FileLock, Timeout
910

1011
import tox
1112
from tox.config import DepConfig, get_py_project_toml
@@ -27,6 +28,22 @@ def get_package(session):
2728
if config.skipsdist:
2829
report.info("skipping sdist step")
2930
return None
31+
lock_file = str(
32+
session.config.toxworkdir.join("{}.lock".format(session.config.isolated_build_env))
33+
)
34+
lock = FileLock(lock_file)
35+
try:
36+
try:
37+
lock.acquire(0.0001)
38+
except Timeout:
39+
report.verbosity0("lock file {} present, will block until released".format(lock_file))
40+
lock.acquire()
41+
return acquire_package(config, report, session)
42+
finally:
43+
lock.release(force=True)
44+
45+
46+
def acquire_package(config, report, session):
3047
if not config.option.sdistonly and (config.sdistsrc or config.option.installpkg):
3148
path = config.option.installpkg
3249
if not path:

tests/unit/session/test_session.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import re
33
import sys
44
import textwrap
5-
import uuid
65
from threading import Thread
76

87
import pytest
@@ -84,31 +83,6 @@ def test_minversion(cmd, initproj):
8483
assert result.ret
8584

8685

87-
def test_tox_parallel_build_safe(initproj, cmd, mock_venv):
88-
initproj(
89-
"env_var_test",
90-
filedefs={
91-
"tox.ini": """
92-
[tox]
93-
envlist = py
94-
[testenv]
95-
skip_install = true
96-
commands = python --version
97-
"""
98-
},
99-
)
100-
result = cmd("--parallel--safe-build")
101-
102-
for path, base in (
103-
(result.session.config.distdir, "dist-"),
104-
(result.session.config.logdir, "log-"),
105-
(result.session.config.distshare, "distshare-"),
106-
):
107-
basename = path.basename
108-
assert basename.startswith(base)
109-
assert uuid.UUID(basename[len(base) :], version=4)
110-
111-
11286
def test_skip_sdist(cmd, initproj):
11387
initproj(
11488
"pkg123-0.7",

tests/unit/test_package.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
import traceback
23

34
import py
45
import pytest
@@ -254,3 +255,64 @@ def test_dist_exists_version_change(mock_venv, initproj, cmd):
254255

255256
result = cmd("-e", "py")
256257
assert result.ret == 0, result.out
258+
259+
260+
def test_tox_parallel_build_safe(initproj, cmd, mock_venv, monkeypatch):
261+
initproj(
262+
"env_var_test",
263+
filedefs={
264+
"tox.ini": """
265+
[tox]
266+
envlist = py
267+
install_cmd = python -m -c 'print("ok")' -- {opts} {packages}'
268+
[testenv]
269+
commands = python --version
270+
"""
271+
},
272+
)
273+
import tox.package
274+
import threading
275+
276+
triggered = threading.Event()
277+
event = threading.Event()
278+
279+
result = {}
280+
281+
def run(name):
282+
try:
283+
outcome = cmd("--parallel--safe-build")
284+
result[name] = outcome
285+
except Exception as exception:
286+
result[name] = exception, traceback.format_exc()
287+
288+
with monkeypatch.context() as m:
289+
prev_build_package = tox.package.build_package
290+
291+
def build_package(config, report, session):
292+
triggered.set()
293+
event.wait()
294+
return prev_build_package(config, report, session)
295+
296+
m.setattr(tox.package, "build_package", build_package)
297+
t1 = threading.Thread(target=run, args=("t1",))
298+
t1.start()
299+
triggered.wait()
300+
301+
t2 = threading.Thread(target=run, args=("t2",))
302+
t2.start()
303+
304+
t2.join(timeout=0.1) # 100 ms should be enough to build the package
305+
assert t2.is_alive() # if still alive means something (our lock) prevented two parallel builds
306+
event.set()
307+
t1.join()
308+
t2.join()
309+
assert result
310+
for val in result.values():
311+
if isinstance(val, tuple):
312+
assert False, "{!r}\n{}".format(val[0], val[1])
313+
err = "\n".join((result["t1"].err, result["t2"].err)).strip()
314+
out = "\n".join((result["t1"].out, result["t2"].out)).strip()
315+
assert not err
316+
lock_file = py.path.local().join(".tox", ".package.lock")
317+
msg = "lock file {} present, will block until released".format(lock_file)
318+
assert msg in out

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ include_trailing_comma = True
141141
force_grid_wrap = 0
142142
line_length = 99
143143
known_first_party = tox,tests
144-
known_third_party = apiclient,docutils,git,httplib2,oauth2client,packaging,pkg_resources,pluggy,py,pytest,setuptools,six,sphinx,toml
144+
known_third_party = apiclient,docutils,filelock,git,httplib2,oauth2client,packaging,pkg_resources,pluggy,py,pytest,setuptools,six,sphinx,toml
145145
146146
[testenv:release]
147147
description = do a release, required posarg of the version number

0 commit comments

Comments
 (0)