Skip to content

Commit c7456aa

Browse files
fschulzegaborbernat
authored andcommitted
Fix for --result-json with --parallel (#1309)
* Correct ``--result-json`` output with ``--parallel``. (#1295) When using ``--parallel`` with ``--result-json`` the test results are now included the same way as with serial runs. This is accomplished by generating json result output for each individual run and at the end copy the data into the main json result output. * avoid duplication in code, improve coverage
1 parent 016e9f2 commit c7456aa

File tree

9 files changed

+140
-25
lines changed

9 files changed

+140
-25
lines changed

docs/changelog/1184.feature.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Adding ```TOX_PARALLEL_NO_SPINNER``` environment variable to disable the spinner in parallel mode for the purposes of clean output when using CI tools - by :user:`zeroshift`
1+
Adding ``TOX_PARALLEL_NO_SPINNER`` environment variable to disable the spinner in parallel mode for the purposes of clean output when using CI tools - by :user:`zeroshift`

docs/changelog/1295.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
When using ``--parallel`` with ``--result-json`` the test results are now included the same way as with serial runs - by :user:`fschulze`

src/tox/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,5 @@ class PIP:
8686
SITE_PACKAGE_QUERY_SCRIPT = os.path.join(_HELP_DIR, "get_site_package_dir.py")
8787
BUILD_REQUIRE_SCRIPT = os.path.join(_HELP_DIR, "build_requires.py")
8888
BUILD_ISOLATED = os.path.join(_HELP_DIR, "build_isolated.py")
89+
PARALLEL_RESULT_JSON_PREFIX = ".tox-result"
90+
PARALLEL_RESULT_JSON_SUFFIX = ".json"

src/tox/session/__init__.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
setup by using virtualenv. Configuration is generally done through an
55
INI-style "tox.ini" file.
66
"""
7+
from __future__ import absolute_import, unicode_literals
78

9+
import json
810
import os
911
import re
1012
import subprocess
@@ -220,6 +222,24 @@ def subcommand_test(self):
220222
retcode = self._summary()
221223
return retcode
222224

225+
def _add_parallel_summaries(self):
226+
if self.config.option.parallel != PARALLEL_OFF and "testenvs" in self.resultlog.dict:
227+
result_log = self.resultlog.dict["testenvs"]
228+
for tox_env in self.venv_dict.values():
229+
data = self._load_parallel_env_report(tox_env)
230+
if data and "testenvs" in data and tox_env.name in data["testenvs"]:
231+
result_log[tox_env.name] = data["testenvs"][tox_env.name]
232+
233+
@staticmethod
234+
def _load_parallel_env_report(tox_env):
235+
"""Load report data into memory, remove disk file"""
236+
result_json_path = tox_env.get_result_json_path()
237+
if result_json_path and result_json_path.exists():
238+
with result_json_path.open("r") as file_handler:
239+
data = json.load(file_handler)
240+
result_json_path.remove()
241+
return data
242+
223243
def _summary(self):
224244
is_parallel_child = PARALLEL_ENV_VAR_KEY in os.environ
225245
if not is_parallel_child:
@@ -254,12 +274,14 @@ def _summary(self):
254274
report(msg)
255275
if not exit_code and not is_parallel_child:
256276
reporter.good(" congratulations :)")
257-
if not is_parallel_child:
258-
path = self.config.option.resultjson
259-
if path:
260-
path = py.path.local(path)
261-
path.write(self.resultlog.dumps_json())
262-
reporter.line("wrote json report at: {}".format(path))
277+
path = self.config.option.resultjson
278+
if path:
279+
if not is_parallel_child:
280+
self._add_parallel_summaries()
281+
path = py.path.local(path)
282+
data = self.resultlog.dumps_json()
283+
reporter.line("write json report at: {}".format(path))
284+
path.write(data)
263285
return exit_code
264286

265287
def showconfig(self):

src/tox/session/commands/run/parallel.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def run_in_thread(tox_env, os_env, processes):
4242
if hasattr(tox_env, "package"):
4343
args_sub.insert(position, str(tox_env.package))
4444
args_sub.insert(position, "--installpkg")
45+
if tox_env.get_result_json_path():
46+
result_json_index = args_sub.index("--result-json")
47+
args_sub[result_json_index + 1] = "{}".format(tox_env.get_result_json_path())
4548
with tox_env.new_action("parallel {}".format(tox_env.name)) as action:
4649

4750
def collect_process(process):

src/tox/util/lock.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ def get_unique_file(path, prefix, suffix):
3636
max_value = max(max_value, int(candidate.basename[len(prefix) : -len(suffix)]))
3737
except ValueError:
3838
continue
39-
winner = path.join("{}{}.log".format(prefix, max_value + 1))
39+
winner = path.join("{}{}{}".format(prefix, max_value + 1, suffix))
4040
winner.ensure(dir=0)
4141
return winner

src/tox/venv.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
from tox import reporter
1414
from tox.action import Action
1515
from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY
16+
from tox.constants import PARALLEL_RESULT_JSON_PREFIX, PARALLEL_RESULT_JSON_SUFFIX
1617
from tox.package.local import resolve_package
18+
from tox.util.lock import get_unique_file
1719
from tox.util.path import ensure_empty_dir
1820

1921
from .config import DepConfig
@@ -113,6 +115,7 @@ def __init__(self, envconfig=None, popen=None, env_log=None):
113115
self.popen = popen
114116
self._actions = []
115117
self.env_log = env_log
118+
self._result_json_path = None
116119

117120
def new_action(self, msg, *args):
118121
config = self.envconfig.config
@@ -130,6 +133,14 @@ def new_action(self, msg, *args):
130133
self.envconfig.envpython,
131134
)
132135

136+
def get_result_json_path(self):
137+
if self._result_json_path is None:
138+
if self.envconfig.config.option.resultjson:
139+
self._result_json_path = get_unique_file(
140+
self.path, PARALLEL_RESULT_JSON_PREFIX, PARALLEL_RESULT_JSON_SUFFIX
141+
)
142+
return self._result_json_path
143+
133144
@property
134145
def hook(self):
135146
return self.envconfig.config.pluginmanager.hook

tests/unit/session/test_parallel.py

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
from __future__ import absolute_import, unicode_literals
22

3+
import json
4+
import os
5+
import subprocess
36
import sys
7+
import threading
48

59
import pytest
610
from flaky import flaky
711

12+
from tox._pytestplugin import RunResult
13+
814

915
def test_parallel(cmd, initproj):
1016
initproj(
@@ -26,7 +32,7 @@ def test_parallel(cmd, initproj):
2632
""",
2733
},
2834
)
29-
result = cmd("--parallel", "all")
35+
result = cmd("-p", "all")
3036
result.assert_success()
3137

3238

@@ -49,7 +55,7 @@ def test_parallel_live(cmd, initproj):
4955
""",
5056
},
5157
)
52-
result = cmd("--parallel", "all", "--parallel-live")
58+
result = cmd("-p", "all", "-o")
5359
result.assert_success()
5460

5561

@@ -73,7 +79,7 @@ def test_parallel_circular(cmd, initproj):
7379
""",
7480
},
7581
)
76-
result = cmd("--parallel", "1")
82+
result = cmd("-p", "1")
7783
result.assert_fail()
7884
assert result.out == "ERROR: circular dependency detected: a | b\n"
7985

@@ -191,26 +197,96 @@ def test_parallel_show_output(cmd, initproj, monkeypatch):
191197
assert "stderr always" in result.out, result.output()
192198

193199

194-
def test_parallel_no_spinner(cmd, initproj, monkeypatch):
195-
monkeypatch.setenv(str("TOX_PARALLEL_NO_SPINNER"), str("1"))
196-
initproj(
200+
@pytest.fixture()
201+
def parallel_project(initproj):
202+
return initproj(
197203
"pkg123-0.7",
198204
filedefs={
199205
"tox.ini": """
200206
[tox]
207+
skipsdist = True
201208
envlist = a, b
202-
isolated_build = true
203209
[testenv]
210+
skip_install = True
204211
commands=python -c "import sys; print(sys.executable)"
205-
[testenv:b]
206-
depends = a
207-
""",
208-
"pyproject.toml": """
209-
[build-system]
210-
requires = ["setuptools >= 35.0.2"]
211-
build-backend = 'setuptools.build_meta'
212-
""",
212+
"""
213213
},
214214
)
215-
result = cmd("--parallel", "all")
215+
216+
217+
def test_parallel_no_spinner_on(cmd, parallel_project, monkeypatch):
218+
monkeypatch.setenv(str("TOX_PARALLEL_NO_SPINNER"), str("1"))
219+
result = cmd("-p", "all")
220+
result.assert_success()
221+
assert "[2] a | b" not in result.out
222+
223+
224+
def test_parallel_no_spinner_off(cmd, parallel_project, monkeypatch):
225+
monkeypatch.setenv(str("TOX_PARALLEL_NO_SPINNER"), str("0"))
226+
result = cmd("-p", "all")
216227
result.assert_success()
228+
assert "[2] a | b" in result.out
229+
230+
231+
def test_parallel_no_spinner_not_set(cmd, parallel_project, monkeypatch):
232+
monkeypatch.delenv(str("TOX_PARALLEL_NO_SPINNER"), raising=False)
233+
result = cmd("-p", "all")
234+
result.assert_success()
235+
assert "[2] a | b" in result.out
236+
237+
238+
def test_parallel_result_json(cmd, parallel_project, tmp_path):
239+
parallel_result_json = tmp_path / "parallel.json"
240+
result = cmd("-p", "all", "--result-json", "{}".format(parallel_result_json))
241+
ensure_result_json_ok(result, parallel_result_json)
242+
243+
244+
def ensure_result_json_ok(result, json_path):
245+
if isinstance(result, RunResult):
246+
result.assert_success()
247+
else:
248+
assert not isinstance(result, subprocess.CalledProcessError)
249+
assert json_path.exists()
250+
serial_data = json.loads(json_path.read_text())
251+
ensure_key_in_env(serial_data)
252+
253+
254+
def ensure_key_in_env(serial_data):
255+
for env in ("a", "b"):
256+
for key in ("setup", "test"):
257+
assert key in serial_data["testenvs"][env], json.dumps(
258+
serial_data["testenvs"], indent=2
259+
)
260+
261+
262+
def test_parallel_result_json_concurrent(cmd, parallel_project, tmp_path):
263+
# first run to set up the environments (env creation is not thread safe)
264+
result = cmd("-p", "all")
265+
result.assert_success()
266+
267+
invoke_result = {}
268+
269+
def invoke_tox_in_thread(thread_name, result_json):
270+
try:
271+
# needs to be process to have it's own stdout
272+
invoke_result[thread_name] = subprocess.check_output(
273+
[sys.executable, "-m", "tox", "-p", "all", "--result-json", str(result_json)],
274+
universal_newlines=True,
275+
)
276+
except subprocess.CalledProcessError as exception:
277+
invoke_result[thread_name] = exception
278+
279+
# now concurrently
280+
parallel1_result_json = tmp_path / "parallel1.json"
281+
parallel2_result_json = tmp_path / "parallel2.json"
282+
threads = [
283+
threading.Thread(target=invoke_tox_in_thread, args=(k, p))
284+
for k, p in (("t1", parallel1_result_json), ("t2", parallel2_result_json))
285+
]
286+
[t.start() for t in threads]
287+
[t.join() for t in threads]
288+
289+
ensure_result_json_ok(invoke_result["t1"], parallel1_result_json)
290+
ensure_result_json_ok(invoke_result["t2"], parallel2_result_json)
291+
# our set_os_env_var is not thread-safe so clean-up TOX_WORK_DIR
292+
os.environ.pop("TOX_WORK_DIR", None)

tests/unit/test_z_cmdline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ def test_result_json(cmd, initproj, example123):
500500
assert isinstance(pyinfo["version_info"], list)
501501
assert pyinfo["version"]
502502
assert pyinfo["executable"]
503-
assert "wrote json report at: {}".format(json_path) == result.outlines[-1]
503+
assert "write json report at: {}".format(json_path) == result.outlines[-1]
504504

505505

506506
def test_developz(initproj, cmd):

0 commit comments

Comments
 (0)