Skip to content

Commit 0de3d69

Browse files
committed
Avoid build package becoming unavailable during session run
We create a hard link (if possible, otherwise copy of the built package) inside a temp folder. This way subsequent sessions are free to remove dist folder and we also guarantee that the built package during the build is what tox environments use (even if the package gets rebuilt in the meantime).
1 parent 253a815 commit 0de3d69

File tree

8 files changed

+161
-51
lines changed

8 files changed

+161
-51
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/ambv/black
3-
rev: 18.6b4
3+
rev: 18.9b0
44
hooks:
55
- id: black
66
args: [--line-length=99, --safe]

changelog/1026.feature.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added ``temp_dir`` folder configuration (defaults to ``{toxworkdir}/.tmp``) that contains tox
2+
temporary files. Package builds now create a hard link (if possible, otherwise copy - notably
3+
Windows Python 2.7) to the built file, and feed that file downstream (e.g. for pip to install it).
4+
The hard link is removed at the end of the run (what it points though is kept inside ``distdir``).
5+
This ensures that a tox session operates on the same package it built, even if a parallel tox run
6+
builds another version. Note ``distdir`` will contain only the last built package in such cases.
7+
- by :user:`gaborbernat`
8+
9+

doc/config.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ Global settings are defined under the ``tox`` section as:
4343

4444
Directory for tox to generate its environments into, will be created if it does not exist.
4545

46+
.. conf:: temp_dir ^ PATH ^ {toxinidir}/.tmp
47+
48+
Directory where to put tox temporary files. For example: we create a hard link (if possible,
49+
otherwise new copy) in this directory for the project package. This ensures tox works correctly
50+
when having parallel runs (as each session will have its own copy of the project package - e.g.
51+
the source distribution).
52+
4653
.. conf:: skipsdist ^ true|false ^ false
4754

4855
Flag indicating to perform the packaging operation or not. Set it to ``true`` when using tox for
@@ -59,10 +66,11 @@ Global settings are defined under the ``tox`` section as:
5966
Directory where the packaged source distribution should be put. Note this is cleaned at the start of
6067
every packaging invocation.
6168

62-
.. conf:: distshare ^ PATH ^ {toxworkdir}/distshare
69+
.. conf:: distshare ^ PATH ^ {homedir}/.tox/distshare
6370

6471
Folder where the packaged source distribution will be moved, this is not cleaned between packaging
65-
invocations.
72+
invocations. On Jenkins (exists ``JENKINS_URL`` or ``HUDSON_URL`` environment variable)
73+
the default path is ``{toxworkdir}/distshare``.
6674

6775
.. conf:: envlist ^ comma separated values
6876

src/tox/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,7 @@ def __init__(self, config, ini_path, ini_data): # noqa
10241024

10251025
reader.addsubstitutions(distdir=config.distdir)
10261026
config.distshare = reader.getpath("distshare", dist_share_default)
1027+
config.temp_dir = reader.getpath("temp_dir", "{toxworkdir}/.tmp")
10271028
reader.addsubstitutions(distshare=config.distshare)
10281029
config.sdistsrc = reader.getpath("sdistsrc", None)
10291030
config.setupdir = reader.getpath("setupdir", "{toxinidir}")

src/tox/package.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
2+
import os
23
import sys
34
import textwrap
45
from collections import namedtuple
6+
from itertools import chain
57

68
import pkg_resources
79
import py
@@ -38,11 +40,40 @@ def get_package(session):
3840
except Timeout:
3941
report.verbosity0("lock file {} present, will block until released".format(lock_file))
4042
lock.acquire()
41-
return acquire_package(config, report, session)
43+
package = acquire_package(config, report, session)
44+
return create_session_view(package, config.temp_dir, report)
4245
finally:
4346
lock.release(force=True)
4447

4548

49+
def create_session_view(package, temp_dir, report):
50+
"""once we build a package we cannot return that directly, as a subsequent call
51+
might delete that package (in order to do its own build); therefore we need to
52+
return a view of the file that it's not prone to deletion and can be removed when the
53+
session ends
54+
"""
55+
exists = [
56+
i.basename[len(package.basename) + 1 :]
57+
for i in temp_dir.listdir(fil="{}.*".format(package.basename))
58+
]
59+
file_id = max(chain((0,), (int(i) for i in exists if six.text_type(i).isnumeric())))
60+
session_package = temp_dir.join("{}.{}".format(package.basename, file_id + 1))
61+
# if we can do hard links do that, otherwise just copy
62+
operation = "links"
63+
if hasattr(os, "link"):
64+
os.link(str(package), str(session_package))
65+
else:
66+
operation = "copied"
67+
package.copy(session_package)
68+
common = session_package.common(package)
69+
report.verbosity1(
70+
"cwd {} package {} {} to {}".format(
71+
common, common.bestrelpath(session_package), operation, common.bestrelpath(package)
72+
)
73+
)
74+
return session_package
75+
76+
4677
def acquire_package(config, report, session):
4778
if not config.option.sdistonly and (config.sdistsrc or config.option.installpkg):
4879
path = config.option.installpkg

src/tox/result.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import json
22
import socket
3+
import subprocess
34
import sys
45

5-
import py
6-
76
import tox
87

98

@@ -44,24 +43,18 @@ def __init__(self, reportlog, name, dict):
4443
self.name = name
4544
self.dict = dict
4645

47-
def set_python_info(self, pythonexecutable):
48-
pythonexecutable = py.path.local(pythonexecutable)
49-
out = pythonexecutable.sysexec(
46+
def set_python_info(self, python_executable):
47+
cmd = [
48+
python_executable,
5049
"-c",
51-
"import sys; "
52-
"print(sys.executable);"
53-
"print(list(sys.version_info)); "
54-
"print(sys.version)",
55-
)
56-
lines = out.splitlines()
57-
executable = lines.pop(0)
58-
version_info = eval(lines.pop(0))
59-
version = "\n".join(lines)
60-
self.dict["python"] = {
61-
"executable": executable,
62-
"version_info": version_info,
63-
"version": version,
64-
}
50+
"import sys; import json;"
51+
"print(json.dumps({"
52+
"'executable': sys.executable,"
53+
"'version_info': list(sys.version_info),"
54+
"'version': sys.version}))",
55+
]
56+
result = subprocess.check_output(cmd, universal_newlines=True)
57+
self.dict["python"] = json.loads(result)
6558

6659
def get_commandlog(self, name):
6760
return CommandLog(self, self.dict.setdefault(name, []))

src/tox/session.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import subprocess
1414
import sys
1515
import time
16+
from contextlib import contextmanager
1617

1718
import pkg_resources
1819
import py
@@ -448,7 +449,20 @@ def runcommand(self):
448449
elif self.config.option.listenvs_all:
449450
self.showenvs(all_envs=True, description=verbosity)
450451
else:
451-
return self.subcommand_test()
452+
with self.cleanup():
453+
return self.subcommand_test()
454+
455+
@contextmanager
456+
def cleanup(self):
457+
self.config.temp_dir.ensure(dir=True)
458+
try:
459+
yield
460+
finally:
461+
for tox_env in self.venvlist:
462+
if hasattr(tox_env, "package"):
463+
if tox_env.package.exists():
464+
self.report.verbosity2("cleanup {}".format(tox_env.package))
465+
tox_env.package.remove()
452466

453467
def _copyfiles(self, srcdir, pathlist, destdir):
454468
for relpath in pathlist:

tests/unit/test_package.py

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
import traceback
3+
from functools import partial
34

45
import py
56
import pytest
@@ -262,57 +263,110 @@ def test_tox_parallel_build_safe(initproj, cmd, mock_venv, monkeypatch):
262263
"env_var_test",
263264
filedefs={
264265
"tox.ini": """
265-
[tox]
266-
envlist = py
267-
install_cmd = python -m -c 'print("ok")' -- {opts} {packages}'
268-
[testenv]
269-
commands = python --version
266+
[tox]
267+
envlist = py
268+
install_cmd = python -m -c 'print("ok")' -- {opts} {packages}'
269+
[testenv]
270+
commands = python --version
270271
"""
271272
},
272273
)
274+
# we try to recreate the following situation
275+
# t1 starts and performs build
276+
# t2 starts, but is blocked from t1 build lock to build
277+
# t1 gets unblocked, t2 can now enter
278+
# t1 is artificially blocked to run test command until t2 finishes build
279+
# (parallel build package present)
280+
# t2 package build finishes both t1 and t2 can now finish and clean up their build packages
273281
import tox.package
274282
import threading
275283

276-
triggered = threading.Event()
277-
event = threading.Event()
284+
t1_build_started = threading.Event()
285+
t1_build_blocker = threading.Event()
286+
t2_build_started = threading.Event()
287+
t2_build_finished = threading.Event()
278288

279-
result = {}
289+
invoke_result = {}
280290

281-
def run(name):
291+
def invoke_tox_in_thread(thread_name):
282292
try:
283-
outcome = cmd("--parallel--safe-build")
284-
result[name] = outcome
293+
result = cmd("--parallel--safe-build", "-vv")
285294
except Exception as exception:
286-
result[name] = exception, traceback.format_exc()
295+
result = exception, traceback.format_exc()
296+
invoke_result[thread_name] = result
297+
298+
prev_build_package = tox.package.build_package
287299

288300
with monkeypatch.context() as m:
289-
prev_build_package = tox.package.build_package
290301

291302
def build_package(config, report, session):
292-
triggered.set()
293-
event.wait()
303+
t1_build_started.set()
304+
prev_run_test_env = tox.session.Session.runtestenv
305+
306+
def run_test_env(self, venv, redirect=False):
307+
t2_build_finished.wait()
308+
return prev_run_test_env(self, venv, redirect)
309+
310+
session.runtestenv = partial(run_test_env, session)
311+
312+
t1_build_blocker.wait()
294313
return prev_build_package(config, report, session)
295314

296315
m.setattr(tox.package, "build_package", build_package)
297-
t1 = threading.Thread(target=run, args=("t1",))
316+
317+
t1 = threading.Thread(target=invoke_tox_in_thread, args=("t1",))
298318
t1.start()
299-
triggered.wait()
319+
t1_build_started.wait()
300320

301-
t2 = threading.Thread(target=run, args=("t2",))
302-
t2.start()
321+
with monkeypatch.context() as m:
322+
323+
def build_package(config, report, session):
324+
t2_build_started.set()
325+
try:
326+
return prev_build_package(config, report, session)
327+
finally:
328+
t2_build_finished.set()
303329

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()
330+
m.setattr(tox.package, "build_package", build_package)
331+
332+
t2 = threading.Thread(target=invoke_tox_in_thread, args=("t2",))
333+
t2.start()
334+
335+
# t2 should get blocked by t1 build lock
336+
t2_build_started.wait(timeout=0.1)
337+
assert not t2_build_started.is_set()
338+
339+
t1_build_blocker.set() # release t1 blocker -> t1 can now finish
340+
# t1 at this point should block at run test until t2 build finishes
341+
t2_build_started.wait()
342+
343+
t1.join() # wait for both t1 and t2 to finish
308344
t2.join()
309-
assert result
310-
for val in result.values():
345+
346+
# all threads finished without error
347+
for val in invoke_result.values():
311348
if isinstance(val, tuple):
312349
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()
350+
351+
# output has no error
352+
err = "\n".join((invoke_result["t1"].err, invoke_result["t2"].err)).strip()
353+
out = "\n".join((invoke_result["t1"].out, invoke_result["t2"].out)).strip()
315354
assert not err
355+
356+
# when the lock is hit we notify
316357
lock_file = py.path.local().join(".tox", ".package.lock")
317358
msg = "lock file {} present, will block until released".format(lock_file)
318359
assert msg in out
360+
361+
# intermediate packages are removed at end of build
362+
t1_package = invoke_result["t1"].session.getvenv("py").package
363+
t2_package = invoke_result["t1"].session.getvenv("py").package
364+
assert t1 != t2
365+
assert not t1_package.exists()
366+
assert not t2_package.exists()
367+
368+
# the final distribution remains
369+
dist_after = invoke_result["t1"].session.config.distdir.listdir()
370+
assert len(dist_after) == 1
371+
sdist = dist_after[0]
372+
assert t1_package != sdist

0 commit comments

Comments
 (0)