Repository: tox-dev/tox
Pull request #936 (2018-08-09 11:25:20)

Ability to specify package requirements for the tox run #783
Done via the ``tox.ini`` (``tox`` section under key ``requires`` - PEP-508
style). Can be used to specify both plugin requirements or build
dependencies until we'll have PEP-517 support.

Using ``pkg_resources.Requirement``. Note tox will refuse to run if
cannot satisfy dependencies. It's the users responsibility to ensure
dependencies are satisfied. ``pkg_resources`` is part of setuptools so
added setuptools as dependency.

Realized pkg_resources contain everything we used from packaging,
dropped packaging as a dependency.



Patch:
diff --git a/changelog/783.feature.rst b/changelog/783.feature.rst
new file mode 100644
index 00000000..c49c8e35
--- /dev/null
+++ b/changelog/783.feature.rst
@@ -0,0 +1,1 @@
+Ability to specify package requirements for the tox run via the ``tox.ini`` (``tox`` section under key ``requires`` - PEP-508 style): can be used to specify both plugin requirements or build dependencies. - by :user:`gaborbernat`
diff --git a/changelog/929.feature.rst b/changelog/929.feature.rst
deleted file mode 100644
index e95c2942..00000000
--- a/changelog/929.feature.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Call virtualenv by invoking directly the virtualenv file (after import resolve) instead of ``-m`` (allows pex packaging tox) - trial accepted. - by :user:`zsimic`
diff --git a/doc/config.rst b/doc/config.rst
index e4c5a0a8..ed4b9d42 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -77,6 +77,17 @@ and will first lookup global tox settings in this section:
     is identified. In a future version of tox, this warning will become an
     error.
 
+.. confval:: requires=LIST
+
+    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 = setuptools >= 30.0.0
+                   py
+
 
 Virtualenv test environment settings
 ------------------------------------
diff --git a/setup.py b/setup.py
index 9504708e..100cc001 100644
--- a/setup.py
+++ b/setup.py
@@ -61,7 +61,7 @@ def main():
         python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*",
         setup_requires=["setuptools_scm"],
         install_requires=[
-            "packaging  >= 17.1",
+            "setuptools  >= 30.0.0",
             "pluggy >= 0.3.0, <1",
             "py >= 1.4.17, <2",
             "six >= 1.0.0, <2",
diff --git a/src/tox/config.py b/src/tox/config.py
index 43c80d39..22eabf97 100755
--- a/src/tox/config.py
+++ b/src/tox/config.py
@@ -16,7 +16,6 @@ from subprocess import list2cmdline
 import pkg_resources
 import pluggy
 import py
-from packaging.version import parse
 
 import tox
 from tox.interpreters import Interpreters
@@ -908,14 +907,17 @@ class parseini:
         # prevent parsing of tox.ini this must be the first thing checked.
         config.minversion = reader.getstring("minversion", None)
         if config.minversion:
-            tox_version = parse(tox.__version__)
-            config_min_version = parse(self.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:
@@ -1000,6 +1002,22 @@ class parseini:
 
         config.skipsdist = reader.getbool("skipsdist", all_develop)
 
+    @staticmethod
+    def ensure_requires_satisfied(specified):
+        fail = False
+        for s in specified:
+            try:
+                pkg_resources.get_distribution(s)
+            except pkg_resources.RequirementParseError:
+                raise
+            except Exception:
+                fail = True
+                print(
+                    "requirement missing {}".format(pkg_resources.Requirement(s)), file=sys.stderr
+                )
+        if fail:
+            raise RuntimeError("not all requirements satisfied, install them alongside tox")
+
     def _list_section_factors(self, section):
         factors = set()
         if section in self._cfg:
diff --git a/src/tox/session.py b/src/tox/session.py
index c1001f1d..5c7d8a74 100644
--- a/src/tox/session.py
+++ b/src/tox/session.py
@@ -13,13 +13,13 @@ import subprocess
 import sys
 import time
 
+import pkg_resources
 import py
-from packaging.version import InvalidVersion, Version
 
 import tox
 from tox.config import parseconfig
 from tox.result import ResultLog
-from tox.venv import VIRTUALENV_FILE, VirtualEnv
+from tox.venv import VirtualEnv
 
 
 def prepare(args):
@@ -728,7 +728,7 @@ class Session:
     def info_versions(self):
         versions = ["tox-{}".format(tox.__version__)]
         proc = subprocess.Popen(
-            (sys.executable, VIRTUALENV_FILE, "--version"), stdout=subprocess.PIPE
+            (sys.executable, "-m", "virtualenv", "--version"), stdout=subprocess.PIPE
         )
         out, _ = proc.communicate()
         versions.append("virtualenv-{}".format(out.decode("UTF-8").strip()))
@@ -779,6 +779,7 @@ def get_version_from_filename(basename):
         return None
     version = m.group(1)
     try:
-        return Version(version)
-    except InvalidVersion:
+
+        return pkg_resources.packaging.version.Version(version)
+    except pkg_resources.packaging.version.InvalidVersion:
         return None
diff --git a/src/tox/venv.py b/src/tox/venv.py
index 93a8a4da..b4e70984 100755
--- a/src/tox/venv.py
+++ b/src/tox/venv.py
@@ -7,17 +7,11 @@ import sys
 import warnings
 
 import py
-import virtualenv
 
 import tox
 
 from .config import DepConfig
 
-# Use virtualenv that comes with tox, don't assume other installed pythons have virtualenv
-VIRTUALENV_FILE = virtualenv.__file__
-if VIRTUALENV_FILE.endswith(".pyc"):
-    VIRTUALENV_FILE = VIRTUALENV_FILE[:-1]
-
 
 class CreationConfig:
     def __init__(self, md5, python, version, sitepackages, usedevelop, deps, alwayscopy):
@@ -476,7 +470,7 @@ def prepend_shebang_interpreter(args):
 @tox.hookimpl
 def tox_testenv_create(venv, action):
     config_interpreter = venv.getsupportedinterpreter()
-    args = [sys.executable, VIRTUALENV_FILE]
+    args = [sys.executable, "-m", "virtualenv"]
     if venv.envconfig.sitepackages:
         args.append("--system-site-packages")
     if venv.envconfig.alwayscopy:
diff --git a/tox.ini b/tox.ini
index d10b0ea8..e6d76ec0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -126,7 +126,7 @@ include_trailing_comma = True
 force_grid_wrap = 0
 line_length = 99
 known_first_party = tox
-known_third_party = apiclient,git,httplib2,oauth2client,packaging,pkg_resources,pluggy,py,pytest,setuptools,six,virtualenv
+known_third_party = apiclient,git,httplib2,oauth2client,packaging,pkg_resources,pluggy,py,pytest,setuptools,six
 
 [testenv:release]
 description = do a release, required posarg of the version number


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 496c4a35..a21abd85 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -2716,3 +2716,25 @@ class TestCommandParser:
         )
         envconfig = config.envconfigs["py36"]
         assert envconfig.commands[0] == ["some", r"hello\world"]
+
+
+def test_plugin_require(newconfig, capsys):
+    inisource = """
+        [tox]
+        requires = tox
+                   name[foo,bar]>=2,<3; python_version>"2.0" and os_name=='a'
+                   b
+    """
+    with pytest.raises(
+        RuntimeError, match="not all requirements satisfied, install them alongside tox"
+    ):
+        newconfig([], inisource)
+
+    out, err = capsys.readouterr()
+    assert err.strip() == "\n".join(
+        [
+            'requirement missing name[bar,foo]<3,>=2; python_version > "2.0" and os_name == "a"',
+            "requirement missing b",
+        ]
+    )
+    assert not out
diff --git a/tests/test_venv.py b/tests/test_venv.py
index 387a9688..5d4b4949 100644
--- a/tests/test_venv.py
+++ b/tests/test_venv.py
@@ -68,7 +68,7 @@ def test_create(mocksession, newconfig):
     pcalls = mocksession._pcalls
     assert len(pcalls) >= 1
     args = pcalls[0].args
-    assert str(args[1]).endswith("virtualenv.py")
+    assert "virtualenv" == str(args[2])
     if not tox.INFO.IS_WIN:
         # realpath is needed for stuff like the debian symlinks
         our_sys_path = py.path.local(sys.executable).realpath()
@@ -443,7 +443,7 @@ def test_install_python3(newmocksession):
     pcalls = mocksession._pcalls
     assert len(pcalls) == 1
     args = pcalls[0].args
-    assert str(args[1]).endswith("virtualenv.py")
+    assert str(args[2]) == "virtualenv"
     pcalls[:] = []
     action = mocksession.newaction(venv, "hello")
     venv._install(["hello"], action=action)
diff --git a/tests/test_z_cmdline.py b/tests/test_z_cmdline.py
index 68b98b04..42104c97 100644
--- a/tests/test_z_cmdline.py
+++ b/tests/test_z_cmdline.py
@@ -687,7 +687,7 @@ def test_alwayscopy(initproj, cmd):
     )
     result = cmd("-vv")
     assert not result.ret
-    assert "virtualenv.py --always-copy" in result.out
+    assert "virtualenv --always-copy" in result.out
 
 
 def test_alwayscopy_default(initproj, cmd):
@@ -702,7 +702,7 @@ def test_alwayscopy_default(initproj, cmd):
     )
     result = cmd("-vv")
     assert not result.ret
-    assert "virtualenv.py --always-copy" not in result.out
+    assert "virtualenv --always-copy" not in result.out
 
 
 def test_empty_activity_ignored(initproj, cmd):