diff --git a/changelog/814.feature.rst b/changelog/814.feature.rst new file mode 100644 index 000000000..ecf911d72 --- /dev/null +++ b/changelog/814.feature.rst @@ -0,0 +1 @@ +``pyproject.toml`` config support initially by just inline the tox.ini under ``tool.tox.legacy_tox_ini`` key; config source priority order is ``pyproject.toml``, ``tox.ini`` and then ``setup.cfg`` - by :user:`gaborbernat` diff --git a/doc/config.rst b/doc/config.rst index 4b3679e24..c57920e56 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -3,12 +3,23 @@ tox configuration specification =============================== -.. _ConfigParser: https://docs.python.org/3/library/configparser.html +tox supports at the moment three locations for specifying the configuration, in the following priority order: + +1. ``pyproject.toml`` +2. ``tox.ini`` +3. ``setup.cfg`` + +As far as the configuration format at the moment we only support standard ConfigParser_ "ini-style" format +(with planned . +``tox.ini`` and ``setup.cfg`` are files are fully such files. ``pyproject.toml`` on the other hand is a TOML +format. However, one can inline the *ini-style* format under the ``tool.tox.legacy_tox_ini`` key as a multi-line +string. -``tox.ini`` files uses the standard ConfigParser_ "ini-style" format. -Below you find the specification, but you might want to skim some +Below you find the specification for the *ini-style* format, but you might want to skim some :doc:`examples` first and use this page as a reference. +.. _ConfigParser: https://docs.python.org/3/library/configparser.html + tox global settings ------------------- diff --git a/doc/example/basic.rst b/doc/example/basic.rst index c9b3247e7..a23e7614a 100644 --- a/doc/example/basic.rst +++ b/doc/example/basic.rst @@ -59,6 +59,30 @@ The environment ``py`` uses the version of Python used to invoke tox. However, you can also create your own test environment names, see some of the examples in :doc:`examples <../examples>`. +pyproject.toml tox legacy ini +----------------------------- + +It's possible to put the tox ini configuration into the ``pyproject.toml`` file too (if want to avoid +an extra file): + +.. code-block:: toml + + [build-system] + requires = [ "setuptools >= 35.0.2", "wheel >= 0.29.0"] + build-backend = "setuptools.build_meta" + + [tool.tox] + legacy_tox_ini = """ + [tox] + envlist = py27,py36 + + [testenv] + deps = pytest >= 3.0.0, <4 + commands = pytest + """ + +Note that when you define a ``pyproject.toml`` you must define the ``build-requires`` section per PEP-518. + specifying a platform ----------------------------------------------- diff --git a/src/tox/config.py b/src/tox/config.py index 70dcb4253..e9f55c99a 100755 --- a/src/tox/config.py +++ b/src/tox/config.py @@ -16,8 +16,10 @@ import pkg_resources import pluggy import py +import toml import tox +from tox.constants import INFO from tox.interpreters import Interpreters hookimpl = tox.hookimpl @@ -95,7 +97,7 @@ def add_testenv_attribute_obj(self, obj): assert hasattr(obj, "postprocess") self._testenv_attr.append(obj) - def _parse_args(self, args): + def parse_cli(self, args): return self.argparser.parse_args(args) def _format_help(self): @@ -213,49 +215,66 @@ def parseconfig(args, plugins=()): :raise SystemExit: toxinit file is not found """ pm = get_plugin_manager(plugins) - # prepare command line options - parser = Parser() - pm.hook.tox_addoption(parser=parser) - # parse command line options - option = parser._parse_args(args) - interpreters = Interpreters(hook=pm.hook) - config = Config(pluginmanager=pm, option=option, interpreters=interpreters) - config._parser = parser - config._testenv_attr = parser._testenv_attr - if config.option.version: - print(get_version_info(pm)) - raise SystemExit(0) - # parse ini file - basename = config.option.configfile - if os.path.isfile(basename): - inipath = py.path.local(basename) - elif os.path.isdir(basename): - # Assume 'tox.ini' filename if directory was passed - inipath = py.path.local(os.path.join(basename, "tox.ini")) + config, option = parse_cli(args, pm) + + for config_file in propose_configs(option.configfile): + config_type = config_file.basename + + content = None + if config_type == "pyproject.toml": + toml_content = get_py_project_toml(config_file) + try: + content = toml_content["tool"]["tox"]["legacy_tox_ini"] + except KeyError: + continue + ParseIni(config, config_file, content) + pm.hook.tox_configure(config=config) # post process config object + break else: - for path in py.path.local().parts(reverse=True): - inipath = path.join(basename) - if inipath.check(): - break - else: - inipath = py.path.local().join("setup.cfg") - if not inipath.check(): - helpoptions = option.help or option.helpini - feedback("toxini file {!r} not found".format(basename), sysexit=not helpoptions) - if helpoptions: - return config + msg = "tox config file (either {}) not found" + candidates = ", ".join(INFO.CONFIG_CANDIDATES) + feedback(msg.format(candidates), sysexit=not (option.help or option.helpini)) + return config - try: - parseini(config, inipath) - except tox.exception.InterpreterNotFound: - exn = sys.exc_info()[1] - # Use stdout to match test expectations - print("ERROR: {}".format(exn)) - # post process config object - pm.hook.tox_configure(config=config) +def get_py_project_toml(path): + with open(str(path)) as file_handler: + config_data = toml.load(file_handler) + return config_data - return config + +def propose_configs(cli_config_file): + from_folder = py.path.local() + if cli_config_file is not None: + if os.path.isfile(cli_config_file): + yield py.path.local(cli_config_file) + return + if os.path.isdir(cli_config_file): + from_folder = py.path.local(cli_config_file) + else: + print( + "ERROR: {} is neither file or directory".format(cli_config_file), file=sys.stderr + ) + return + for basename in INFO.CONFIG_CANDIDATES: + if from_folder.join(basename).isfile(): + yield from_folder.join(basename) + for path in from_folder.parts(reverse=True): + ini_path = path.join(basename) + if ini_path.check(): + yield ini_path + + +def parse_cli(args, pm): + parser = Parser() + pm.hook.tox_addoption(parser=parser) + option = parser.parse_cli(args) + if option.version: + print(get_version_info(pm)) + raise SystemExit(0) + interpreters = Interpreters(hook=pm.hook) + config = Config(pluginmanager=pm, option=option, interpreters=interpreters, parser=parser) + return config, option def feedback(msg, sysexit=False): @@ -373,7 +392,7 @@ def tox_addoption(parser): parser.add_argument( "-c", action="store", - default="tox.ini", + default=None, dest="configfile", help="config file name or directory with 'tox.ini' file.", ) @@ -763,13 +782,16 @@ def develop(testenv_config, value): class Config(object): """Global Tox config object.""" - def __init__(self, pluginmanager, option, interpreters): + def __init__(self, pluginmanager, option, interpreters, parser): self.envconfigs = {} """Mapping envname -> envconfig""" self.invocationcwd = py.path.local() self.interpreters = interpreters self.pluginmanager = pluginmanager self.option = option + self._parser = parser + self._testenv_attr = parser._testenv_attr + """option namespace containing all parsed command line options""" @property @@ -872,38 +894,36 @@ def make_hashseed(): return str(random.randint(1, max_seed)) -class parseini: - def __init__(self, config, inipath): # noqa - config.toxinipath = inipath +class ParseIni(object): + def __init__(self, config, ini_path, ini_data): # noqa + config.toxinipath = ini_path config.toxinidir = config.toxinipath.dirpath() - self._cfg = py.iniconfig.IniConfig(config.toxinipath) + self._cfg = py.iniconfig.IniConfig(config.toxinipath, ini_data) config._cfg = self._cfg self.config = config - if inipath.basename == "setup.cfg": - prefix = "tox" - else: - prefix = None - ctxname = getcontextname() - if ctxname == "jenkins": + prefix = "tox" if ini_path.basename == "setup.cfg" else None + + context_name = getcontextname() + if context_name == "jenkins": reader = SectionReader( "tox:jenkins", self._cfg, prefix=prefix, fallbacksections=["tox"] ) - distshare_default = "{toxworkdir}/distshare" - elif not ctxname: + dist_share_default = "{toxworkdir}/distshare" + elif not context_name: reader = SectionReader("tox", self._cfg, prefix=prefix) - distshare_default = "{homedir}/.tox/distshare" + dist_share_default = "{homedir}/.tox/distshare" else: raise ValueError("invalid context") if config.option.hashseed is None: - hashseed = make_hashseed() + hash_seed = make_hashseed() elif config.option.hashseed == "noset": - hashseed = None + hash_seed = None else: - hashseed = config.option.hashseed - config.hashseed = hashseed + hash_seed = config.option.hashseed + config.hashseed = hash_seed reader.addsubstitutions(toxinidir=config.toxinidir, homedir=config.homedir) # As older versions of tox may have bugs or incompatibilities that @@ -942,10 +962,10 @@ def __init__(self, config, inipath): # noqa override = False if config.option.indexurl: - for urldef in config.option.indexurl: - m = re.match(r"\W*(\w+)=(\S+)", urldef) + for url_def in config.option.indexurl: + m = re.match(r"\W*(\w+)=(\S+)", url_def) if m is None: - url = urldef + url = url_def name = "default" else: name, url = m.groups() @@ -966,7 +986,7 @@ def __init__(self, config, inipath): # noqa self._make_thread_safe_path(config, "distdir", unique_id) reader.addsubstitutions(distdir=config.distdir) - config.distshare = reader.getpath("distshare", distshare_default) + config.distshare = reader.getpath("distshare", dist_share_default) self._make_thread_safe_path(config, "distshare", unique_id) reader.addsubstitutions(distshare=config.distshare) config.sdistsrc = reader.getpath("sdistsrc", None) diff --git a/src/tox/constants.py b/src/tox/constants.py index 7d035ec9c..bd215625f 100644 --- a/src/tox/constants.py +++ b/src/tox/constants.py @@ -42,6 +42,7 @@ class PYTHON: class INFO: DEFAULT_CONFIG_NAME = "tox.ini" + CONFIG_CANDIDATES = ("pyproject.toml", "tox.ini", "setup.cfg") IS_WIN = sys.platform == "win32" diff --git a/src/tox/package.py b/src/tox/package.py index 54e6558b2..dfb969568 100644 --- a/src/tox/package.py +++ b/src/tox/package.py @@ -6,10 +6,9 @@ import pkg_resources import py import six -import toml import tox -from tox.config import DepConfig +from tox.config import DepConfig, get_py_project_toml BuildInfo = namedtuple("BuildInfo", ["requires", "backend_module", "backend_object"]) @@ -144,8 +143,7 @@ def abort(message): report.error("missing {}".format(toml_file)) raise SystemExit(1) - with open(str(toml_file)) as file_handler: - config_data = toml.load(file_handler) + config_data = get_py_project_toml(toml_file) if "build-system" not in config_data: abort("build-system section missing") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index cf458e99a..d4d940f7c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -2387,7 +2387,8 @@ def test_no_tox_ini(self, cmd, initproj): result = cmd() assert result.ret assert result.out == "" - assert result.err == "ERROR: toxini file 'tox.ini' not found\n" + msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" + assert result.err == msg def test_override_workdir(self, cmd, initproj): baddir = "badworkdir-123" @@ -2624,3 +2625,49 @@ def test_isolated_build_env_cannot_be_in_envlist(newconfig, capsys): out, err = capsys.readouterr() assert not err assert not out + + +def test_config_via_pyproject_legacy(initproj): + initproj( + "config_via_pyproject_legacy-0.5", + filedefs={ + "pyproject.toml": ''' + [tool.tox] + legacy_tox_ini = """ + [tox] + envlist = py27 + """ + ''' + }, + ) + config = parseconfig([]) + assert config.envlist == ["py27"] + + +def test_config_bad_pyproject_specified(initproj, capsys): + base = initproj("config_via_pyproject_legacy-0.5", filedefs={"pyproject.toml": ""}) + with pytest.raises(SystemExit): + parseconfig(["-c", str(base.join("pyproject.toml"))]) + + out, err = capsys.readouterr() + msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" + assert err == msg + assert "ERROR:" not in out + + +@pytest.mark.skipif(sys.platform == "win32", reason="no named pipes on Windows") +def test_config_bad_config_type_specified(monkeypatch, tmpdir, capsys): + monkeypatch.chdir(tmpdir) + name = tmpdir.join("named_pipe") + os.mkfifo(str(name)) + with pytest.raises(SystemExit): + parseconfig(["-c", str(name)]) + + out, err = capsys.readouterr() + notes = ( + "ERROR: {} is neither file or directory".format(name), + "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found", + ) + msg = "\n".join(notes) + "\n" + assert err == msg + assert "ERROR:" not in out diff --git a/tests/unit/test_z_cmdline.py b/tests/unit/test_z_cmdline.py index 20eed9f75..65c9a61ee 100644 --- a/tests/unit/test_z_cmdline.py +++ b/tests/unit/test_z_cmdline.py @@ -107,7 +107,8 @@ def test_getvenv(self, initproj): def test_notoxini_help_still_works(initproj, cmd): initproj("example123-0.5", filedefs={"tests": {"test_hello.py": "def test_hello(): pass"}}) result = cmd("-h") - assert result.err == "ERROR: toxini file 'tox.ini' not found\n" + msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" + assert result.err == msg assert result.out.startswith("usage: ") assert any("--help" in l for l in result.outlines), result.outlines assert not result.ret