Skip to content

Commit

Permalink
implement tox environment provisioning (#1185)
Browse files Browse the repository at this point in the history
Resolves #998.
  • Loading branch information
gaborbernat authored Mar 12, 2019
1 parent d21b14d commit 5b68897
Show file tree
Hide file tree
Showing 15 changed files with 318 additions and 146 deletions.
2 changes: 2 additions & 0 deletions docs/changelog/998.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tox now auto-provisions itself if needed (see :ref:`auto-provision`). Plugins or minimum version of tox no longer
need to be manually satisfied by the user, increasing their ease of use.
45 changes: 27 additions & 18 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,31 @@ Global settings are defined under the ``tox`` section as:
.. conf:: minversion

Define the minimal tox version required to run; if the host tox is less than this
the tool with exit with an error message indicating the user needs to upgrade tox.
the tool with create an environment and provision it with a tox that satisfies it
under :conf:`provision_tox_env`.

.. conf:: requires ^ LIST of PEP-508

.. versionadded:: 3.2.0

Specify python packages that need to exist alongside the tox installation for the tox build
to be able to start. Use this to specify plugin requirements (or the version of ``virtualenv`` -
determines the default ``pip``, ``setuptools``, and ``wheel`` versions the tox environments
start with). If these dependencies are not specified tox will create :conf:`provision_tox_env`
environment so that they are satisfied and delegate all calls to that.

.. code-block:: ini
[tox]
requires = tox-venv
setuptools >= 30.0.0
.. conf:: provision_tox_env ^ string ^ .tox

.. versionadded:: 3.8.0

Name of the virtual environment used to provision a tox having all dependencies specified
inside :conf:`requires` and :conf:`minversion`.

.. conf:: toxworkdir ^ PATH ^ {toxinidir}/.tox

Expand Down Expand Up @@ -122,23 +146,6 @@ Global settings are defined under the ``tox`` section as:
configure :conf:`basepython` in the global testenv without affecting environments
that have implied base python versions.

.. conf:: requires ^ LIST of PEP-508

.. versionadded:: 3.2.0

Specify python packages that need to exist alongside the tox installation for the tox build
to be able to start. Use this to specify plugin requirements and build dependencies.

.. code-block:: ini
[tox]
requires = tox-venv
setuptools >= 30.0.0
.. note:: tox does **not** install those required packages for you. tox only checks if the
requirements are satisfied and crashes early with an helpful error rather then later
in the process.

.. conf:: isolated_build ^ true|false ^ false

.. versionadded:: 3.3.0
Expand Down Expand Up @@ -218,6 +225,7 @@ Complete list of settings that you can put into ``testenv*`` sections:
Use this to specify the python version for a tox environment. If not specified, the virtual
environments factors (e.g. name part) will be used to automatically set one. For example, ``py37``
means ``python3.7``, ``py3`` means ``python3`` and ``py`` means ``python``.
:conf:`provision_tox_env` environment does not inherit this setting from the ``toxenv`` section.

.. versionchanged:: 3.1

Expand All @@ -226,6 +234,7 @@ Complete list of settings that you can put into ``testenv*`` sections:
:conf:`ignore_basepython_conflict` is set, the value is ignored and we force the
``basepython`` implied from the factor name.


.. conf:: commands ^ ARGVLIST

The commands to be called for testing. Only execute if :conf:`commands_pre` succeed.
Expand Down
26 changes: 26 additions & 0 deletions docs/example/basic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,29 @@ Example progress bar, showing a rotating spinner, the number of environments run
.. code-block:: bash
⠹ [2] py27 | py36
.. _`auto-provision`:

tox auto-provisioning
---------------------
In case the host tox does not satisfy either the :conf:`minversion` or the :conf:`requires`, tox will now automatically
create a virtual environment under :conf:`provision_tox_env` that satisfies those constraints and delegate all calls
to this meta environment. This should allow automatically satisfying constraints on your tox environment,
given you have at least version ``3.8.0`` of tox.

For example given:

.. code-block:: ini
[tox]
minversion = 3.10.0
requires = tox_venv >= 1.0.0
if the user runs it with tox ``3.8.0`` or later installed tox will automatically ensured that both the minimum version
and requires constraints are satisfied, by creating a virtual environment under ``.tox`` folder, and then installing
into it ``tox >= 3.10.0`` and ``tox_venv >= 1.0.0``. Afterwards all tox invocations are forwarded to the tox installed
inside ``.tox\.tox`` folder (referred to as meta-tox or auto-provisioned tox).

This allows tox to automatically setup itself with all its plugins for the current project. If the host tox satisfies
the constraints expressed with the :conf:`requires` and :conf:`minversion` no such provisioning is done (to avoid
setup cost when it's not explicitly needed).
1 change: 0 additions & 1 deletion src/tox/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ def feed_stdin(self, fin, process, redirect):
else:
out, err = process.communicate()
except KeyboardInterrupt:
reporter.error("KEYBOARDINTERRUPT")
process.wait()
raise
return out
Expand Down
108 changes: 55 additions & 53 deletions src/tox/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import tox
from tox.constants import INFO
from tox.interpreters import Interpreters, NoInterpreterInfo
from tox.reporter import update_default_reporter

from .parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY
from .parallel import add_parallel_config, add_parallel_flags
Expand Down Expand Up @@ -227,6 +228,7 @@ def parseconfig(args, plugins=()):
"""
pm = get_plugin_manager(plugins)
config, option = parse_cli(args, pm)
update_default_reporter(config.option.quiet_level, config.option.verbose_level)

for config_file in propose_configs(option.configfile):
config_type = config_file.basename
Expand Down Expand Up @@ -572,7 +574,7 @@ def basepython_default(testenv_config, value):

parser.add_testenv_attribute(
name="basepython",
type="string",
type="basepython",
default=None,
postprocess=basepython_default,
help="executable name or path of interpreter used to create a virtual test environment.",
Expand Down Expand Up @@ -977,25 +979,6 @@ def __init__(self, config, ini_path, ini_data): # noqa

reader.addsubstitutions(toxinidir=config.toxinidir, homedir=config.homedir)

# As older versions of tox may have bugs or incompatibilities that
# prevent parsing of tox.ini this must be the first thing checked.
config.minversion = reader.getstring("minversion", None)
if config.minversion:
# As older versions of tox may have bugs or incompatibilities that
# prevent parsing of tox.ini this must be the first thing checked.
config.minversion = reader.getstring("minversion", None)
if config.minversion:
tox_version = pkg_resources.parse_version(tox.__version__)
config_min_version = pkg_resources.parse_version(self.config.minversion)
if config_min_version > tox_version:
raise tox.exception.MinVersionError(
"tox version is {}, required is at least {}".format(
tox.__version__, self.config.minversion
)
)

self.ensure_requires_satisfied(reader.getlist("requires"))

if config.option.workdir is None:
config.toxworkdir = reader.getpath("toxworkdir", "{toxinidir}/.tox")
else:
Expand All @@ -1004,19 +987,30 @@ def __init__(self, config, ini_path, ini_data): # noqa
if os.path.exists(str(config.toxworkdir)):
config.toxworkdir = config.toxworkdir.realpath()

if config.option.skip_missing_interpreters == "config":
val = reader.getbool("skip_missing_interpreters", False)
config.option.skip_missing_interpreters = "true" if val else "false"

reader.addsubstitutions(toxworkdir=config.toxworkdir)
config.ignore_basepython_conflict = reader.getbool("ignore_basepython_conflict", False)

config.distdir = reader.getpath("distdir", "{toxworkdir}/dist")

reader.addsubstitutions(distdir=config.distdir)
config.distshare = reader.getpath("distshare", dist_share_default)
config.temp_dir = reader.getpath("temp_dir", "{toxworkdir}/.tmp")
reader.addsubstitutions(distshare=config.distshare)
config.sdistsrc = reader.getpath("sdistsrc", None)
config.setupdir = reader.getpath("setupdir", "{toxinidir}")
config.logdir = config.toxworkdir.join("log")

# determine indexserver dictionary
config.indexserver = {"default": IndexServerConfig("default")}
prefix = "indexserver"
for line in reader.getlist(prefix):
name, url = map(lambda x: x.strip(), line.split("=", 1))
config.indexserver[name] = IndexServerConfig(name, url)

if config.option.skip_missing_interpreters == "config":
val = reader.getbool("skip_missing_interpreters", False)
config.option.skip_missing_interpreters = "true" if val else "false"

override = False
if config.option.indexurl:
for url_def in config.option.indexurl:
Expand All @@ -1037,16 +1031,7 @@ def __init__(self, config, ini_path, ini_data): # noqa
for name in config.indexserver:
config.indexserver[name] = IndexServerConfig(name, override)

reader.addsubstitutions(toxworkdir=config.toxworkdir)
config.distdir = reader.getpath("distdir", "{toxworkdir}/dist")

reader.addsubstitutions(distdir=config.distdir)
config.distshare = reader.getpath("distshare", dist_share_default)
config.temp_dir = reader.getpath("temp_dir", "{toxworkdir}/.tmp")
reader.addsubstitutions(distshare=config.distshare)
config.sdistsrc = reader.getpath("sdistsrc", None)
config.setupdir = reader.getpath("setupdir", "{toxinidir}")
config.logdir = config.toxworkdir.join("log")
self.handle_provision(config, reader)

self.parse_build_isolation(config, reader)
config.envlist, all_envs = self._getenvdata(reader, config)
Expand Down Expand Up @@ -1080,32 +1065,43 @@ def __init__(self, config, ini_path, ini_data): # noqa

config.skipsdist = reader.getbool("skipsdist", all_develop)

def parse_build_isolation(self, config, reader):
config.isolated_build = reader.getbool("isolated_build", False)
config.isolated_build_env = reader.getstring("isolated_build_env", ".package")
if config.isolated_build is True:
name = config.isolated_build_env
if name not in config.envconfigs:
config.envconfigs[name] = self.make_envconfig(
name, "{}{}".format(testenvprefix, name), reader._subs, config
)
def handle_provision(self, config, reader):
requires_list = reader.getlist("requires")
config.minversion = reader.getstring("minversion", None)
requires_list.append("tox >= {}".format(config.minversion or tox.__version__))
config.provision_tox_env = name = reader.getstring("provision_tox_env", ".tox")
env_config = self.make_envconfig(
name, "{}{}".format(testenvprefix, name), reader._subs, config
)
env_config.deps = [DepConfig(r, None) for r in requires_list]
self.ensure_requires_satisfied(config, env_config)

@staticmethod
def ensure_requires_satisfied(specified):
def ensure_requires_satisfied(config, env_config):
missing_requirements = []
for s in specified:
deps = env_config.deps
for require in deps:
# noinspection PyBroadException
try:
pkg_resources.get_distribution(s)
pkg_resources.get_distribution(require.name)
except pkg_resources.RequirementParseError:
raise
except Exception:
missing_requirements.append(str(pkg_resources.Requirement(s)))
missing_requirements.append(str(pkg_resources.Requirement(require.name)))
config.run_provision = bool(missing_requirements)
if missing_requirements:
raise tox.exception.MissingRequirement(
"Packages {} need to be installed alongside tox in {}".format(
", ".join(missing_requirements), sys.executable
config.envconfigs[config.provision_tox_env] = env_config
raise tox.exception.MissingRequirement(config)

def parse_build_isolation(self, config, reader):
config.isolated_build = reader.getbool("isolated_build", False)
config.isolated_build_env = reader.getstring("isolated_build_env", ".package")
if config.isolated_build is True:
name = config.isolated_build_env
if name not in config.envconfigs:
config.envconfigs[name] = self.make_envconfig(
name, "{}{}".format(testenvprefix, name), reader._subs, config
)
)

def _list_section_factors(self, section):
factors = set()
Expand All @@ -1132,6 +1128,11 @@ def make_envconfig(self, name, section, subs, config, replace=True):
if atype in ("bool", "path", "string", "dict", "dict_setenv", "argv", "argvlist"):
meth = getattr(reader, "get{}".format(atype))
res = meth(env_attr.name, env_attr.default, replace=replace)
elif atype == "basepython":
no_fallback = name in (config.provision_tox_env,)
res = reader.getstring(
env_attr.name, env_attr.default, replace=replace, no_fallback=no_fallback
)
elif atype == "space-separated-list":
res = reader.getlist(env_attr.name, sep=" ")
elif atype == "line-list":
Expand Down Expand Up @@ -1363,9 +1364,10 @@ def getargvlist(self, name, default="", replace=True):
def getargv(self, name, default="", replace=True):
return self.getargvlist(name, default, replace=replace)[0]

def getstring(self, name, default=None, replace=True, crossonly=False):
def getstring(self, name, default=None, replace=True, crossonly=False, no_fallback=False):
x = None
for s in [self.section_name] + self.fallbacksections:
sections = [self.section_name] + ([] if no_fallback else self.fallbacksections)
for s in sections:
try:
x = self._cfg[s][name]
break
Expand Down
11 changes: 5 additions & 6 deletions src/tox/exception.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import pipes
import signal


Expand Down Expand Up @@ -80,10 +81,8 @@ class MissingDependency(Error):
class MissingRequirement(Error):
"""A requirement defined in :config:`require` is not met."""

def __init__(self, config):
self.config = config

class MinVersionError(Error):
"""The installed tox version is lower than requested minversion."""

def __init__(self, message):
self.message = message
super(MinVersionError, self).__init__(message)
def __str__(self):
return " ".join(pipes.quote(i) for i in self.config.requires)
Loading

0 comments on commit 5b68897

Please sign in to comment.