diff --git a/docs/changelog/439.feature.rst b/docs/changelog/439.feature.rst new file mode 100644 index 000000000..029b6c2bb --- /dev/null +++ b/docs/changelog/439.feature.rst @@ -0,0 +1 @@ +Parallel mode added (alternative to ``detox`` which is being deprecated), for more details see :ref:`parallel_mode` - by :user:`gaborbernat`. diff --git a/docs/changelog/template.jinja2 b/docs/changelog/template.jinja2 index cb98bf097..c8dcae1fe 100644 --- a/docs/changelog/template.jinja2 +++ b/docs/changelog/template.jinja2 @@ -13,7 +13,8 @@ {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} -- {{ text }} ({{ values|join(', ') }}) +- {{ text }} + {{ values|join(',\n ') }} {% endfor %} {% else %} diff --git a/docs/config.rst b/docs/config.rst index c939e127b..848700e0e 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -158,6 +158,26 @@ Global settings are defined under the ``tox`` section as: Name of the virtual environment used to create a source distribution from the source tree. +.. conf:: parallel_show_output ^ bool ^ false + + .. versionadded:: 3.7.0 + + If set to True the content of the output will always be shown when running in parallel mode. + +.. conf:: depends ^ comma separated values + + .. versionadded:: 3.7.0 + + tox environments this depends on. tox will try to run all dependent environments before running this + environment. Format is same as :conf:`envlist` (allows factor usage). + + .. warning:: + + ``depends`` does not pull in dependencies into the run target, for example if you select ``py27,py36,coverage`` + via the ``-e`` tox will only run those three (even if ``coverage`` may specify as ``depends`` other targets too - + such as ``py27, py35, py36, py37``). + + Jenkins override ++++++++++++++++ diff --git a/docs/example/basic.rst b/docs/example/basic.rst index daff7e946..501069c45 100644 --- a/docs/example/basic.rst +++ b/docs/example/basic.rst @@ -381,3 +381,57 @@ meant exactly for that purpose, by setting the ``alwayscopy`` directive in your [testenv] alwayscopy = True + +.. _`parallel_mode`: + +Parallel mode +------------- +``tox`` allows running environments in parallel: + +- Invoke by using the ``--parallel`` or ``-p`` flag. After the packaging phase completes tox will run in parallel + processes tox environments (spins a new instance of the tox interpreter, but passes through all host flags and + environment variables). +- ``-p`` takes an argument specifying the degree of parallelization: + + - ``all`` to run all invoked environments in parallel, + - ``auto`` to limit it to CPU count, + - or pass an integer to set that limit. +- Parallel mode displays a progress spinner while running tox environments in parallel, and reports outcome of + these as soon as completed with a human readable duration timing attached. +- Parallel mode by default shows output only of failed environments and ones marked as :conf:`parallel_show_output` + ``=True``. +- There's now a concept of dependency between environments (specified via :conf:`depends`), tox will re-order the + environment list to be run to satisfy these dependencies (in sequential run too). Furthermore, in parallel mode, + will only schedule a tox environment to run once all of its dependencies finished (independent of their outcome). + + .. warning:: + + ``depends`` does not pull in dependencies into the run target, for example if you select ``py27,py36,coverage`` + via the ``-e`` tox will only run those three (even if ``coverage`` may specify as ``depends`` other targets too - + such as ``py27, py35, py36, py37``). + +- ``--parallel-live``/``-o`` allows showing the live output of the standard output and error, also turns off reporting + described above. +- Note: parallel evaluation disables standard input. Use non parallel invocation if you need standard input. + +Example final output: + +.. code-block:: bash + + $ tox -e py27,py36,coverage -p all + ✔ OK py36 in 9.533 seconds + ✔ OK py27 in 9.96 seconds + ✔ OK coverage in 2.0 seconds + ___________________________ summary ______________________________________________________ + py27: commands succeeded + py36: commands succeeded + coverage: commands succeeded + congratulations :) + + +Example progress bar, showing a rotating spinner, the number of environments running and their list (limited up to \ +120 characters): + +.. code-block:: bash + + ⠹ [2] py27 | py36 diff --git a/docs/plugins.rst b/docs/plugins.rst index 5ad34fb1d..ae19d94e2 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -58,7 +58,6 @@ and locations of all installed plugins:: 3.0.0 imported from /home/ob/.virtualenvs/tmp/lib/python3.6/site-packages/tox/__init__.py registered plugins: tox-travis-0.10 at /home/ob/.virtualenvs/tmp/lib/python3.6/site-packages/tox_travis/hooks.py - detox-0.12 at /home/ob/.virtualenvs/tmp/lib/python3.6/site-packages/detox/tox_proclimit.py Creating a plugin diff --git a/setup.py b/setup.py index fe804c384..798d7e839 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ setup_requires=["setuptools-scm>2, <4"], # readthedocs needs it extras_require={ "testing": [ + "freezegun >= 0.3.11", "pytest >= 3.0.0, <4", "pytest-cov >= 2.5.1, <3", "pytest-mock >= 1.10.0, <2", diff --git a/src/tox/config.py b/src/tox/config/__init__.py similarity index 98% rename from src/tox/config.py rename to src/tox/config/__init__.py index aad17ebdc..e6e864bbf 100644 --- a/src/tox/config.py +++ b/src/tox/config/__init__.py @@ -21,6 +21,7 @@ import tox from tox.constants import INFO from tox.interpreters import Interpreters, NoInterpreterInfo +from .parallel import add_parallel_flags, ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY, add_parallel_config hookimpl = tox.hookimpl """DEPRECATED - REMOVE - this is left for compatibility with plugins importing this from here. @@ -59,7 +60,13 @@ class Parser: """Command line and ini-parser control object.""" def __init__(self): - self.argparser = argparse.ArgumentParser(description="tox options", add_help=False) + class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter): + def __init__(self, prog): + super(HelpFormatter, self).__init__(prog, max_help_position=35, width=190) + + self.argparser = argparse.ArgumentParser( + description="tox options", add_help=False, prog="tox", formatter_class=HelpFormatter + ) self._testenv_attr = [] def add_argument(self, *args, **kwargs): @@ -274,7 +281,9 @@ def parse_cli(args, pm): print(get_version_info(pm)) raise SystemExit(0) interpreters = Interpreters(hook=pm.hook) - config = Config(pluginmanager=pm, option=option, interpreters=interpreters, parser=parser) + config = Config( + pluginmanager=pm, option=option, interpreters=interpreters, parser=parser, args=args + ) return config, option @@ -413,6 +422,7 @@ def tox_addoption(parser): dest="sdistonly", help="only perform the sdist packaging activity.", ) + add_parallel_flags(parser) parser.add_argument( "--parallel--safe-build", action="store_true", @@ -799,6 +809,8 @@ def develop(testenv_config, value): help="list of extras to install with the source distribution or develop install", ) + add_parallel_config(parser) + def cli_skip_missing_interpreter(parser): class SkipMissingInterpreterAction(argparse.Action): @@ -822,7 +834,7 @@ def __call__(self, parser, namespace, values, option_string=None): class Config(object): """Global Tox config object.""" - def __init__(self, pluginmanager, option, interpreters, parser): + def __init__(self, pluginmanager, option, interpreters, parser, args): self.envconfigs = OrderedDict() """Mapping envname -> envconfig""" self.invocationcwd = py.path.local() @@ -831,6 +843,7 @@ def __init__(self, pluginmanager, option, interpreters, parser): self.option = option self._parser = parser self._testenv_attr = parser._testenv_attr + self.args = args """option namespace containing all parsed command line options""" @@ -1040,7 +1053,7 @@ def __init__(self, config, ini_path, ini_data): # noqa # factors stated in config envlist stated_envlist = reader.getstring("envlist", replace=False) if stated_envlist: - for env in _split_env(stated_envlist): + for env in config.envlist: known_factors.update(env.split("-")) # configure testenvs @@ -1119,6 +1132,9 @@ def make_envconfig(self, name, section, subs, config, replace=True): res = reader.getlist(env_attr.name, sep=" ") elif atype == "line-list": res = reader.getlist(env_attr.name, sep="\n") + elif atype == "env-list": + res = reader.getstring(env_attr.name, replace=False) + res = tuple(_split_env(res)) else: raise ValueError("unknown type {!r}".format(atype)) if env_attr.postprocess: @@ -1133,6 +1149,7 @@ def make_envconfig(self, name, section, subs, config, replace=True): def _getenvdata(self, reader, config): candidates = ( + os.environ.get(PARALLEL_ENV_VAR_KEY), self.config.option.env, os.environ.get("TOXENV"), reader.getstring("envlist", replace=False), @@ -1167,6 +1184,8 @@ def _getenvdata(self, reader, config): def _split_env(env): """if handed a list, action="append" was used for -e """ + if env is None: + return [] if not isinstance(env, list): env = [e.split("#", 1)[0].strip() for e in env.split("\n")] env = ",".join([e for e in env if e]) diff --git a/src/tox/config/parallel.py b/src/tox/config/parallel.py new file mode 100644 index 000000000..5cc859a2c --- /dev/null +++ b/src/tox/config/parallel.py @@ -0,0 +1,77 @@ +from __future__ import absolute_import, unicode_literals + +from argparse import ArgumentTypeError + +ENV_VAR_KEY = "TOX_PARALLEL_ENV" +OFF_VALUE = 0 +DEFAULT_PARALLEL = OFF_VALUE + + +def auto_detect_cpus(): + try: + from os import sched_getaffinity # python 3 only + + def cpu_count(): + return len(sched_getaffinity(0)) + + except ImportError: + # python 2 options + try: + from os import cpu_count + except ImportError: + from multiprocessing import cpu_count + + try: + n = cpu_count() + except NotImplementedError: # pragma: no cov + n = None # pragma: no cov + return n if n else 1 + + +def parse_num_processes(s): + if s == "all": + return None + if s == "auto": + return auto_detect_cpus() + else: + value = int(s) + if value < 0: + raise ArgumentTypeError("value must be positive") + return value + + +def add_parallel_flags(parser): + parser.add_argument( + "-p", + "--parallel", + dest="parallel", + help="run tox environments in parallel, the argument controls limit: all," + " auto - cpu count, some positive number, zero is turn off", + action="store", + type=parse_num_processes, + default=DEFAULT_PARALLEL, + metavar="VAL", + ) + parser.add_argument( + "-o", + "--parallel-live", + action="store_true", + dest="parallel_live", + help="connect to stdout while running environments", + ) + + +def add_parallel_config(parser): + parser.add_testenv_attribute( + "depends", + type="env-list", + help="tox environments that this environment depends on (must be run after those)", + ) + + parser.add_testenv_attribute( + "parallel_show_output", + type="bool", + default=False, + help="if set to True the content of the output will always be shown " + "when running in parallel mode", + ) diff --git a/src/tox/session.py b/src/tox/session.py index fe54ee433..298bdc557 100644 --- a/src/tox/session.py +++ b/src/tox/session.py @@ -4,7 +4,6 @@ setup by using virtualenv. Configuration is generally done through an INI-style "tox.ini" file. """ -from __future__ import print_function import os import pipes @@ -13,15 +12,21 @@ import subprocess import sys import time +from collections import OrderedDict from contextlib import contextmanager +from threading import Event, Semaphore, Thread import pkg_resources import py import tox from tox.config import parseconfig +from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY +from tox.config.parallel import OFF_VALUE as PARALLEL_OFF from tox.result import ResultLog from tox.util import set_os_env_var +from tox.util.graph import stable_topological_sort +from tox.util.spinner import Spinner from tox.venv import VirtualEnv @@ -394,8 +399,15 @@ def __init__(self, config, popen=subprocess.Popen, Report=Reporter): self.venvlist = [self.getvenv(x) for x in self.evaluated_env_list()] except LookupError: raise SystemExit(1) - except tox.exception.ConfigError as e: - self.report.error(str(e)) + except tox.exception.ConfigError as exception: + self.report.error(str(exception)) + raise SystemExit(1) + try: + self.venv_order = stable_topological_sort( + OrderedDict((v.name, v.envconfig.depends) for v in self.venvlist) + ) + except ValueError as exception: + self.report.error("circular dependency detected: {}".format(exception)) raise SystemExit(1) self._actions = [] @@ -460,7 +472,8 @@ def cleanup(self): try: yield finally: - for tox_env in self.venvlist: + for name in self.venv_order: + tox_env = self.getvenv(name) if ( hasattr(tox_env, "package") and isinstance(tox_env.package, py.path.local) @@ -560,7 +573,8 @@ def subcommand_test(self): if self.config.skipsdist: self.report.info("skipping sdist step") else: - for venv in self.venvlist: + for name in self.venv_order: + venv = self.getvenv(name) if not venv.envconfig.skip_install: venv.package = self.hook.tox_package(session=self, venv=venv) if not venv.package: @@ -568,7 +582,18 @@ def subcommand_test(self): venv.envconfig.setenv[str("TOX_PACKAGE")] = str(venv.package) if self.config.option.sdistonly: return - for venv in self.venvlist: + + within_parallel = PARALLEL_ENV_VAR_KEY in os.environ + if not within_parallel and self.config.option.parallel != PARALLEL_OFF: + self.run_parallel() + else: + self.run_sequential() + retcode = self._summary() + return retcode + + def run_sequential(self): + for name in self.venv_order: + venv = self.getvenv(name) if self.setupenv(venv): if venv.envconfig.skip_install: self.finishvenv(venv) @@ -581,9 +606,105 @@ def subcommand_test(self): self.installpkg(venv, venv.package) self.runenvreport(venv) - self.runtestenv(venv) - retcode = self._summary() - return retcode + self.runtestenv(venv) + + def run_parallel(self): + """here we'll just start parallel sub-processes""" + live_out = self.config.option.parallel_live + args = [sys.executable, "-m", "tox"] + self.config.args + try: + position = args.index("--") + except ValueError: + position = len(args) + try: + parallel_at = args[0:position].index("--parallel") + del args[parallel_at] + position -= 1 + except ValueError: + pass + + max_parallel = self.config.option.parallel + if max_parallel is None: + max_parallel = len(self.venv_order) + semaphore = Semaphore(max_parallel) + finished = Event() + sink = None if live_out else subprocess.PIPE + + show_progress = not live_out and self.report.verbosity > Verbosity.QUIET + with Spinner(enabled=show_progress) as spinner: + + def run_in_thread(tox_env, os_env): + res = None + env_name = tox_env.envconfig.envname + try: + os_env[str(PARALLEL_ENV_VAR_KEY)] = str(env_name) + args_sub = list(args) + if hasattr(tox_env, "package"): + args_sub.insert(position, str(tox_env.package)) + args_sub.insert(position, "--installpkg") + process = subprocess.Popen( + args_sub, + env=os_env, + stdout=sink, + stderr=sink, + stdin=None, + universal_newlines=True, + ) + res = process.wait() + finally: + semaphore.release() + finished.set() + tox_env.status = ( + "skipped tests" + if self.config.option.notest + else ("parallel child exit code {}".format(res) if res else res) + ) + done.add(env_name) + report = spinner.succeed + if self.config.option.notest: + report = spinner.skip + elif res: + report = spinner.fail + report(env_name) + + if not live_out: + out, err = process.communicate() + if res or tox_env.envconfig.parallel_show_output: + outcome = ( + "Failed {} under process {}, stdout:\n".format(env_name, process.pid) + if res + else "" + ) + message = "{}{}{}".format( + outcome, out, "\nstderr:\n{}".format(err) if err else "" + ).rstrip() + self.report.logline_if(Verbosity.QUIET, message) + + threads = [] + todo_keys = set(self.venv_order) + todo = OrderedDict( + (i, todo_keys & set(self.getvenv(i).envconfig.depends)) for i in self.venv_order + ) + done = set() + while todo: + for name, depends in list(todo.items()): + if depends - done: + # skip if has unfinished dependencies + continue + del todo[name] + venv = self.getvenv(name) + semaphore.acquire(blocking=True) + spinner.add(name) + thread = Thread(target=run_in_thread, args=(venv, os.environ.copy())) + thread.start() + threads.append(thread) + if todo: + # wait until someone finishes and retry queuing jobs + finished.wait() + finished.clear() + + for thread in threads: + thread.join() def runenvreport(self, venv): """ @@ -608,40 +729,45 @@ def runtestenv(self, venv, redirect=False): self.hook.tox_runtest_post(venv=venv) def _summary(self): - self.report.startsummary() - retcode = 0 - for venv in self.venvlist: + is_parallel_child = PARALLEL_ENV_VAR_KEY in os.environ + if not is_parallel_child: + self.report.startsummary() + exit_code = 0 + for name in self.venv_order: + venv = self.getvenv(name) + reporter = self.report.good status = venv.status if isinstance(status, tox.exception.InterpreterNotFound): msg = " {}: {}".format(venv.envconfig.envname, str(status)) if self.config.option.skip_missing_interpreters == "true": - self.report.skip(msg) + reporter = self.report.skip else: - retcode = 1 - self.report.error(msg) + exit_code = 1 + reporter = self.report.error elif status == "platform mismatch": msg = " {}: {}".format(venv.envconfig.envname, str(status)) - self.report.skip(msg) + reporter = self.report.skip elif status and status == "ignored failed command": msg = " {}: {}".format(venv.envconfig.envname, str(status)) - self.report.good(msg) elif status and status != "skipped tests": msg = " {}: {}".format(venv.envconfig.envname, str(status)) - self.report.error(msg) - retcode = 1 + reporter = self.report.error + exit_code = 1 else: if not status: status = "commands succeeded" - self.report.good(" {}: {}".format(venv.envconfig.envname, status)) - if not retcode: + msg = " {}: {}".format(venv.envconfig.envname, status) + if not is_parallel_child: + reporter(msg) + if not exit_code and not is_parallel_child: self.report.good(" congratulations :)") - - path = self.config.option.resultjson - if path: - path = py.path.local(path) - path.write(self.resultlog.dumps_json()) - self.report.line("wrote json report at: {}".format(path)) - return retcode + if not is_parallel_child: + path = self.config.option.resultjson + if path: + path = py.path.local(path) + path.write(self.resultlog.dumps_json()) + self.report.line("wrote json report at: {}".format(path)) + return exit_code def showconfig(self): self.info_versions() diff --git a/src/tox/util.py b/src/tox/util/__init__.py similarity index 88% rename from src/tox/util.py rename to src/tox/util/__init__.py index 731b7ecbe..f0acbf67e 100644 --- a/src/tox/util.py +++ b/src/tox/util/__init__.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import, unicode_literals import os from contextlib import contextmanager diff --git a/src/tox/util/graph.py b/src/tox/util/graph.py new file mode 100644 index 000000000..db258409b --- /dev/null +++ b/src/tox/util/graph.py @@ -0,0 +1,68 @@ +from __future__ import absolute_import, unicode_literals + +from collections import OrderedDict, defaultdict + + +def stable_topological_sort(graph): + to_order = set(graph.keys()) # keep a log of what we need to order + + # normalize graph - fill missing nodes (assume no dependency) + for values in list(graph.values()): + for value in values: + if value not in graph: + graph[value] = tuple() + + inverse_graph = defaultdict(set) + for key, depends in graph.items(): + for depend in depends: + inverse_graph[depend].add(key) + + topology = [] + degree = {k: len(v) for k, v in graph.items()} + ready_to_visit = {n for n, d in degree.items() if not d} + need_to_visit = OrderedDict((i, None) for i in graph.keys()) + while need_to_visit: + # to keep stable, pick the first node ready to visit in the original order + for node in need_to_visit: + if node in ready_to_visit: + break + else: + break + del need_to_visit[node] + + topology.append(node) + + # decrease degree for nodes we're going too + for to_node in inverse_graph[node]: + degree[to_node] -= 1 + if not degree[to_node]: # if a node has no more incoming node it's ready to visit + ready_to_visit.add(to_node) + + result = [n for n in topology if n in to_order] # filter out missing nodes we extended + + if len(result) < len(to_order): + identify_cycle(graph) + msg = "could not order tox environments and failed to detect circle" # pragma: no cover + raise ValueError(msg) # pragma: no cover + return result + + +def identify_cycle(graph): + path = OrderedDict() + visited = set() + + def visit(vertex): + if vertex in visited: + return None + visited.add(vertex) + path[vertex] = None + for neighbour in graph.get(vertex, ()): + if neighbour in path or visit(neighbour): + return path + del path[vertex] + return None + + for node in graph: + result = visit(node) + if result is not None: + raise ValueError("{}".format(" | ".join(result.keys()))) diff --git a/src/tox/util/spinner.py b/src/tox/util/spinner.py new file mode 100644 index 000000000..870afcb0c --- /dev/null +++ b/src/tox/util/spinner.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""A minimal non-colored version of https://pypi.org/project/halo, to track list progress""" +from __future__ import absolute_import, unicode_literals + +import os +import sys +import threading +from collections import OrderedDict +from datetime import datetime + +import py + +threads = [] + +if os.name == "nt": + import ctypes + + class _CursorInfo(ctypes.Structure): + _fields_ = [("size", ctypes.c_int), ("visible", ctypes.c_byte)] + + +class Spinner(object): + CLEAR_LINE = "\033[K" + max_width = 120 + frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + def __init__(self, enabled=True, refresh_rate=0.1): + self.refresh_rate = refresh_rate + self.enabled = enabled + self._file = sys.stdout + self.stream = py.io.TerminalWriter(file=self._file) + self._envs = OrderedDict() + self._frame_index = 0 + + def clear(self): + if self.enabled: + self.stream.write("\r") + self.stream.write(self.CLEAR_LINE) + + def render(self): + while True: + self._stop_spinner.wait(self.refresh_rate) + if self._stop_spinner.is_set(): + break + self.render_frame() + return self + + def render_frame(self): + if self.enabled: + self.clear() + self.stream.write("\r{}".format(self.frame())) + + def frame(self): + frame = self.frames[self._frame_index] + self._frame_index += 1 + self._frame_index = self._frame_index % len(self.frames) + text_frame = "[{}] {}".format(len(self._envs), " | ".join(self._envs)) + if len(text_frame) > self.max_width - 1: + text_frame = "{}...".format(text_frame[: self.max_width - 1 - 3]) + return "{} {}".format(*[(frame, text_frame)][0]) + + def __enter__(self): + if self.enabled: + self.disable_cursor() + self.render_frame() + self._stop_spinner = threading.Event() + self._spinner_thread = threading.Thread(target=self.render) + self._spinner_thread.setDaemon(True) + self._spinner_thread.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if not self._stop_spinner.is_set(): + if self._spinner_thread: + self._stop_spinner.set() + self._spinner_thread.join() + + self._frame_index = 0 + if self.enabled: + self.clear() + self.enable_cursor() + + return self + + def add(self, name): + self._envs[name] = datetime.now() + + def succeed(self, key): + self.finalize(key, "✔ OK", green=True) + + def fail(self, key): + self.finalize(key, "✖ FAIL", red=True) + + def skip(self, key): + self.finalize(key, "⚠ SKIP", white=True) + + def finalize(self, key, status, **kwargs): + start_at = self._envs[key] + del self._envs[key] + if self.enabled: + self.clear() + self.stream.write( + "{} {} in {}{}".format( + status, key, td_human_readable(datetime.now() - start_at), os.linesep + ), + **kwargs + ) + if not self._envs: + self.__exit__(None, None, None) + + def disable_cursor(self): + if self._file.isatty(): + if os.name == "nt": + ci = _CursorInfo() + handle = ctypes.windll.kernel32.GetStdHandle(-11) + ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci)) + ci.visible = False + ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci)) + elif os.name == "posix": + self.stream.write("\033[?25l") + + def enable_cursor(self): + if self._file.isatty(): + if os.name == "nt": + ci = _CursorInfo() + handle = ctypes.windll.kernel32.GetStdHandle(-11) + ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci)) + ci.visible = True + ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci)) + elif os.name == "posix": + self.stream.write("\033[?25h") + + +def td_human_readable(delta): + seconds = int(delta.total_seconds()) + periods = [ + ("year", 60 * 60 * 24 * 365), + ("month", 60 * 60 * 24 * 30), + ("day", 60 * 60 * 24), + ("hour", 60 * 60), + ("minute", 60), + ("second", 1), + ] + + texts = [] + for period_name, period_seconds in periods: + if seconds > period_seconds or period_seconds == 1: + period_value, seconds = divmod(seconds, period_seconds) + if period_name == "second": + ms = delta.total_seconds() - int(delta.total_seconds()) + period_value += round(ms, 3) + has_s = "s" if period_value > 1 else "" + texts.append("{} {}{}".format(period_value, period_name, has_s)) + return ", ".join(texts) diff --git a/src/tox/venv.py b/src/tox/venv.py index 68a54439c..6712531e6 100644 --- a/src/tox/venv.py +++ b/src/tox/venv.py @@ -559,6 +559,9 @@ def prepend_shebang_interpreter(args): return args +NO_DOWNLOAD = False + + @tox.hookimpl def tox_testenv_create(venv, action): config_interpreter = venv.getsupportedinterpreter() @@ -567,6 +570,8 @@ def tox_testenv_create(venv, action): args.append("--system-site-packages") if venv.envconfig.alwayscopy: args.append("--always-copy") + if NO_DOWNLOAD: + args.append("--no-download") # add interpreter explicitly, to prevent using default (virtualenv.ini) args.extend(["--python", str(config_interpreter)]) venv.session.make_emptydir(venv.path) diff --git a/tests/conftest.py b/tests/conftest.py index ec59f4a1c..cf0821a12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,4 +2,7 @@ # TODO move fixtures here and only keep helper functions/classes in the plugin # TODO _pytest_helpers might be a better name than _pytestplugin then? # noinspection PyUnresolvedReferences +import tox.venv from tox._pytestplugin import * # noqa + +tox.venv.NO_DOWNLOAD = True diff --git a/tests/unit/test_config.py b/tests/unit/config/test_config.py similarity index 100% rename from tests/unit/test_config.py rename to tests/unit/config/test_config.py diff --git a/tests/unit/config/test_config_parallel.py b/tests/unit/config/test_config_parallel.py new file mode 100644 index 000000000..f2333a793 --- /dev/null +++ b/tests/unit/config/test_config_parallel.py @@ -0,0 +1,72 @@ +import pytest + + +def test_parallel_default(newconfig): + config = newconfig([], "") + assert isinstance(config.option.parallel, int) + assert config.option.parallel == 0 + assert config.option.parallel_live is False + + +def test_parallel_live_on(newconfig): + config = newconfig(["-o"], "") + assert config.option.parallel_live is True + + +def test_parallel_auto(newconfig): + config = newconfig(["-p", "auto"], "") + assert isinstance(config.option.parallel, int) + assert config.option.parallel > 0 + + +def test_parallel_all(newconfig): + config = newconfig(["-p", "all"], "") + assert config.option.parallel is None + + +def test_parallel_number(newconfig): + config = newconfig(["-p", "2"], "") + assert config.option.parallel == 2 + + +def test_parallel_number_negative(newconfig, capsys): + with pytest.raises(SystemExit): + newconfig(["-p", "-1"], "") + + out, err = capsys.readouterr() + assert not out + assert "value must be positive" in err + + +def test_depends(newconfig, capsys): + config = newconfig( + """\ + [tox] + [testenv:py] + depends = py37, py36 + """ + ) + assert config.envconfigs["py"].depends == ("py37", "py36") + + +def test_depends_multi_row_facotr(newconfig, capsys): + config = newconfig( + """\ + [tox] + [testenv:py] + depends = py37, + {py36}-{a,b} + """ + ) + assert config.envconfigs["py"].depends == ("py37", "py36-a", "py36-b") + + +def test_depends_factor(newconfig, capsys): + config = newconfig( + """\ + [tox] + [testenv:py] + depends = {py37, py36}-{cov,no} + """ + ) + assert config.envconfigs["py"].depends == ("py37-cov", "py37-no", "py36-cov", "py36-no") diff --git a/tests/unit/session/test_parallel.py b/tests/unit/session/test_parallel.py new file mode 100644 index 000000000..bbc7b1c3e --- /dev/null +++ b/tests/unit/session/test_parallel.py @@ -0,0 +1,109 @@ +from __future__ import absolute_import, unicode_literals + +import os + + +def test_parallel(cmd, initproj): + initproj( + "pkg123-0.7", + filedefs={ + "tox.ini": """ + [tox] + envlist = a, b + isolated_build = true + [testenv] + commands=python -c "import sys; print(sys.executable)" + [testenv:b] + depends = a + """, + "pyproject.toml": """ + [build-system] + requires = ["setuptools >= 35.0.2"] + build-backend = 'setuptools.build_meta' + """, + }, + ) + result = cmd("--parallel", "all") + assert result.ret == 0, "{}{}{}".format(result.err, os.linesep, result.out) + + +def test_parallel_live(cmd, initproj): + initproj( + "pkg123-0.7", + filedefs={ + "tox.ini": """ + [tox] + isolated_build = true + envlist = a, b + [testenv] + commands=python -c "import sys; print(sys.executable)" + """, + "pyproject.toml": """ + [build-system] + requires = ["setuptools >= 35.0.2"] + build-backend = 'setuptools.build_meta' + """, + }, + ) + result = cmd("--parallel", "all", "--parallel-live") + assert result.ret == 0, "{}{}{}".format(result.err, os.linesep, result.out) + + +def test_parallel_circular(cmd, initproj): + initproj( + "pkg123-0.7", + filedefs={ + "tox.ini": """ + [tox] + isolated_build = true + envlist = a, b + [testenv:a] + depends = b + [testenv:b] + depends = a + """, + "pyproject.toml": """ + [build-system] + requires = ["setuptools >= 35.0.2"] + build-backend = 'setuptools.build_meta' + """, + }, + ) + result = cmd("--parallel", "1") + assert result.ret == 1, result.out + assert result.out == "ERROR: circular dependency detected: a | b\n" + + +def test_parallel_error_report(cmd, initproj): + initproj( + "pkg123-0.7", + filedefs={ + "tox.ini": """ + [tox] + isolated_build = true + envlist = a + [testenv] + commands=python -c "import sys, os; sys.stderr.write(str(12345) + os.linesep);\ + raise SystemExit(17)" + """, + "pyproject.toml": """ + [build-system] + requires = ["setuptools >= 35.0.2"] + build-backend = 'setuptools.build_meta' + """, + }, + ) + result = cmd("-p", "all") + msg = "{}{}{}".format(result.err, os.linesep, result.out) + assert result.ret == 1, msg + # we print output + assert "(exited with code 17)" in result.out, msg + assert "Failed a under process " in result.out, msg + + assert any(line for line in result.outlines if line == "12345") + + # single summary at end + summary_lines = [j for j, l in enumerate(result.outlines) if " summary " in l] + assert len(summary_lines) == 1, msg + + assert result.outlines[summary_lines[0] + 1 :] == ["ERROR: a: parallel child exit code 1"] diff --git a/tests/unit/util/test_graph.py b/tests/unit/util/test_graph.py new file mode 100644 index 000000000..c2dbdaad1 --- /dev/null +++ b/tests/unit/util/test_graph.py @@ -0,0 +1,64 @@ +from collections import OrderedDict + +import pytest + +from tox.util.graph import stable_topological_sort + + +def test_topological_order_specified_only(): + graph = OrderedDict() + graph["A"] = "B", "C" + result = stable_topological_sort(graph) + assert result == ["A"] + + +def test_topological_order(): + graph = OrderedDict() + graph["A"] = "B", "C" + graph["B"] = tuple() + graph["C"] = tuple() + result = stable_topological_sort(graph) + assert result == ["B", "C", "A"] + + +def test_topological_order_cycle(): + graph = OrderedDict() + graph["A"] = "B", "C" + graph["B"] = ("A",) + with pytest.raises(ValueError, match="A | B"): + stable_topological_sort(graph) + + +def test_topological_complex(): + graph = OrderedDict() + graph["A"] = "B", "C" + graph["B"] = "C", "D" + graph["C"] = ("D",) + graph["D"] = tuple() + result = stable_topological_sort(graph) + assert result == ["D", "C", "B", "A"] + + +def test_two_sub_graph(): + graph = OrderedDict() + graph["F"] = tuple() + graph["E"] = tuple() + graph["D"] = "E", "F" + graph["A"] = "B", "C" + graph["B"] = tuple() + graph["C"] = tuple() + + result = stable_topological_sort(graph) + assert result == ["F", "E", "D", "B", "C", "A"] + + +def test_two_sub_graph_circle(): + graph = OrderedDict() + graph["F"] = tuple() + graph["E"] = tuple() + graph["D"] = "E", "F" + graph["A"] = "B", "C" + graph["B"] = ("A",) + graph["C"] = tuple() + with pytest.raises(ValueError, match="A | B"): + stable_topological_sort(graph) diff --git a/tests/unit/util/test_spinner.py b/tests/unit/util/test_spinner.py new file mode 100644 index 000000000..b53de10bd --- /dev/null +++ b/tests/unit/util/test_spinner.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import os +import sys +import time + +from freezegun import freeze_time + +from tox.util import spinner + + +@freeze_time("2012-01-14") +def test_spinner(capfd, monkeypatch): + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner(refresh_rate=100) as spin: + for _ in range(len(spin.frames)): + spin.stream.write("\n") + spin.render_frame() + spin.stream.write("\n") + out, err = capfd.readouterr() + lines = out.split("\n") + expected = ["\r{}\r{} [0] ".format(spin.CLEAR_LINE, i) for i in spin.frames] + [ + "\r{}\r{} [0] ".format(spin.CLEAR_LINE, spin.frames[0]), + "\r{}".format(spin.CLEAR_LINE), + ] + assert lines == expected + + +@freeze_time("2012-01-14") +def test_spinner_progress(capfd, monkeypatch): + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner() as spin: + for _ in range(len(spin.frames)): + spin.stream.write("\n") + time.sleep(spin.refresh_rate) + + out, err = capfd.readouterr() + assert not err + assert len({i.strip() for i in out.split("[0]")}) > len(spin.frames) / 2 + + +@freeze_time("2012-01-14") +def test_spinner_atty(capfd, monkeypatch): + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + with spinner.Spinner(refresh_rate=100) as spin: + spin.stream.write("\n") + out, err = capfd.readouterr() + lines = out.split("\n") + posix = os.name == "posix" + expected = [ + "{}\r{}\r{} [0] ".format("\x1b[?25l" if posix else "", spin.CLEAR_LINE, spin.frames[0]), + "\r\x1b[K{}".format("\x1b[?25h" if posix else ""), + ] + assert lines == expected + + +@freeze_time("2012-01-14") +def test_spinner_report(capfd, monkeypatch): + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner(refresh_rate=100) as spin: + spin.stream.write(os.linesep) + spin.add("ok") + spin.add("fail") + spin.add("skip") + spin.succeed("ok") + spin.fail("fail") + spin.skip("skip") + out, err = capfd.readouterr() + lines = out.split(os.linesep) + del lines[0] + expected = [ + "\r{}✔ OK ok in 0.0 second".format(spin.CLEAR_LINE), + "\r{}✖ FAIL fail in 0.0 second".format(spin.CLEAR_LINE), + "\r{}⚠ SKIP skip in 0.0 second".format(spin.CLEAR_LINE), + "\r{}".format(spin.CLEAR_LINE), + ] + assert lines == expected + assert not err + + +def test_spinner_long_text(capfd, monkeypatch): + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner(refresh_rate=100) as spin: + spin.stream.write("\n") + spin.add("a" * 60) + spin.add("b" * 60) + spin.render_frame() + spin.stream.write("\n") + out, err = capfd.readouterr() + assert not err + expected = [ + "\r{}\r{} [2] {} | {}...".format(spin.CLEAR_LINE, spin.frames[1], "a" * 60, "b" * 49), + "\r{}".format(spin.CLEAR_LINE), + ] + lines = out.split("\n") + del lines[0] + assert lines == expected diff --git a/tests/unit/test_util.py b/tests/unit/util/test_util.py similarity index 100% rename from tests/unit/test_util.py rename to tests/unit/util/test_util.py diff --git a/tox.ini b/tox.ini index 3d0e14cac..57d6725c7 100644 --- a/tox.ini +++ b/tox.ini @@ -29,7 +29,6 @@ commands = pytest {posargs:\ --junitxml={env:JUNIT_XML_FILE:{toxworkdir}/.test.{envname}.xml} \ . } - [testenv:docs] description = invoke sphinx-build to build the HTML docs basepython = python3.7 @@ -80,6 +79,8 @@ commands = coverage erase coverage xml -o {toxworkdir}/coverage.xml coverage html -d {toxworkdir}/htmlcov diff-cover --compare-branch {env:DIFF_AGAINST:origin/master} {toxworkdir}/coverage.xml +depends = py27, py34, py35, py36, py37, pypy, pypy3 +parallel_show_output = True [testenv:coveralls] description = [only run on CI]: upload coverage data to codecov (depends on coverage running first) @@ -145,7 +146,7 @@ include_trailing_comma = True force_grid_wrap = 0 line_length = 99 known_first_party = tox,tests -known_third_party = apiclient,docutils,filelock,git,httplib2,oauth2client,packaging,pkg_resources,pluggy,py,pytest,setuptools,six,sphinx,toml +known_third_party = apiclient,docutils,filelock,freezegun,git,httplib2,oauth2client,packaging,pkg_resources,pluggy,py,pytest,setuptools,six,sphinx,toml [testenv:release] description = do a release, required posarg of the version number