diff --git a/src/tox/config/__init__.py b/src/tox/config/__init__.py index c21222c..93805ba 100644 --- a/src/tox/config/__init__.py +++ b/src/tox/config/__init__.py @@ -2,6 +2,7 @@ from __future__ import print_function import argparse import itertools +import json import os import random import re @@ -538,6 +539,16 @@ def tox_addoption(parser): action="store_true", help="override alwayscopy setting to True in all envs", ) + parser.add_argument( + "--no-provision", + action="store", + nargs="?", + default=False, + const=True, + metavar="REQUIRES_JSON", + help="do not perform provision, but fail and if a path was provided " + "write provision metadata as JSON to it", + ) cli_skip_missing_interpreter(parser) parser.add_argument("--workdir", metavar="PATH", help="tox working directory") @@ -1234,11 +1245,11 @@ class ParseIni(object): feedback("--devenv requires only a single -e", sysexit=True) def handle_provision(self, config, reader): - requires_list = reader.getlist("requires") + config.requires = reader.getlist("requires") config.minversion = reader.getstring("minversion", None) config.provision_tox_env = name = reader.getstring("provision_tox_env", ".tox") min_version = "tox >= {}".format(config.minversion or tox.__version__) - deps = self.ensure_requires_satisfied(config, requires_list, min_version) + deps = self.ensure_requires_satisfied(config, config.requires, min_version) if config.run_provision: section_name = "testenv:{}".format(name) if section_name not in self._cfg.sections: @@ -1254,8 +1265,8 @@ class ParseIni(object): # raise on unknown args self.config._parser.parse_cli(args=self.config.args, strict=True) - @staticmethod - def ensure_requires_satisfied(config, requires, min_version): + @classmethod + def ensure_requires_satisfied(cls, config, requires, min_version): missing_requirements = [] failed_to_parse = False deps = [] @@ -1282,12 +1293,33 @@ class ParseIni(object): missing_requirements.append(str(requirements.Requirement(require))) if failed_to_parse: raise tox.exception.BadRequirement() + if config.option.no_provision and missing_requirements: + msg = "provisioning explicitly disabled within {}, but missing {}" + if config.option.no_provision is not True: # it's a path + msg += " and wrote to {}" + cls.write_requires_to_json_file(config) + raise tox.exception.Error( + msg.format(sys.executable, missing_requirements, config.option.no_provision) + ) if WITHIN_PROVISION and missing_requirements: msg = "break infinite loop provisioning within {} missing {}" raise tox.exception.Error(msg.format(sys.executable, missing_requirements)) config.run_provision = bool(len(missing_requirements)) return deps + @staticmethod + def write_requires_to_json_file(config): + requires_dict = { + "minversion": config.minversion, + "requires": config.requires, + } + try: + with open(config.option.no_provision, "w", encoding="utf-8") as outfile: + json.dump(requires_dict, outfile, indent=4) + except TypeError: # Python 2 + with open(config.option.no_provision, "w") as outfile: + json.dump(requires_dict, outfile, indent=4, encoding="utf-8") + 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") diff --git a/tests/unit/session/test_provision.py b/tests/unit/session/test_provision.py index aa631c0..710df60 100644 --- a/tests/unit/session/test_provision.py +++ b/tests/unit/session/test_provision.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import json import os import shutil import subprocess @@ -42,6 +43,35 @@ def test_provision_min_version_is_requires(newconfig, next_tox_major): assert config.ignore_basepython_conflict is False +def test_provision_config_has_minversion_and_requires(newconfig, next_tox_major): + with pytest.raises(MissingRequirement) as context: + newconfig( + [], + """\ + [tox] + minversion = {} + requires = + setuptools > 2 + pip > 3 + """.format( + next_tox_major, + ), + ) + config = context.value.config + + assert config.run_provision is True + assert config.minversion == next_tox_major + assert config.requires == ["setuptools > 2", "pip > 3"] + + +def test_provision_config_empty_minversion_and_requires(newconfig, next_tox_major): + config = newconfig([], "") + + assert config.run_provision is False + assert config.minversion is None + assert config.requires == [] + + def test_provision_tox_change_name(newconfig): config = newconfig( [], @@ -149,6 +179,99 @@ def test_provision_cli_args_not_ignored_if_provision_false(cmd, initproj): result.assert_fail(is_run_test_env=False) +parametrize_json_path = pytest.mark.parametrize("json_path", [None, "missing.json"]) + + +@parametrize_json_path +def test_provision_does_not_fail_with_no_provision_no_reason(cmd, initproj, json_path): + p = initproj("test-0.1", {"tox.ini": "[tox]"}) + result = cmd("--no-provision", *([json_path] if json_path else [])) + result.assert_success(is_run_test_env=True) + assert not (p / "missing.json").exists() + + +@parametrize_json_path +def test_provision_fails_with_no_provision_next_tox(cmd, initproj, next_tox_major, json_path): + p = initproj( + "test-0.1", + { + "tox.ini": """\ + [tox] + minversion = {} + """.format( + next_tox_major, + ) + }, + ) + result = cmd("--no-provision", *([json_path] if json_path else [])) + result.assert_fail(is_run_test_env=False) + if json_path: + missing = json.loads((p / json_path).read_text("utf-8")) + assert missing["minversion"] == next_tox_major + + +@parametrize_json_path +def test_provision_fails_with_no_provision_missing_requires(cmd, initproj, json_path): + p = initproj( + "test-0.1", + { + "tox.ini": """\ + [tox] + requires = + virtualenv > 99999999 + """ + }, + ) + result = cmd("--no-provision", *([json_path] if json_path else [])) + result.assert_fail(is_run_test_env=False) + if json_path: + missing = json.loads((p / json_path).read_text("utf-8")) + assert missing["requires"] == ["virtualenv > 99999999"] + + +@parametrize_json_path +def test_provision_does_not_fail_with_satisfied_requires(cmd, initproj, next_tox_major, json_path): + p = initproj( + "test-0.1", + { + "tox.ini": """\ + [tox] + minversion = 0 + requires = + setuptools > 2 + pip > 3 + """ + }, + ) + result = cmd("--no-provision", *([json_path] if json_path else [])) + result.assert_success(is_run_test_env=True) + assert not (p / "missing.json").exists() + + +@parametrize_json_path +def test_provision_fails_with_no_provision_combined(cmd, initproj, next_tox_major, json_path): + p = initproj( + "test-0.1", + { + "tox.ini": """\ + [tox] + minversion = {} + requires = + setuptools > 2 + pip > 3 + """.format( + next_tox_major, + ) + }, + ) + result = cmd("--no-provision", *([json_path] if json_path else [])) + result.assert_fail(is_run_test_env=False) + if json_path: + missing = json.loads((p / json_path).read_text("utf-8")) + assert missing["minversion"] == next_tox_major + assert missing["requires"] == ["setuptools > 2", "pip > 3"] + + @pytest.fixture(scope="session") def wheel(tmp_path_factory): """create a wheel for a project"""